opentakeoff-mcp 0.9.87 → 0.9.90

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 +660 -85
  2. package/package.json +2 -2
@@ -311,6 +311,84 @@ function detectScale(textContent, viewport) {
311
311
  return null;
312
312
  }
313
313
 
314
+ // ../web/src/lib/textjoin.ts
315
+ var JOIN_GAP = 0.08;
316
+ var JOIN_OVERLAP = 0.05;
317
+ var LINE_TOL = 0.25;
318
+ var SIZE_TOL = 0.25;
319
+ function axis(s) {
320
+ const r = s.rot ?? 0;
321
+ if (r === 90) return { a0: s.y0, a1: s.y1, p: (s.x0 + s.x1) / 2, h: s.x1 - s.x0 };
322
+ if (r === 270) return { a0: -s.y1, a1: -s.y0, p: (s.x0 + s.x1) / 2, h: s.x1 - s.x0 };
323
+ if (r === 180) return { a0: -s.x1, a1: -s.x0, p: (s.y0 + s.y1) / 2, h: s.y1 - s.y0 };
324
+ return { a0: s.x0, a1: s.x1, p: (s.y0 + s.y1) / 2, h: s.y1 - s.y0 };
325
+ }
326
+ function abuttingGroups(spans) {
327
+ const n = spans.length;
328
+ const next = new Int32Array(n).fill(-1);
329
+ const hasPrev = new Uint8Array(n);
330
+ const byRot = /* @__PURE__ */ new Map();
331
+ for (let i = 0; i < n; i++) {
332
+ const r = spans[i].rot ?? 0;
333
+ let l = byRot.get(r);
334
+ if (!l) byRot.set(r, l = []);
335
+ l.push(i);
336
+ }
337
+ const ax = spans.map(axis);
338
+ for (const [rot, idx] of byRot) {
339
+ if (rot % 90 !== 0) continue;
340
+ idx.sort((i, j) => ax[i].a0 - ax[j].a0);
341
+ for (let k = 0; k < idx.length; k++) {
342
+ const i = idx[k], A = ax[i];
343
+ if (!(A.h > 0) || !spans[i].str) continue;
344
+ let best = -1, bestGap = Infinity;
345
+ for (let m = k + 1; m < idx.length; m++) {
346
+ const j = idx[m], B = ax[j];
347
+ if (B.a0 - A.a1 > JOIN_GAP * A.h) break;
348
+ if (!(B.h > 0) || !spans[j].str) continue;
349
+ const h = Math.max(A.h, B.h);
350
+ if (Math.abs(A.h - B.h) > SIZE_TOL * h) continue;
351
+ if (Math.abs(A.p - B.p) > LINE_TOL * h) continue;
352
+ const gap = B.a0 - A.a1;
353
+ if (gap < -JOIN_OVERLAP * h || gap > JOIN_GAP * h) continue;
354
+ if (/\d$/.test(spans[i].str) && /^\d/.test(spans[j].str)) continue;
355
+ if (Math.abs(gap) < Math.abs(bestGap)) {
356
+ best = j;
357
+ bestGap = gap;
358
+ }
359
+ }
360
+ if (best >= 0 && !hasPrev[best] && next[i] < 0) {
361
+ next[i] = best;
362
+ hasPrev[best] = 1;
363
+ }
364
+ }
365
+ }
366
+ const groups = [];
367
+ for (let i = 0; i < n; i++) {
368
+ if (hasPrev[i]) continue;
369
+ const g = [i];
370
+ for (let j = next[i], guard = 0; j >= 0 && guard < n; j = next[j], guard++) g.push(j);
371
+ groups.push(g);
372
+ }
373
+ return groups;
374
+ }
375
+ function joinAbuttingSpans(spans) {
376
+ return abuttingGroups(spans).map((g) => {
377
+ if (g.length === 1) return spans[g[0]];
378
+ const first = spans[g[0]];
379
+ let { x0, y0, x1, y1 } = first, str = first.str;
380
+ for (const j of g.slice(1)) {
381
+ const s = spans[j];
382
+ str += s.str;
383
+ x0 = Math.min(x0, s.x0);
384
+ y0 = Math.min(y0, s.y0);
385
+ x1 = Math.max(x1, s.x1);
386
+ y1 = Math.max(y1, s.y1);
387
+ }
388
+ return { ...first, str, x0, y0, x1, y1 };
389
+ });
390
+ }
391
+
314
392
  // src/pdf.ts
315
393
  var requireHere = createRequire(import.meta.url);
316
394
  var PDFJS_ROOT = path.dirname(requireHere.resolve("pdfjs-dist/package.json"));
@@ -422,14 +500,7 @@ async function openPdf(filePath) {
422
500
  };
423
501
  }
424
502
  function positionedText(ph) {
425
- const out = [];
426
- for (const it of ph.textContent.items || []) {
427
- const str = it.str || "";
428
- if (!str.trim()) continue;
429
- const t = pdfjs.Util.transform(ph.viewport.transform, it.transform);
430
- out.push({ str, x: +t[4].toFixed(1), y: +t[5].toFixed(1) });
431
- }
432
- return out;
503
+ return joinAbuttingSpans(rawSpans(ph)).map((s) => ({ str: s.str, x: s.ox, y: s.oy }));
433
504
  }
434
505
  function textItemsInRegion(ph, r) {
435
506
  const items = [];
@@ -440,7 +511,7 @@ function textItemsInRegion(ph, r) {
440
511
  }
441
512
  return { items };
442
513
  }
443
- function textSpans(ph) {
514
+ function rawSpans(ph) {
444
515
  const out = [];
445
516
  for (const it of ph.textContent.items || []) {
446
517
  const str = it.str || "";
@@ -458,6 +529,8 @@ function textSpans(ph) {
458
529
  const rot = (Math.round(Math.atan2(dy, dx) * 180 / Math.PI) % 360 + 360) % 360;
459
530
  out.push({
460
531
  str,
532
+ ox: +x.toFixed(1),
533
+ oy: +y.toFixed(1),
461
534
  x0: +Math.min(...xs).toFixed(1),
462
535
  y0: +Math.min(...ys).toFixed(1),
463
536
  x1: +Math.max(...xs).toFixed(1),
@@ -467,6 +540,9 @@ function textSpans(ph) {
467
540
  }
468
541
  return out;
469
542
  }
543
+ function textSpans(ph) {
544
+ return joinAbuttingSpans(rawSpans(ph)).map(({ ox: _ox, oy: _oy, ...span }) => span);
545
+ }
470
546
 
471
547
  // src/scalewarn.ts
472
548
  var SCALE_NOTE_MARGIN_XY = 0.1;
@@ -830,6 +906,13 @@ function escalationParams(sensitivity) {
830
906
  }
831
907
  function strokeLuminance(args) {
832
908
  const first = args?.[0];
909
+ if (typeof first === "string") {
910
+ const hex2 = /^#([0-9a-f]{6})$/i.exec(first);
911
+ if (!hex2) return null;
912
+ const v = parseInt(hex2[1], 16);
913
+ const L2 = 0.2126 * (v >> 16 & 255) + 0.7152 * (v >> 8 & 255) + 0.0722 * (v & 255);
914
+ return Math.max(0, Math.min(255, Math.round(L2)));
915
+ }
833
916
  const a = Array.isArray(first) || ArrayBuffer.isView(first) && !(first instanceof DataView) ? first : args;
834
917
  if (!a || a.length < 3) return null;
835
918
  const [r, g, b] = [a[0], a[1], a[2]].map((v) => typeof v === "number" && Number.isFinite(v) ? v : NaN);
@@ -838,6 +921,43 @@ function strokeLuminance(args) {
838
921
  const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) * k;
839
922
  return Math.max(0, Math.min(255, Math.round(L)));
840
923
  }
924
+ var DRAW_MOVETO = 0;
925
+ var DRAW_LINETO = 1;
926
+ var DRAW_CURVETO = 2;
927
+ var DRAW_CLOSEPATH = 3;
928
+ function decodeConstructPath(args, OPS3) {
929
+ if (!args) return null;
930
+ const first = args[0];
931
+ if (Array.isArray(first) || ArrayBuffer.isView(first)) {
932
+ return { ops: first, co: args[1], paintFn: null };
933
+ }
934
+ if (typeof first !== "number") return null;
935
+ const flat = Array.isArray(args[1]) ? args[1][0] : null;
936
+ if (!flat || !(Array.isArray(flat) || ArrayBuffer.isView(flat))) return null;
937
+ const f = flat;
938
+ const ops = [], co = [];
939
+ for (let k = 0; k < f.length; ) {
940
+ const c = f[k++];
941
+ if (c === DRAW_MOVETO) {
942
+ ops.push(OPS3.moveTo);
943
+ co.push(f[k++], f[k++]);
944
+ } else if (c === DRAW_LINETO) {
945
+ ops.push(OPS3.lineTo);
946
+ co.push(f[k++], f[k++]);
947
+ } else if (c === DRAW_CURVETO) {
948
+ ops.push(OPS3.curveTo);
949
+ co.push(f[k++], f[k++], f[k++], f[k++], f[k++], f[k++]);
950
+ } else if (c === DRAW_CLOSEPATH) {
951
+ ops.push(OPS3.closePath);
952
+ } else break;
953
+ }
954
+ return { ops, co, paintFn: first };
955
+ }
956
+ function embeddedPaintFlags(paintFn, OPS3) {
957
+ if (paintFn === OPS3.endPath) return SEG_CLIP;
958
+ if (paintFn === OPS3.fill || paintFn === OPS3.eoFill) return SEG_FILLONLY;
959
+ return 0;
960
+ }
841
961
  function extractVectorGeometry(opList, transform, OPS3) {
842
962
  const points = [];
843
963
  const segs = [];
@@ -978,9 +1098,11 @@ function extractVectorGeometry(opList, transform, OPS3) {
978
1098
  if (t) imageArea += ctmDet * Math.abs(t[0] * t[3] - t[1] * t[2]);
979
1099
  }
980
1100
  } else if (fn === OPS3.constructPath) {
1101
+ const decoded = decodeConstructPath(args, OPS3);
1102
+ if (!decoded) continue;
981
1103
  const devW = Math.min(15, Math.max(0, Math.ceil((lw || 0) * Math.sqrt(Math.abs(m[0] * m[3] - m[1] * m[2])))));
982
- const flags = paintFlags(i) | devW << 4;
983
- const ops = args[0], co = args[1];
1104
+ const flags = (decoded.paintFn === null ? paintFlags(i) : embeddedPaintFlags(decoded.paintFn, OPS3)) | devW << 4;
1105
+ const ops = decoded.ops, co = decoded.co;
984
1106
  let c = 0, cur = null, start = null;
985
1107
  const pathLayer = curLayer;
986
1108
  const pathLum = lum;
@@ -999,7 +1121,8 @@ function extractVectorGeometry(opList, transform, OPS3) {
999
1121
  cur = p;
1000
1122
  visit(p);
1001
1123
  };
1002
- for (const op of ops) {
1124
+ for (let oi = 0; oi < ops.length; oi++) {
1125
+ const op = ops[oi];
1003
1126
  if (op === OPS3.moveTo) {
1004
1127
  cur = tx(co[c], co[c + 1]);
1005
1128
  start = cur;
@@ -1760,8 +1883,8 @@ function sweepDimensionStrings(segs, meta, ws, ftPx, rejects, dimTexts) {
1760
1883
  if (claimed[c.i]) continue;
1761
1884
  let b = Math.floor((c.ang + shift) / BIN);
1762
1885
  if (b >= NB) b -= NB;
1763
- const axis = ((b * BIN - shift + BIN / 2) % 180 + 180) % 180;
1764
- const th = axis * Math.PI / 180, dx = Math.cos(th), dy = Math.sin(th), nx = -dy, ny = dx;
1886
+ const axis2 = ((b * BIN - shift + BIN / 2) % 180 + 180) % 180;
1887
+ const th = axis2 * Math.PI / 180, dx = Math.cos(th), dy = Math.sin(th), nx = -dy, ny = dx;
1765
1888
  const r = {
1766
1889
  c,
1767
1890
  d: (c.x1 + c.x2) / 2 * nx + (c.y1 + c.y2) / 2 * ny,
@@ -1773,7 +1896,7 @@ function sweepDimensionStrings(segs, meta, ws, ftPx, rejects, dimTexts) {
1773
1896
  else bins.set(b, [r]);
1774
1897
  }
1775
1898
  for (const [b, rows] of bins) {
1776
- const axis = ((b * BIN - shift + BIN / 2) % 180 + 180) % 180;
1899
+ const axis2 = ((b * BIN - shift + BIN / 2) % 180 + 180) % 180;
1777
1900
  rows.sort((a, z4) => a.d - z4.d || a.t0 - z4.t0);
1778
1901
  let g0 = 0;
1779
1902
  for (let k = 1; k <= rows.length; k++) {
@@ -1789,7 +1912,7 @@ function sweepDimensionStrings(segs, meta, ws, ftPx, rejects, dimTexts) {
1789
1912
  }
1790
1913
  cur.d = cur.dSum / cur.members.length;
1791
1914
  cur.len = cur.t1 - cur.t0;
1792
- const th = axis * Math.PI / 180, dx = Math.cos(th), dy = Math.sin(th), nx = -dy, ny = dx;
1915
+ const th = axis2 * Math.PI / 180, dx = Math.cos(th), dy = Math.sin(th), nx = -dy, ny = dx;
1793
1916
  cur.p0 = [cur.t0 * dx + cur.d * nx, cur.t0 * dy + cur.d * ny];
1794
1917
  cur.p1 = [cur.t1 * dx + cur.d * nx, cur.t1 * dy + cur.d * ny];
1795
1918
  chains.push(cur);
@@ -1804,7 +1927,7 @@ function sweepDimensionStrings(segs, meta, ws, ftPx, rejects, dimTexts) {
1804
1927
  cur.dSum += r.d;
1805
1928
  } else {
1806
1929
  flush();
1807
- 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] };
1930
+ cur = { axis: axis2, d: 0, dSum: r.d, t0: r.t0, t1: r.t1, len: 0, members: [r.c.i], p0: [0, 0], p1: [0, 0] };
1808
1931
  }
1809
1932
  }
1810
1933
  flush();
@@ -2775,14 +2898,14 @@ function sealAttempt(mo, ix, iy, sensitivity, radii, minPassPx = 0, given) {
2775
2898
  const { mw, mh, mask, ws } = mo;
2776
2899
  const isDim = (i) => (mask[i] & MASK_DIMSTR_BIT) !== 0 && (mask[i] & 1) === 0;
2777
2900
  const capCells = mw * mh * SEAL_MAX_SHEET_FRAC;
2778
- const box = boxOf(ref.region, mw, mh);
2901
+ const box2 = boxOf(ref.region, mw, mh);
2779
2902
  const region = ref.region.slice();
2780
2903
  let count = ref.count, mergedPieces = 0;
2781
2904
  const tried = /* @__PURE__ */ new Set();
2782
2905
  let frontier = true;
2783
2906
  for (let pass = 0; pass < 8 && frontier; pass++) {
2784
2907
  frontier = false;
2785
- 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) };
2908
+ const b = { x0: Math.max(1, box2.x0 - 2), y0: Math.max(1, box2.y0 - 2), x1: Math.min(mw - 2, box2.x1 + 2), y1: Math.min(mh - 2, box2.y1 + 2) };
2786
2909
  for (let y = b.y0; y <= b.y1; y++) for (let x = b.x0; x <= b.x1; x++) {
2787
2910
  const i = y * mw + x;
2788
2911
  if (!isDim(i)) continue;
@@ -2819,10 +2942,10 @@ function sealAttempt(mo, ix, iy, sensitivity, radii, minPassPx = 0, given) {
2819
2942
  count++;
2820
2943
  }
2821
2944
  const fb = boxOf(fr.region, mw, mh);
2822
- box.x0 = Math.min(box.x0, fb.x0);
2823
- box.y0 = Math.min(box.y0, fb.y0);
2824
- box.x1 = Math.max(box.x1, fb.x1);
2825
- box.y1 = Math.max(box.y1, fb.y1);
2945
+ box2.x0 = Math.min(box2.x0, fb.x0);
2946
+ box2.y0 = Math.min(box2.y0, fb.y0);
2947
+ box2.x1 = Math.max(box2.x1, fb.x1);
2948
+ box2.y1 = Math.max(box2.y1, fb.y1);
2826
2949
  mergedPieces++;
2827
2950
  frontier = true;
2828
2951
  }
@@ -6164,6 +6287,14 @@ function flattenArcRing(pts, throughAt, closed2 = true) {
6164
6287
  }
6165
6288
 
6166
6289
  // ../web/src/lib/shapeMetrics.js
6290
+ function linearVerticalFt(s, cond) {
6291
+ if (s?.origin?.derived) return { rise: 0, drop: 0 };
6292
+ const pick = (own, dflt) => {
6293
+ const v = own != null && own !== "" ? Number(own) : Number(dflt);
6294
+ return Number.isFinite(v) && v > 0 ? v : 0;
6295
+ };
6296
+ return { rise: pick(s?.rise_ft, cond?.rise_ft), drop: pick(s?.drop_ft, cond?.drop_ft) };
6297
+ }
6167
6298
  function computeShapeMetrics(s, dims, upp, cond) {
6168
6299
  const pts = (s.verts_norm || []).map(([nx, ny]) => [nx * dims.w, ny * dims.h]);
6169
6300
  const u = upp || 0;
@@ -6174,9 +6305,16 @@ function computeShapeMetrics(s, dims, upp, cond) {
6174
6305
  return { area_sf: +(LF * h).toFixed(2), perimeter_lf: +LF.toFixed(2) };
6175
6306
  }
6176
6307
  if (s.measure_role === "linear") {
6177
- const LF = openLen(s.curved ? flattenCurve(pts) : pts) * u;
6308
+ const plan = openLen(s.curved ? flattenCurve(pts) : pts) * u;
6309
+ const { rise, drop } = linearVerticalFt(s, cond);
6310
+ const vert = rise + drop;
6311
+ const LF = plan + vert;
6178
6312
  const tIn = Number(cond?.thickness_in) || 0;
6179
- return { perimeter_lf: +LF.toFixed(2), area_sf: tIn > 0 ? +(LF * tIn / 12).toFixed(2) : 0 };
6313
+ return {
6314
+ perimeter_lf: +LF.toFixed(2),
6315
+ area_sf: tIn > 0 ? +(LF * tIn / 12).toFixed(2) : 0,
6316
+ ...vert > 0 ? { plan_lf: +plan.toFixed(2), vertical_lf: +vert.toFixed(2) } : {}
6317
+ };
6180
6318
  }
6181
6319
  const holesPx = (s.verts_norm_holes || []).map((ring) => ring.map(([nx, ny]) => [nx * dims.w, ny * dims.h]));
6182
6320
  const met = holesPx.length ? polyWithHolesMetrics(pts, holesPx) : closedMetrics(pts);
@@ -6303,25 +6441,25 @@ function sharedRuns(ringA, ringB, opts) {
6303
6441
  for (const r of raw) {
6304
6442
  const idx = [];
6305
6443
  for (let i = r.from; i <= r.to; i++) idx.push(i % samples.length);
6306
- const path5 = idx.map((i) => samples[i]);
6444
+ const path6 = idx.map((i) => samples[i]);
6307
6445
  if (idx.length < samples.length) {
6308
6446
  const before = samples[((r.from - 1) % samples.length + samples.length) % samples.length];
6309
6447
  const after = samples[(r.to + 1) % samples.length];
6310
- const head2 = refineEnd(path5[0], before);
6311
- const tail = refineEnd(path5[path5.length - 1], after);
6312
- if (Math.hypot(head2[0] - path5[0][0], head2[1] - path5[0][1]) > 1e-6) path5.unshift(head2);
6313
- if (Math.hypot(tail[0] - path5[path5.length - 1][0], tail[1] - path5[path5.length - 1][1]) > 1e-6) path5.push(tail);
6448
+ const head2 = refineEnd(path6[0], before);
6449
+ const tail = refineEnd(path6[path6.length - 1], after);
6450
+ if (Math.hypot(head2[0] - path6[0][0], head2[1] - path6[0][1]) > 1e-6) path6.unshift(head2);
6451
+ if (Math.hypot(tail[0] - path6[path6.length - 1][0], tail[1] - path6[path6.length - 1][1]) > 1e-6) path6.push(tail);
6314
6452
  }
6315
6453
  let length_px = 0;
6316
- for (let i = 1; i < path5.length; i++) {
6317
- length_px += Math.hypot(path5[i][0] - path5[i - 1][0], path5[i][1] - path5[i - 1][1]);
6454
+ for (let i = 1; i < path6.length; i++) {
6455
+ length_px += Math.hypot(path6[i][0] - path6[i - 1][0], path6[i][1] - path6[i - 1][1]);
6318
6456
  }
6319
6457
  if (length_px < min_len_px) continue;
6320
6458
  const gap_px = median(idx.map((i) => dists[i]));
6321
- const mid = path5[path5.length >> 1];
6459
+ const mid = path6[path6.length >> 1];
6322
6460
  runs.push({
6323
6461
  kind: gap_px <= touch_px ? "butt" : "wall",
6324
- path: path5,
6462
+ path: path6,
6325
6463
  length_px,
6326
6464
  gap_px,
6327
6465
  at: [mid[0], mid[1]]
@@ -6332,18 +6470,18 @@ function sharedRuns(ringA, ringB, opts) {
6332
6470
  var TRANSITION_DEFAULTS = { max_gap_in: 12, min_run_in: 12 };
6333
6471
  var round22 = (n) => +n.toFixed(2);
6334
6472
  var round12 = (n) => +n.toFixed(1);
6335
- function dropCollinear(path5, tol = 1e-6) {
6336
- if (path5.length < 3) return path5.map((p) => [p[0], p[1]]);
6337
- const out = [[path5[0][0], path5[0][1]]];
6338
- for (let i = 1; i < path5.length - 1; i++) {
6339
- const a = out[out.length - 1], b = path5[i], c = path5[i + 1];
6473
+ function dropCollinear(path6, tol = 1e-6) {
6474
+ if (path6.length < 3) return path6.map((p) => [p[0], p[1]]);
6475
+ const out = [[path6[0][0], path6[0][1]]];
6476
+ for (let i = 1; i < path6.length - 1; i++) {
6477
+ const a = out[out.length - 1], b = path6[i], c = path6[i + 1];
6340
6478
  const abx = b[0] - a[0], aby = b[1] - a[1];
6341
6479
  const acx = c[0] - a[0], acy = c[1] - a[1];
6342
6480
  const len = Math.hypot(acx, acy);
6343
6481
  const off = len > 0 ? Math.abs(abx * acy - aby * acx) / len : Infinity;
6344
6482
  if (off > tol) out.push([b[0], b[1]]);
6345
6483
  }
6346
- out.push([path5[path5.length - 1][0], path5[path5.length - 1][1]]);
6484
+ out.push([path6[path6.length - 1][0], path6[path6.length - 1][1]]);
6347
6485
  return out;
6348
6486
  }
6349
6487
  function deriveTransitionRuns(a, b, sheets, opts = {}) {
@@ -8949,11 +9087,24 @@ var Session = class _Session {
8949
9087
  const s = this.sheet(name);
8950
9088
  if (s.upp == null) throw new UserError(this.scaleGate(s));
8951
9089
  const { pts: run2, arcs } = this.bend(pts, opts.arc_through, false, "measure_line");
8952
- const { perimeter_lf: length_lf = 0 } = this.quantify(s, "linear", run2);
9090
+ const own = { ...opts.rise_ft !== void 0 ? { rise_ft: opts.rise_ft } : {}, ...opts.drop_ft !== void 0 ? { drop_ft: opts.drop_ft } : {} };
9091
+ const vertical = this.verticalFor(opts.condition, own);
9092
+ const computed = this.quantify(s, "linear", run2, void 0, vertical);
9093
+ const length_lf = computed.perimeter_lf ?? 0;
8953
9094
  let shape_id;
8954
- if (opts.condition) shape_id = this.commit(s, opts.condition, "linear", run2, { area_sf: 0, perimeter_lf: length_lf }, { method: "manual", actor: "agent", ...arcs ? { curved: true } : {} }).id;
9095
+ if (opts.condition) {
9096
+ const shape = this.commit(s, opts.condition, "linear", run2, computed, { method: "manual", actor: "agent", ...arcs ? { curved: true } : {} });
9097
+ Object.assign(shape, own);
9098
+ shape_id = shape.id;
9099
+ }
8955
9100
  this.flushCommits("measure_line");
8956
- return { length_lf, npts: run2.length, ...arcs ? { arcs } : {}, ...shape_id ? { shape_id } : {} };
9101
+ return {
9102
+ length_lf,
9103
+ npts: run2.length,
9104
+ ...computed.vertical_lf ? { plan_lf: computed.plan_lf, vertical_lf: computed.vertical_lf, rise_ft: vertical.rise, drop_ft: vertical.drop } : {},
9105
+ ...arcs ? { arcs } : {},
9106
+ ...shape_id ? { shape_id } : {}
9107
+ };
8957
9108
  }
8958
9109
  /** Surface Area — the canvas's Surface tool (commitSurface): an OPEN run
8959
9110
  * traced along the wall in plan view, quantified as traced LF × height.
@@ -9004,14 +9155,31 @@ var Session = class _Session {
9004
9155
  }
9005
9156
  return p;
9006
9157
  }
9158
+ /** A linear run's quantities from its plan length and vertical legs (#441)
9159
+ * — the server twin of shapeMetrics' linear branch: perimeter_lf is the
9160
+ * TOTAL the summers read; plan_lf / vertical_lf appear only when a leg
9161
+ * exists, so a flat run's record is what it always was. area_sf stays 0 —
9162
+ * the canvas mints border SF only from a condition thickness. */
9163
+ static linearQty(planLf, vertical) {
9164
+ const vert = (vertical?.rise ?? 0) + (vertical?.drop ?? 0);
9165
+ const plan = round2(planLf);
9166
+ return { area_sf: 0, perimeter_lf: round2(planLf + vert), ...vert > 0 ? { plan_lf: plan, vertical_lf: round2(vert) } : {} };
9167
+ }
9168
+ /** The legs a linear run resolves to: its own rise_ft / drop_ft where
9169
+ * given, else the condition's defaults (by tag — the condition may not
9170
+ * exist yet on a first commit, in which case there are no defaults). */
9171
+ verticalFor(tag, own) {
9172
+ const cond = tag ? this.conditions.find((x) => _Session.tagKey(x.finish_tag) === _Session.tagKey(tag)) : void 0;
9173
+ return linearVerticalFt(own, cond);
9174
+ }
9007
9175
  /** The quantities a shape of `role` carries for `verts` on sheet `s` — the
9008
9176
  * same arithmetic measure_polygon / measure_line / measure_surface /
9009
9177
  * place_count run, in one place so a revised batch measures exactly as a
9010
9178
  * fresh commit would. */
9011
- quantify(s, role, verts, heightFt) {
9179
+ quantify(s, role, verts, heightFt, vertical) {
9012
9180
  if (role === "count") return { count: 1 };
9013
9181
  const upp = s.upp ?? 0;
9014
- if (role === "linear") return { area_sf: 0, perimeter_lf: round2(openLen(verts) * upp) };
9182
+ if (role === "linear") return _Session.linearQty(openLen(verts) * upp, vertical);
9015
9183
  if (role === "surface_area") {
9016
9184
  const LF = openLen(verts) * upp;
9017
9185
  return { area_sf: round2(LF * (heightFt ?? 0)), perimeter_lf: round2(LF) };
@@ -9145,6 +9313,8 @@ var Session = class _Session {
9145
9313
  if (proposed.waste_pct !== void 0 && proposed.waste_pct !== c.waste_pct) diff.waste_pct = proposed.waste_pct;
9146
9314
  if (proposed.multiplier !== void 0 && proposed.multiplier !== c.multiplier) diff.multiplier = proposed.multiplier;
9147
9315
  if (proposed.height_ft !== void 0 && proposed.height_ft !== c.height_ft) diff.height_ft = proposed.height_ft;
9316
+ if (proposed.rise_ft !== void 0 && proposed.rise_ft !== (c.rise_ft ?? 0)) diff.rise_ft = proposed.rise_ft;
9317
+ if (proposed.drop_ft !== void 0 && proposed.drop_ft !== (c.drop_ft ?? 0)) diff.drop_ft = proposed.drop_ft;
9148
9318
  if (proposed.roll_setup !== void 0) {
9149
9319
  if (proposed.roll_setup === null) {
9150
9320
  if (c.roll_setup) diff.roll_setup = null;
@@ -9199,6 +9369,7 @@ var Session = class _Session {
9199
9369
  const before = structuredClone(c);
9200
9370
  const { finish_tag, ...knobs } = proposal.proposed;
9201
9371
  this.applyConditionKnobs(c, knobs);
9372
+ if (knobs.rise_ft !== void 0 || knobs.drop_ft !== void 0) this.reflowLinears(c);
9202
9373
  if (finish_tag !== void 0) c.finish_tag = finish_tag;
9203
9374
  this.conditionEditProposals.splice(i, 1);
9204
9375
  this.record({ op: "condition_proposal_accept", tool: "accept_condition_edit", proposal, index: i, before });
@@ -9210,9 +9381,30 @@ var Session = class _Session {
9210
9381
  waste_pct: c.waste_pct,
9211
9382
  multiplier: c.multiplier,
9212
9383
  ...c.height_ft !== void 0 ? { height_ft: c.height_ft } : {},
9384
+ ...c.rise_ft !== void 0 ? { rise_ft: c.rise_ft } : {},
9385
+ ...c.drop_ft !== void 0 ? { drop_ft: c.drop_ft } : {},
9213
9386
  ...c.roll_setup ? { roll_setup: c.roll_setup } : {}
9214
9387
  };
9215
9388
  }
9389
+ /** Re-price every linear run of `c` from its geometry and the legs it now
9390
+ * resolves to (#441) — the canvas's setCondParam re-flow, on the server. A
9391
+ * run carrying its own rise_ft / drop_ft keeps that field; a derived run
9392
+ * never takes a leg. Nothing is journaled here: the condition step that
9393
+ * called it is the undo unit, and undo calls it again with the old values. */
9394
+ reflowLinears(c) {
9395
+ for (const sh of this.shapes) {
9396
+ if (sh.condition_id !== c.id || sh.measure_role !== "linear") continue;
9397
+ const s = this.sheets.get(sh.sheet_id);
9398
+ if (!s || s.upp == null) continue;
9399
+ const px = sh.verts_norm.map(([nx, ny]) => [nx * s.widthPx, ny * s.heightPx]);
9400
+ const vertical = sh.origin?.derived ? { rise: 0, drop: 0 } : linearVerticalFt(sh, c);
9401
+ sh.computed = { ...sh.computed, ..._Session.linearQty(openLen(px) * (s.upp ?? 0), vertical) };
9402
+ if (!vertical.rise && !vertical.drop) {
9403
+ delete sh.computed.plan_lf;
9404
+ delete sh.computed.vertical_lf;
9405
+ }
9406
+ }
9407
+ }
9216
9408
  /** The pending condition diffs as the report and the summary print them:
9217
9409
  * current values beside proposed, per condition. */
9218
9410
  proposedConditionEdits() {
@@ -9587,22 +9779,26 @@ var Session = class _Session {
9587
9779
  };
9588
9780
  const wasLf = parent.computed?.perimeter_lf ?? 0;
9589
9781
  const wasSf = parent.computed?.area_sf ?? 0;
9590
- const qty = (lenPx) => {
9591
- const lf = round2(lenPx * upp);
9592
- const k = wasLf > 0 ? lf / wasLf : 0;
9593
- return { area_sf: round2(wasSf * k), perimeter_lf: lf };
9782
+ const parentCond = this.conditions.find((x) => x.id === parent.condition_id);
9783
+ const parentLegs = parent.origin?.derived ? { rise: 0, drop: 0 } : linearVerticalFt(parent, parentCond);
9784
+ const wasPlan = parent.computed?.plan_lf ?? wasLf;
9785
+ const qty = (lenPx, legs) => {
9786
+ const plan = round2(lenPx * upp);
9787
+ const k = wasPlan > 0 ? plan / wasPlan : 0;
9788
+ return { ..._Session.linearQty(plan, legs), area_sf: round2(wasSf * k) };
9594
9789
  };
9595
9790
  const toNorm = (run2) => run2.map(([x, y]) => [x / s.widthPx, y / s.heightPx]);
9596
9791
  const survivors = r.runs ?? [];
9597
9792
  const [head2, ...rest] = survivors;
9598
9793
  if (!head2) throw new UserError(`That ring covers the whole of ${parent.id}. Removing a run outright is delete_shape, not a cut.`);
9599
9794
  parent.verts_norm = toNorm(head2);
9600
- parent.computed = qty(openLen(head2));
9795
+ parent.computed = qty(openLen(head2), parentLegs);
9601
9796
  const minted = rest.map((piece) => ({
9602
9797
  ...structuredClone(parent),
9603
9798
  id: uid("shp"),
9604
9799
  verts_norm: toNorm(piece),
9605
- computed: qty(openLen(piece))
9800
+ ...parentLegs.rise || parentLegs.drop ? { rise_ft: 0, drop_ft: 0 } : {},
9801
+ computed: qty(openLen(piece), { rise: 0, drop: 0 })
9606
9802
  }));
9607
9803
  this.shapes.push(...minted);
9608
9804
  this.record({ op: "runcut", tool: "cut_out", target_id: parent.id, target_prev, minted_ids: minted.map((m) => m.id) });
@@ -9613,7 +9809,7 @@ var Session = class _Session {
9613
9809
  pieces,
9614
9810
  removed_lf: round2(Math.max(0, wasLf - pieces.reduce((n, p) => n + p.lf, 0))),
9615
9811
  removed_sf: round2(Math.max(0, wasSf - pieces.reduce((n, p) => n + p.sf, 0))),
9616
- note: minted.length ? `The cut fell inside the run, so it comes back in ${pieces.length} pieces \u2014 same condition, same height, each measured on its own. One undo_last puts the run back whole.` : "The run keeps its id and its condition; only its length (and the SF that rides on it) changed. One undo_last puts it back whole."
9812
+ note: minted.length ? `The cut fell inside the run, so it comes back in ${pieces.length} pieces \u2014 same condition, same height, each measured on its own${parentLegs.rise || parentLegs.drop ? `; the run's rise/drop stay on ${parent.id}, the new piece(s) carry none (rise_ft/drop_ft 0 \u2014 edit_shape to move a leg)` : ""}. One undo_last puts the run back whole.` : "The run keeps its id and its condition; only its length (and the SF that rides on it) changed. One undo_last puts it back whole."
9617
9813
  };
9618
9814
  }
9619
9815
  /** The canvas's cutoutParentPrevSans, ported verbatim in spirit (#206 —
@@ -10708,8 +10904,15 @@ var Session = class _Session {
10708
10904
  if (cur.origin?.reviewed === true) {
10709
10905
  throw new UserError(`Shape ${JSON.stringify(id)} was affirmed by a human \u2014 reviewed work is ink, not pencil, and cannot be edited by an agent.`);
10710
10906
  }
10711
- if (patch.verts === void 0 && patch.condition === void 0 && patch.role === void 0 && patch.label === void 0) {
10712
- throw new UserError("Nothing to change \u2014 pass at least one of verts, condition, role, label.");
10907
+ if (patch.verts === void 0 && patch.condition === void 0 && patch.role === void 0 && patch.label === void 0 && patch.rise_ft === void 0 && patch.drop_ft === void 0) {
10908
+ throw new UserError("Nothing to change \u2014 pass at least one of verts, condition, role, label, rise_ft, drop_ft.");
10909
+ }
10910
+ const roleAfter = patch.role ?? cur.measure_role;
10911
+ if ((patch.rise_ft !== void 0 || patch.drop_ft !== void 0) && roleAfter !== "linear") {
10912
+ throw new UserError(`rise_ft / drop_ft are a linear run's vertical legs \u2014 ${JSON.stringify(id)} ${patch.role !== void 0 ? `would be ${roleAfter}` : `is ${roleAfter}`}. A wall's vertical is its height_ft.`);
10913
+ }
10914
+ if (cur.origin?.derived && (patch.rise_ft !== void 0 || patch.drop_ft !== void 0)) {
10915
+ throw new UserError(`Shape ${JSON.stringify(id)} is a derived run (base or transition) \u2014 a floor-level line by construction; it never takes a rise or drop. Trace the vertical run with measure_line.`);
10713
10916
  }
10714
10917
  if (cur.cuts_shape_id && (patch.verts !== void 0 || patch.role !== void 0 || patch.condition !== void 0)) {
10715
10918
  throw new UserError(`Shape ${JSON.stringify(id)} is a reconciled cutout \u2014 its ring IS the hole in ${cur.cuts_shape_id}. delete_shape it (the parent's geometry is restored) and cut_out again where you mean it.`);
@@ -10733,7 +10936,17 @@ var Session = class _Session {
10733
10936
  if (!(h > 0)) throw new UserError(`Surface Area needs a height \u2014 set height_ft on ${cond?.finish_tag ?? "the condition"} with edit_condition first.`);
10734
10937
  return h;
10735
10938
  };
10736
- const computed = role === "count" ? { count: cur.computed.count ?? 1 } : role === "linear" ? { area_sf: 0, perimeter_lf: round2(openLen(vertsPx) * upp) } : role === "surface_area" ? (() => {
10939
+ const legs = role === "linear" ? {
10940
+ ...cur.rise_ft !== void 0 ? { rise_ft: cur.rise_ft } : {},
10941
+ ...cur.drop_ft !== void 0 ? { drop_ft: cur.drop_ft } : {}
10942
+ } : {};
10943
+ for (const k of ["rise_ft", "drop_ft"]) {
10944
+ const v = patch[k];
10945
+ if (v === null) delete legs[k];
10946
+ else if (v !== void 0) legs[k] = v;
10947
+ }
10948
+ const condTagAfter = patch.condition !== void 0 ? patch.condition : this.conditions.find((x) => x.id === cur.condition_id)?.finish_tag;
10949
+ const computed = role === "count" ? { count: cur.computed.count ?? 1 } : role === "linear" ? _Session.linearQty(openLen(vertsPx) * upp, cur.origin?.derived ? { rise: 0, drop: 0 } : this.verticalFor(condTagAfter, legs)) : role === "surface_area" ? (() => {
10737
10950
  const LF = openLen(vertsPx) * upp;
10738
10951
  return { area_sf: round2(LF * heightFor()), perimeter_lf: round2(LF) };
10739
10952
  })() : (() => {
@@ -10751,6 +10964,7 @@ var Session = class _Session {
10751
10964
  computed,
10752
10965
  ...nextLabel ? { label: nextLabel } : {},
10753
10966
  ...role === "surface_area" ? { height_ft: Number(cur.height_ft) || heightFor() } : {},
10967
+ ...legs,
10754
10968
  ...cur.origin ? { origin: {
10755
10969
  ...cur.origin,
10756
10970
  agent_edits: (cur.origin.agent_edits ?? 0) + 1,
@@ -10760,12 +10974,16 @@ var Session = class _Session {
10760
10974
  } } : {}
10761
10975
  };
10762
10976
  if (!nextLabel) delete this.shapes[i].label;
10977
+ if (legs.rise_ft === void 0) delete this.shapes[i].rise_ft;
10978
+ if (legs.drop_ft === void 0) delete this.shapes[i].drop_ft;
10763
10979
  this.record({ op: "edit", tool: "edit_shape", before });
10764
10980
  const changed = [
10765
10981
  ...patch.verts !== void 0 ? ["verts"] : [],
10766
10982
  ...patch.condition !== void 0 ? ["condition"] : [],
10767
10983
  ...patch.role !== void 0 ? ["role"] : [],
10768
- ...patch.label !== void 0 ? ["label"] : []
10984
+ ...patch.label !== void 0 ? ["label"] : [],
10985
+ ...patch.rise_ft !== void 0 ? ["rise_ft"] : [],
10986
+ ...patch.drop_ft !== void 0 ? ["drop_ft"] : []
10769
10987
  ];
10770
10988
  return {
10771
10989
  shape_id: id,
@@ -10919,6 +11137,8 @@ var Session = class _Session {
10919
11137
  if (opts.waste_pct !== void 0) c.waste_pct = opts.waste_pct;
10920
11138
  if (opts.multiplier !== void 0) c.multiplier = opts.multiplier;
10921
11139
  if (opts.height_ft !== void 0) c.height_ft = opts.height_ft;
11140
+ if (opts.rise_ft !== void 0) c.rise_ft = opts.rise_ft;
11141
+ if (opts.drop_ft !== void 0) c.drop_ft = opts.drop_ft;
10922
11142
  if (opts.roll_setup !== void 0) {
10923
11143
  if (opts.roll_setup === null) {
10924
11144
  delete c.roll_setup;
@@ -10932,8 +11152,8 @@ var Session = class _Session {
10932
11152
  }
10933
11153
  }
10934
11154
  editCondition(tag, opts) {
10935
- if (opts.waste_pct === void 0 && opts.multiplier === void 0 && opts.height_ft === void 0 && opts.roll_setup === void 0) {
10936
- throw new UserError("Nothing to change \u2014 pass at least one of waste_pct, multiplier, height_ft, roll_setup.");
11155
+ if (opts.waste_pct === void 0 && opts.multiplier === void 0 && opts.height_ft === void 0 && opts.rise_ft === void 0 && opts.drop_ft === void 0 && opts.roll_setup === void 0) {
11156
+ throw new UserError("Nothing to change \u2014 pass at least one of waste_pct, multiplier, height_ft, rise_ft, drop_ft, roll_setup.");
10937
11157
  }
10938
11158
  const c = this.conditions.find((x) => x.finish_tag === tag);
10939
11159
  if (!c) {
@@ -10944,10 +11164,13 @@ var Session = class _Session {
10944
11164
  waste_pct: c.waste_pct,
10945
11165
  multiplier: c.multiplier,
10946
11166
  height_ft: c.height_ft,
11167
+ rise_ft: c.rise_ft,
11168
+ drop_ft: c.drop_ft,
10947
11169
  roll_setup: c.roll_setup ? structuredClone(c.roll_setup) : void 0
10948
11170
  };
10949
11171
  this.applyConditionKnobs(c, opts);
10950
11172
  this.record({ op: "condition", tool: "edit_condition", condition_id: c.id, before });
11173
+ if (opts.rise_ft !== void 0 || opts.drop_ft !== void 0) this.reflowLinears(c);
10951
11174
  let roll;
10952
11175
  if (hasRollSetup(c)) {
10953
11176
  const { dimsFor, uppFor } = this.rollInputs();
@@ -10961,6 +11184,8 @@ var Session = class _Session {
10961
11184
  waste_pct: c.waste_pct,
10962
11185
  multiplier: c.multiplier,
10963
11186
  ...c.height_ft !== void 0 ? { height_ft: c.height_ft } : {},
11187
+ ...c.rise_ft !== void 0 ? { rise_ft: c.rise_ft } : {},
11188
+ ...c.drop_ft !== void 0 ? { drop_ft: c.drop_ft } : {},
10964
11189
  ...c.roll_setup ? { roll_setup: c.roll_setup } : {},
10965
11190
  ...roll ? { roll } : {}
10966
11191
  };
@@ -11125,8 +11350,13 @@ var Session = class _Session {
11125
11350
  c.multiplier = e.before.multiplier;
11126
11351
  if (e.before.height_ft === void 0) delete c.height_ft;
11127
11352
  else c.height_ft = e.before.height_ft;
11353
+ if (e.before.rise_ft === void 0) delete c.rise_ft;
11354
+ else c.rise_ft = e.before.rise_ft;
11355
+ if (e.before.drop_ft === void 0) delete c.drop_ft;
11356
+ else c.drop_ft = e.before.drop_ft;
11128
11357
  if (e.before.roll_setup === void 0) delete c.roll_setup;
11129
11358
  else c.roll_setup = e.before.roll_setup;
11359
+ this.reflowLinears(c);
11130
11360
  }
11131
11361
  undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 0 });
11132
11362
  } else if (e.op === "duplicate_condition") {
@@ -12163,8 +12393,12 @@ var symbolSweepOutput = {
12163
12393
  warning: z.string().optional().describe("Present when the work cap dropped candidates \u2014 what a tighter seed rect would recover")
12164
12394
  };
12165
12395
  var measureLineOutput = {
12166
- length_lf: z.number(),
12396
+ length_lf: z.number().describe("The run's TOTAL length: plan trace + rise + drop (#441)"),
12167
12397
  npts: z.number().int(),
12398
+ plan_lf: z.number().optional().describe("The flat X\u2013Y trace alone \u2014 present only when the run carries a vertical leg"),
12399
+ vertical_lf: z.number().optional().describe("rise_ft + drop_ft \u2014 present only when a leg exists"),
12400
+ rise_ft: z.number().optional().describe("The rise this run resolved to (its own, else the condition default) \u2014 present with vertical_lf"),
12401
+ drop_ft: z.number().optional().describe("The drop this run resolved to \u2014 present with vertical_lf"),
12168
12402
  arcs: z.number().int().optional().describe("How many arc_through bows were laid \u2014 present only when the trace was bent; the vertices reported are the baked arc, not the three points you gave"),
12169
12403
  shape_id: z.string().optional().describe("Present when condition was passed and the shape committed")
12170
12404
  };
@@ -12233,6 +12467,8 @@ var conditionKnobs = z.object({
12233
12467
  waste_pct: z.number().optional(),
12234
12468
  multiplier: z.number().optional(),
12235
12469
  height_ft: z.number().optional(),
12470
+ rise_ft: z.number().optional(),
12471
+ drop_ft: z.number().optional(),
12236
12472
  roll_setup: z.object({}).passthrough().nullable().optional()
12237
12473
  }).passthrough();
12238
12474
  var proposalRow = z.object({
@@ -12473,11 +12709,13 @@ var deleteShapeOutput = {
12473
12709
  };
12474
12710
  var editShapeOutput = {
12475
12711
  shape_id: z.string(),
12476
- changed: z.array(z.enum(["verts", "condition", "role", "label"])).describe("Which fields this call actually changed"),
12712
+ changed: z.array(z.enum(["verts", "condition", "role", "label", "rise_ft", "drop_ft"])).describe("Which fields this call actually changed"),
12477
12713
  measure_role: z.enum(["floor_area", "deduct", "linear", "surface_area", "count"]),
12478
12714
  nverts: z.number().int(),
12479
12715
  area_sf: z.number().optional().describe("0 for linear shapes; LF \xD7 height for surface_area; absent for count"),
12480
- perimeter_lf: z.number().optional().describe("Length for linear/surface runs, perimeter for closed ones; absent for count"),
12716
+ perimeter_lf: z.number().optional().describe("Length for linear/surface runs (a linear run's TOTAL incl. rise + drop), perimeter for closed ones; absent for count"),
12717
+ plan_lf: z.number().optional().describe("Linear runs with a vertical leg: the flat trace alone (#441)"),
12718
+ vertical_lf: z.number().optional().describe("Linear runs with a vertical leg: rise + drop (#441)"),
12481
12719
  count: z.number().optional().describe("count shapes only \u2014 the marker's EA (preserved across the edit)"),
12482
12720
  label: z.string().optional().describe("The shape's room/phase label after this call \u2014 absent when it carries none (a cleared label reports as absent, not as an empty string)"),
12483
12721
  agent_edits: z.number().int().describe("How many times the agent has revised this shape \u2014 separate from the human-correction tally")
@@ -12620,6 +12858,8 @@ var editConditionOutput = {
12620
12858
  waste_pct: z.number().describe("The condition's waste % after this write"),
12621
12859
  multiplier: z.number().describe("The condition's quantity multiplier after this write"),
12622
12860
  height_ft: z.number().optional().describe("The condition's wall height after this write \u2014 present once set (measure_surface multiplies traced LF by it)"),
12861
+ rise_ft: z.number().optional().describe("The condition's default rise for its linear runs after this write \u2014 present once set (#441)"),
12862
+ drop_ft: z.number().optional().describe("The condition's default drop for its linear runs after this write \u2014 present once set (#441)"),
12623
12863
  roll_setup: z.object({}).passthrough().optional().describe("The condition's roll-goods setup after this write \u2014 present while opted in"),
12624
12864
  roll: z.object({
12625
12865
  condition_id: z.string(),
@@ -12964,9 +13204,132 @@ var countMarksOutput = {
12964
13204
  };
12965
13205
 
12966
13206
  // src/marked.ts
12967
- import path3 from "node:path";
13207
+ import path4 from "node:path";
12968
13208
  import { readFile as readFile2, writeFile } from "node:fs/promises";
12969
13209
 
13210
+ // ../web/src/lib/annotationTools.js
13211
+ var ANNOTATION_DEFAULTS = { color: "#dc3d43", stroke_pt: 1.5, font_pt: 10, opacity: 0.32, head: "filled", both: false, mode: "freehand", width_pt: 10, line_style: "solid" };
13212
+ var ANNOTATION_PRESETS = [
13213
+ { id: "review", name: "Review", tool: "cloud", style: { ...ANNOTATION_DEFAULTS } },
13214
+ { id: "direction", name: "Direction", tool: "arrow", style: { ...ANNOTATION_DEFAULTS, color: "#2563eb", stroke_pt: 2 } },
13215
+ { id: "key", name: "Key information", tool: "highlighter", style: { ...ANNOTATION_DEFAULTS, color: "#ffd60a", mode: "text" } }
13216
+ ];
13217
+ var clamp2 = (n, a, b, fallback) => Number.isFinite(+n) ? Math.max(a, Math.min(b, +n)) : fallback;
13218
+ function annotationStyle(s = {}) {
13219
+ s = s && typeof s === "object" ? s : {};
13220
+ return {
13221
+ color: /^#[0-9a-f]{6}$/i.test(s.color) ? s.color : ANNOTATION_DEFAULTS.color,
13222
+ stroke_pt: clamp2(s.stroke_pt, 0.5, 6, 1.5),
13223
+ font_pt: clamp2(s.font_pt, 6, 24, 10),
13224
+ opacity: clamp2(s.opacity, 0.1, 0.75, 0.32),
13225
+ width_pt: clamp2(s.width_pt, 2, 40, 10),
13226
+ head: ["filled", "open", "none"].includes(s.head) ? s.head : "filled",
13227
+ both: !!s.both,
13228
+ mode: ["freehand", "straight", "text"].includes(s.mode) ? s.mode : "freehand",
13229
+ line_style: ["solid", "dashed", "dotted"].includes(s.line_style) ? s.line_style : "solid"
13230
+ };
13231
+ }
13232
+ var box = (a, b) => [[Math.min(a[0], b[0]), Math.min(a[1], b[1])], [Math.max(a[0], b[0]), Math.max(a[1], b[1])]];
13233
+ function noteLines(text, limit = 36) {
13234
+ const lines = [];
13235
+ for (const para of String(text || "").split("\n")) {
13236
+ let line = "";
13237
+ for (const word of para.split(/\s+/).filter(Boolean)) {
13238
+ if (line && line.length + word.length + 1 > limit) {
13239
+ lines.push(line);
13240
+ line = "";
13241
+ }
13242
+ let rest = word;
13243
+ while (rest.length > limit) {
13244
+ if (line) {
13245
+ lines.push(line);
13246
+ line = "";
13247
+ }
13248
+ lines.push(rest.slice(0, limit));
13249
+ rest = rest.slice(limit);
13250
+ }
13251
+ line += (line ? " " : "") + rest;
13252
+ }
13253
+ lines.push(line);
13254
+ }
13255
+ return lines;
13256
+ }
13257
+ var path3 = (pts) => pts.map((p, i) => [i ? "L" : "M", ...p]);
13258
+ function pathString(commands, transform = (p) => p) {
13259
+ return commands.map(([verb, ...v]) => {
13260
+ const ps = [];
13261
+ for (let i = 0; i < v.length; i += 2) ps.push(transform([v[i], v[i + 1]]).join(","));
13262
+ return verb + ps.join(" ");
13263
+ }).join(" ");
13264
+ }
13265
+ function annotationScene(m, W, H, pixelScale = 2) {
13266
+ const s = annotationStyle({ ...m.annotation_style, color: m.color || m.annotation_style?.color });
13267
+ const P = (p) => [p[0] * W, p[1] * H];
13268
+ const paths = [], texts = [];
13269
+ const stroke = s.stroke_pt * pixelScale;
13270
+ const add = (commands, extra = {}) => paths.push({ commands, stroke: s.color, width: stroke, opacity: 1, ...extra });
13271
+ const head2 = (a, b) => {
13272
+ if (s.head === "none") return;
13273
+ const size = Math.max(6, s.stroke_pt * 4) * pixelScale, len = Math.hypot(b[0] - a[0], b[1] - a[1]);
13274
+ if (!len) return;
13275
+ const u = [(b[0] - a[0]) / len, (b[1] - a[1]) / len], base = [b[0] - u[0] * size, b[1] - u[1] * size];
13276
+ const pts = [[base[0] - u[1] * size * 0.42, base[1] + u[0] * size * 0.42], b, [base[0] + u[1] * size * 0.42, base[1] - u[0] * size * 0.42]];
13277
+ add([...path3(pts), ...s.head === "filled" ? [["Z"]] : []], { fill: s.head === "filled" ? s.color : void 0, dash: void 0 });
13278
+ };
13279
+ const leader = (a, b) => {
13280
+ add(path3([a, b]));
13281
+ head2(a, b);
13282
+ if (s.both) head2(b, a);
13283
+ };
13284
+ const noteOrigin = (at) => {
13285
+ const lines = noteLines(m.text), font = s.font_pt * pixelScale, pad = 5 * pixelScale;
13286
+ const w = Math.max(45 * pixelScale, ...lines.map((l) => l.length * font * 0.62)) + pad * 2;
13287
+ const h = Math.max(1, lines.length) * font * 1.35 + pad * 2;
13288
+ return [Math.max(0, Math.min(W - w, at[0])), Math.max(0, Math.min(H - h, at[1]))];
13289
+ };
13290
+ const note = (raw) => {
13291
+ const at = noteOrigin(raw);
13292
+ const lines = noteLines(m.text), font = s.font_pt * pixelScale, pad = 5 * pixelScale;
13293
+ const width = Math.max(45 * pixelScale, ...lines.map((l) => l.length * font * 0.62)) + pad * 2;
13294
+ const height = Math.max(1, lines.length) * font * 1.35 + pad * 2;
13295
+ add([...path3([at, [at[0] + width, at[1]], [at[0] + width, at[1] + height], [at[0], at[1] + height]]), ["Z"]], { fill: "#ffffff" });
13296
+ lines.forEach((text, i) => texts.push({ text, x: at[0] + pad, y: at[1] + pad + font * (1 + i * 1.35), size: font, color: "#172033" }));
13297
+ return [[at[0], at[1]], [at[0] + width, at[1] + height]];
13298
+ };
13299
+ let noteBox2 = null;
13300
+ if (m.type === "arrow" && m.from && m.to) leader(P(m.from), P(m.to));
13301
+ if (m.type === "callout" && m.at && m.target) {
13302
+ leader(noteOrigin(P(m.at)), P(m.target));
13303
+ noteBox2 = note(P(m.at));
13304
+ }
13305
+ if (m.type === "text" && m.at) noteBox2 = note(P(m.at));
13306
+ if (m.type === "cloud" && m.rect) {
13307
+ const r = box(...m.rect), a = P(r[0]), b = P(r[1]), cb = cloudBezier(...a, ...b);
13308
+ add([["M", ...cb.start], ...cb.segments.map((q) => ["C", ...q.flat()]), ["Z"]]);
13309
+ if (m.text) {
13310
+ const at = noteOrigin(P(m.note_at || [r[0][0], r[1][1] + 12 * pixelScale / H]));
13311
+ add(path3([at, [a[0], b[1]]]));
13312
+ noteBox2 = note(at);
13313
+ }
13314
+ }
13315
+ if (m.type === "highlight") {
13316
+ if (m.quads?.length) for (const q of m.quads) add([...path3(q.map(P)), ["Z"]], { fill: s.color, stroke: void 0, opacity: s.opacity, blend: "multiply" });
13317
+ else if (m.pts?.length > 1) add(path3(m.pts.map(P)), { width: m.w ? m.w * W : s.width_pt * pixelScale, opacity: s.opacity, blend: "multiply" });
13318
+ else if (m.rect) {
13319
+ const [a, b] = box(...m.rect);
13320
+ add([...path3([P(a), P([b[0], a[1]]), P(b), P([a[0], b[1]])]), ["Z"]], { fill: s.color, stroke: void 0, opacity: s.opacity, blend: "multiply" });
13321
+ }
13322
+ }
13323
+ const dash = s.line_style === "dashed" ? [5 * pixelScale, 3 * pixelScale] : s.line_style === "dotted" ? [pixelScale, 3 * pixelScale] : void 0;
13324
+ for (const p of paths) if (p.stroke && !p.fill && m.type !== "highlight") p.dash = dash;
13325
+ if (m.type === "cloud" && m.rect && Number.isFinite(m.rev) && m.rev > 0) {
13326
+ const r = box(...m.rect), x = r[1][0] * W, y = r[0][1] * H - 12 * pixelScale;
13327
+ add([["M", x, y - 7 * pixelScale], ["L", x + 7 * pixelScale, y + 5 * pixelScale], ["L", x - 7 * pixelScale, y + 5 * pixelScale], ["Z"]], { fill: "#ffffff" });
13328
+ texts.push({ text: String(m.rev), x: x - 2.5 * pixelScale, y: y + 3 * pixelScale, size: 8 * pixelScale, color: s.color });
13329
+ }
13330
+ return { paths, texts, noteBox: noteBox2 };
13331
+ }
13332
+
12970
13333
  // ../web/src/lib/svgpath.js
12971
13334
  function isSep(c) {
12972
13335
  return c === " " || c === " " || c === "\n" || c === "\r" || c === "\f" || c === ",";
@@ -13735,12 +14098,13 @@ function invertPixels(cv) {
13735
14098
  ctx.restore();
13736
14099
  }
13737
14100
  async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, approvals = [], rfis: rfisIn = [], conditions, getPage, loadPdfData, company, clientInfo, credit = null, provenance = null, coverTitle = "Marked Set", units = "imperial" }) {
14101
+ markups = (markups || []).filter((m) => !m.reference_only);
13738
14102
  const rfis = liveRfis(rfisIn);
13739
14103
  const M = units === "metric";
13740
14104
  const uA = (sf) => M ? sf * 0.09290304 : sf;
13741
14105
  const uL = (lf) => M ? lf * 0.3048 : lf;
13742
14106
  const AU = M ? "m2" : "SF", LU = M ? "m" : "LF";
13743
- const { PDFDocument, StandardFonts, rgb, degrees, LineCapStyle } = await import("pdf-lib");
14107
+ const { PDFDocument, StandardFonts, rgb, degrees, LineCapStyle, BlendMode } = await import("pdf-lib");
13744
14108
  const condById = Object.fromEntries(conditions.map((c) => [c.id, c]));
13745
14109
  const rfiNum = new Map((rfis || []).map((r) => [r.id, r.number]));
13746
14110
  const byKey = (arr3) => {
@@ -14154,7 +14518,27 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
14154
14518
  const mcol = rgb(...hex(dark ? boostForDark(mbase) : mbase));
14155
14519
  const mdash = pdfDashFor(m.line_style || "solid");
14156
14520
  const mw = clampWeight(m.weight);
14157
- if (m.type === "highlight" && (m.pts || []).length >= 2) {
14521
+ if (m.annotation_style && ["arrow", "highlight", "callout", "cloud", "text"].includes(m.type)) {
14522
+ const scene = annotationScene(m, W, H, RENDER_SCALE);
14523
+ const P = ([x, y]) => {
14524
+ const [px, py] = toPage(x, y);
14525
+ return [px, -py];
14526
+ };
14527
+ for (const ink2 of scene.paths) pg.drawSvgPath(pathString(ink2.commands, P), {
14528
+ x: 0,
14529
+ y: 0,
14530
+ ...ink2.fill ? { color: rgb(...hex(ink2.fill)), opacity: ink2.opacity } : {},
14531
+ ...ink2.stroke ? { borderColor: rgb(...hex(ink2.stroke)), borderWidth: ink2.width * ptScale, borderOpacity: ink2.opacity } : {},
14532
+ ...ink2.dash ? { borderDashArray: ink2.dash.map((n) => n * ptScale) } : {},
14533
+ ...ink2.blend ? { blendMode: BlendMode.Multiply } : {},
14534
+ borderLineCap: LineCapStyle.Round
14535
+ });
14536
+ for (const ink2 of scene.texts) text(ink2.text, ink2.x, ink2.y, ink2.size * ptScale, rgb(...hex(ink2.color)));
14537
+ if (rlabel) {
14538
+ const at = m.at || m.from || m.rect?.[0] || m.pts?.[0];
14539
+ if (at) text(rlabel, at[0] * W, at[1] * H - 10 / ptScale, 8, mcol, bold);
14540
+ }
14541
+ } else if (m.type === "highlight" && (m.pts || []).length >= 2) {
14158
14542
  const ipts = m.pts.map(([nx, ny]) => [nx * W, ny * H]);
14159
14543
  const inkCol = rgb(...hex(m.color || "#ffd60a"));
14160
14544
  const wImg = (m.w || 0.01) * W * mw;
@@ -14401,7 +14785,7 @@ async function exportMarkedPdf(session, opts) {
14401
14785
  throw new UserError("Nothing to mark yet \u2014 commit shapes (one_click / detect_rooms / measure_polygon / measure_line with a condition) or annotate before exporting the marked set.");
14402
14786
  }
14403
14787
  const base = file.replace(/\.pdf$/i, "");
14404
- const outPath = path3.resolve(opts.path ?? path3.join(path3.dirname(filePath), `${base} - marked set.pdf`));
14788
+ const outPath = path4.resolve(opts.path ?? path4.join(path4.dirname(filePath), `${base} - marked set.pdf`));
14405
14789
  await assertWritable(outPath, "pdf", opts.overwrite);
14406
14790
  const sheetStates = session.sheetList();
14407
14791
  const byFilePage = new Map(sheetStates.map((s) => [`${session.fileFor(s.key)}#${s.pageNum}`, s.page]));
@@ -14479,7 +14863,7 @@ async function exportMarkedPdf(session, opts) {
14479
14863
  }
14480
14864
 
14481
14865
  // src/importing.ts
14482
- import path4 from "node:path";
14866
+ import path5 from "node:path";
14483
14867
  import { readFile as readFile3 } from "node:fs/promises";
14484
14868
 
14485
14869
  // ../web/src/lib/reviewState.js
@@ -14487,6 +14871,11 @@ function normalizeAgentReview(shape) {
14487
14871
  return shape?.origin?.actor === "agent" && shape.origin.reviewed == null ? { ...shape, origin: { ...shape.origin, reviewed: false } } : shape;
14488
14872
  }
14489
14873
 
14874
+ // ../web/src/lib/multiplier.js
14875
+ function isValidMultiplier(v) {
14876
+ return v === void 0 || v === null || typeof v === "number" && Number.isFinite(v) && v > 0;
14877
+ }
14878
+
14490
14879
  // ../web/src/lib/importTakeoff.js
14491
14880
  function parseTakeoffImport(text) {
14492
14881
  let doc;
@@ -14511,6 +14900,11 @@ function mergeTakeoffImport(current, imported, knownFiles = null) {
14511
14900
  const cur = current && typeof current === "object" ? current : {};
14512
14901
  const impShapes = arr2(imported.shapes).filter((s) => s && typeof s === "object" && typeof s.sheet_id === "string" && typeof s.id === "string").map(normalizeAgentReview);
14513
14902
  const impConds = arr2(imported.conditions).filter((c) => c && typeof c === "object" && typeof c.id === "string");
14903
+ const badMult = impConds.filter((c) => !isValidMultiplier(c.multiplier));
14904
+ if (badMult.length) {
14905
+ const list = badMult.map((c) => `${c.finish_tag ?? c.id} (${JSON.stringify(c.multiplier)})`).join(", ");
14906
+ throw new Error(`Couldn't import takeoff: condition multiplier must be a positive number \u2014 ${list}. Fix the file and re-import. Nothing was imported.`);
14907
+ }
14514
14908
  const localScales = new Map(arr2(cur.sheets).filter((s) => typeof s?.sheet_id === "string").map((s) => [s.sheet_id, s.units_per_px]));
14515
14909
  const incomingScales = new Map(arr2(imported.sheets).filter((s) => typeof s?.sheet_id === "string").map((s) => [s.sheet_id, s.units_per_px]));
14516
14910
  const existingIds = new Set(arr2(cur.shapes).map((s) => s.id));
@@ -14675,7 +15069,7 @@ async function importTakeoff(session, filePath) {
14675
15069
  const addedIds = session.shapes.filter((s) => !prevShapeIds.has(s.id)).map((s) => s.id);
14676
15070
  if (addedIds.length) session.journalCommit("import_takeoff", addedIds);
14677
15071
  return {
14678
- file: path4.basename(filePath),
15072
+ file: path5.basename(filePath),
14679
15073
  ...note,
14680
15074
  rules_imported: rulesImported,
14681
15075
  shapes_total: session.shapes.length,
@@ -14720,8 +15114,8 @@ function registerTools(realServer, session, opts = {}) {
14720
15114
  merge: z2.boolean().optional().describe("true = ADD this document to the working set, keeping all existing work (merge into an empty session is just a load)")
14721
15115
  },
14722
15116
  outputSchema: loadPlanOutput
14723
- }, run("load_plan", async ({ path: path5, merge: merge2 }) => {
14724
- const loaded = await session.loadPlan(path5, { merge: merge2 });
15117
+ }, run("load_plan", async ({ path: path6, merge: merge2 }) => {
15118
+ const loaded = await session.loadPlan(path6, { merge: merge2 });
14725
15119
  server.sendResourceListChanged();
14726
15120
  return loaded;
14727
15121
  }));
@@ -14808,15 +15202,17 @@ function registerTools(realServer, session, opts = {}) {
14808
15202
  outputSchema: cutOutOutput
14809
15203
  }, run("cut_out", (a) => session.cutOut(a)));
14810
15204
  server.registerTool("measure_line", {
14811
- description: `Measure an open polyline (min 2 points, image px): length_lf at the sheet's scale. Requires the scale to be set. Pass condition to commit it as a linear shape (base, transitions, feature strips). A curved run (base along a radius wall, a curved feature strip) takes arc_through: one point on the bow, marked. ${COORDS}`,
15205
+ description: `Measure an open polyline (min 2 points, image px): length_lf at the sheet's scale. Requires the scale to be set. Pass condition to commit it as a linear shape (base, transitions, feature strips, conduit and home runs). A curved run (base along a radius wall, a curved feature strip) takes arc_through: one point on the bow, marked. DROP AND RISE (#441): a plan trace is the flat X\u2013Y path; the material also travels VERTICALLY \u2014 a home run drops from the ceiling to a panel, rises to a box. length_lf is the TOTAL: plan + rise + drop. The condition's rise_ft / drop_ft (edit_condition) are the defaults for every run under it; pass rise_ft / drop_ft here to give THIS run its own legs (0 included \u2014 "no drop on this one" is a statement), and the reply splits plan_lf / vertical_lf beside the total when a leg exists. ${COORDS}`,
14812
15206
  inputSchema: {
14813
15207
  sheet: z2.string(),
14814
15208
  pts: z2.array(pointSchema).min(2),
14815
15209
  condition: z2.string().optional(),
15210
+ rise_ft: z2.number().min(0).optional().describe("This run's vertical leg UP, in feet, added to its plan length \u2014 overrides the condition's rise_ft default for this run (0 = no rise here, whatever the default)"),
15211
+ drop_ft: z2.number().min(0).optional().describe("This run's vertical leg DOWN, in feet, added to its plan length \u2014 overrides the condition's drop_ft default for this run (0 = no drop here, whatever the default)"),
14816
15212
  arc_through: z2.array(z2.number().int().nonnegative()).optional().describe("Indices of points that are the MIDDLE of an arc: the trace runs the point before \u2192 this point \u2192 the point after as the unique circle through the three (the canvas's Curve mode). For a curved wall put one point anywhere ON the bow between its two ends and mark it. The arc is baked to ordinary vertices on commit and origin.curved is stamped; a mark on an end of an open run, or two marks in a row, refuses.")
14817
15213
  },
14818
15214
  outputSchema: measureLineOutput
14819
- }, run("measure_line", (a) => session.measureLine(a.sheet, a.pts, { condition: a.condition, arc_through: a.arc_through })));
15215
+ }, run("measure_line", (a) => session.measureLine(a.sheet, a.pts, { condition: a.condition, arc_through: a.arc_through, rise_ft: a.rise_ft, drop_ft: a.drop_ft })));
14820
15216
  server.registerTool("measure_surface", {
14821
15217
  description: `Surface Area \u2014 wall SF (#146): trace an OPEN run along the wall in plan view (min 2 points, image px) and the quantity is traced LF \xD7 height. This is how wall tile, wainscot, and wall systems are taken off \u2014 the quantity family ${oneClick ? "one_click and " : ""}measure_polygon cannot produce. Height lives on the CONDITION (the canvas's H knob): pass height_ft to set it on this call (journals as its own undo step, like typing H before tracing), or set it once with edit_condition; with neither, this refuses and mints nothing. The shape snapshots the height it was quantified at. Requires the sheet's scale. ${COORDS}`,
14822
15218
  inputSchema: {
@@ -15089,10 +15485,12 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
15089
15485
  verts: z2.array(pointSchema).optional().describe("Replacement geometry (image px): \u22653 vertices for an area shape, \u22652 points for a linear/surface run, \u22651 for a count marker"),
15090
15486
  condition: z2.string().optional().describe("Reassign to this finish tag (minted on first use)"),
15091
15487
  role: z2.enum(["floor_area", "deduct", "linear", "surface_area", "count"]).optional().describe("Switch what the shape measures \u2014 flipping INTO surface_area needs a height on the shape or its condition"),
15092
- label: z2.string().optional().describe('The room (or phase/area) this shape belongs to, e.g. "134" or "OFFICE 101" \u2014 what per-room reporting groups by. Pass "" to clear it')
15488
+ label: z2.string().optional().describe('The room (or phase/area) this shape belongs to, e.g. "134" or "OFFICE 101" \u2014 what per-room reporting groups by. Pass "" to clear it'),
15489
+ rise_ft: z2.number().min(0).nullable().optional().describe("Linear runs only (#441): this run's vertical leg UP in feet, overriding the condition's rise_ft default (0 = none). null clears the override so the condition's default applies again. perimeter_lf is recomputed as plan + rise + drop"),
15490
+ drop_ft: z2.number().min(0).nullable().optional().describe("Linear runs only (#441): this run's vertical leg DOWN in feet, overriding the condition's drop_ft default (0 = none). null clears the override so the condition's default applies again")
15093
15491
  },
15094
15492
  outputSchema: editShapeOutput
15095
- }, run("edit_shape", (a) => session.editShape(a.shape_id, { verts: a.verts, condition: a.condition, role: a.role, label: a.label })));
15493
+ }, run("edit_shape", (a) => session.editShape(a.shape_id, { verts: a.verts, condition: a.condition, role: a.role, label: a.label, rise_ft: a.rise_ft, drop_ft: a.drop_ft })));
15096
15494
  server.registerTool("edit_materials", {
15097
15495
  description: `Add, remove, or patch supporting-materials rows on a condition \u2014 the coverage-rate lines that turn a measured area/length/count into an order quantity (adhesive at N sf/gal, grout at N lf/bag, \u2026), matching the canvas's per-condition Supporting Materials panel. Each row is {name, per, basis, unit, round, note}: quantity = the condition's basis total (area/linear/count/seam_lf) \xF7 per, rounded up to whole purchase units unless round:false. basis "seam_lf" is the one basis that is FIGURED rather than measured: it is the length where two cuts meet on the floor, read off the condition's roll layout (set roll_setup with edit_condition), which is what a heat-weld rod or a carpet seam tape is bought by. A 20-ft-wide room off a 12-ft roll seams once down its length; the same square footage as two 10-ft rooms seams not at all, and no percentage of the area or the perimeter can tell those two jobs apart. Without a roll_setup \u2014 or with no committed floor shapes to lay out \u2014 a seam_lf row reads 0, which is the honest state rather than a guess. condition names an existing OR NEW finish tag (minted on first touch, same as ${oneClick ? "one_click/" : ""}measure_polygon) \u2014 add alone is enough to seed materials on a condition before you've traced anything. remove/patch target existing row ids from this reply or export_takeoff (takeoff_summary strips materials for a compact quantities-only reply); a bad id 404s the WHOLE call before anything is written, and referencing an id on a tag with no condition yet errors rather than silently minting an empty one. No review gate here \u2014 materials rows are quantity config, not traced geometry, so this edits directly; undo_last reverses a call in one step (the condition's whole materials array, snapshotted before the write, restored verbatim).`,
15098
15496
  inputSchema: {
@@ -15120,6 +15518,8 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
15120
15518
  waste_pct: z2.number().min(0).optional().describe("Waste percentage applied to net order quantities, e.g. 10 for 10%"),
15121
15519
  multiplier: z2.number().positive().optional().describe("Quantity multiplier (\xD7N identical areas). Note: the canvas treats 0 as 1, so 0 is rejected here rather than silently meaning 'off'"),
15122
15520
  height_ft: z2.number().positive().optional().describe("Wall height in feet \u2014 the canvas's H knob; measure_surface quantifies traced LF \xD7 this"),
15521
+ rise_ft: z2.number().min(0).optional().describe("Drop and Rise (#441): the vertical leg UP, in feet, every linear run of this condition adds to its plan length (LF = plan + rise + drop). Re-flows existing runs that do not carry their own rise_ft; derived base/transitions never take a leg. 0 turns it off"),
15522
+ drop_ft: z2.number().min(0).optional().describe("Drop and Rise (#441): the vertical leg DOWN, in feet, every linear run of this condition adds to its plan length. Re-flows existing runs that do not carry their own drop_ft. 0 turns it off"),
15123
15523
  roll_setup: z2.union([
15124
15524
  z2.null().describe("Opt the condition OUT of roll goods"),
15125
15525
  z2.object({
@@ -15135,7 +15535,7 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
15135
15535
  ]).optional().describe("Roll-goods opt-in (#147): presence of a setup is what makes the condition roll goods \u2014 seams figured, cuts packed, order footage beside the measured quantities. Same-material partial edits patch the existing setup; null opts out. The reply echoes the figured order (cuts, order_lf, rolls, order_qty) whenever floor shapes exist on scaled sheets, and export_report's roll_goods block carries the same rows")
15136
15536
  },
15137
15537
  outputSchema: editConditionOutput
15138
- }, run("edit_condition", (a) => session.editCondition(a.condition, { waste_pct: a.waste_pct, multiplier: a.multiplier, height_ft: a.height_ft, roll_setup: a.roll_setup })));
15538
+ }, run("edit_condition", (a) => session.editCondition(a.condition, { waste_pct: a.waste_pct, multiplier: a.multiplier, height_ft: a.height_ft, roll_setup: a.roll_setup, rise_ft: a.rise_ft, drop_ft: a.drop_ft })));
15139
15539
  server.registerTool("propose_condition_edit", {
15140
15540
  description: `PROPOSE a change to a condition instead of making it (#365): a diff \u2014 a new finish tag (rename), waste %, \xD7N multiplier, height_ft, roll_setup \u2014 held PENDING until the estimator accepts it from the panel. edit_condition is the wrong power for "I think this condition is wrong": a tag rename or a knob change should be a decision the estimator makes, not one they discover. Until acceptance NOTHING changes \u2014 takeoff_summary and export_report keep computing from the current values and carry the diff beside them (proposed_condition_edits), and once accepted the report is byte-for-byte what a direct edit_condition would have produced (the same write path). Only fields that differ from the current value are recorded; a proposal that changes nothing is refused, and a rename onto a tag another condition already carries is refused (two conditions on one tag would make one unreachable). One pending diff per condition \u2014 proposing again replaces the earlier one (undo_last restores it). rationale is required: the estimator accepts a reason.`,
15141
15541
  inputSchema: {
@@ -15144,11 +15544,13 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
15144
15544
  waste_pct: z2.number().min(0).optional(),
15145
15545
  multiplier: z2.number().positive().optional(),
15146
15546
  height_ft: z2.number().positive().optional(),
15547
+ rise_ft: z2.number().min(0).optional().describe("Proposed default vertical leg UP for the condition's linear runs (#441)"),
15548
+ drop_ft: z2.number().min(0).optional().describe("Proposed default vertical leg DOWN for the condition's linear runs (#441)"),
15147
15549
  roll_setup: z2.union([z2.null(), z2.object({}).passthrough()]).optional().describe("Proposed roll-goods setup, or null to propose opting out"),
15148
15550
  rationale: z2.string().min(1).describe("Why \u2014 the schedule row, the spec section, the sheet note that decided it")
15149
15551
  },
15150
15552
  outputSchema: proposeConditionEditOutput
15151
- }, run("propose_condition_edit", (a) => session.proposeConditionEdit(a.condition, { finish_tag: a.finish_tag, waste_pct: a.waste_pct, multiplier: a.multiplier, height_ft: a.height_ft, roll_setup: a.roll_setup }, a.rationale)));
15553
+ }, run("propose_condition_edit", (a) => session.proposeConditionEdit(a.condition, { finish_tag: a.finish_tag, waste_pct: a.waste_pct, multiplier: a.multiplier, height_ft: a.height_ft, rise_ft: a.rise_ft, drop_ft: a.drop_ft, roll_setup: a.roll_setup }, a.rationale)));
15152
15554
  server.registerTool("withdraw_condition_edit", {
15153
15555
  description: `Drop a pending condition-edit proposal (#365) without touching the condition. undo_last re-seats it.`,
15154
15556
  inputSchema: { proposal_id: z2.string().describe("From propose_condition_edit, or takeoff_summary's proposed_condition_edits") },
@@ -15339,7 +15741,7 @@ No review gate: the pencil-not-ink rule exists to stop an agent inventing geomet
15339
15741
  import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
15340
15742
 
15341
15743
  // src/wiki.generated.ts
15342
- var WIKI_VERSION = "0.9.87";
15744
+ var WIKI_VERSION = "0.9.90";
15343
15745
  var WIKI_PAGES = [
15344
15746
  {
15345
15747
  "key": "index",
@@ -15416,7 +15818,7 @@ var WIKI_PAGES = [
15416
15818
  ];
15417
15819
 
15418
15820
  // src/protocol.generated.ts
15419
- var PROTOCOL_INDEX_JSON = '{\n "protocol": "TakeoffDocument v1 (draft)",\n "legacy": "takeoff_canvas.v1 structural profile (draft)",\n "scope": "Machine-readable discovery and contract resources; writers and runtime validation are unchanged.",\n "coordinate_contract": "Tool coordinates are full-sheet image pixels at render scale 2.0, top-left origin, y-down; persisted verts_norm are normalized to the sheet/composite frame.",\n "roles": [\n "floor_area",\n "deduct",\n "linear",\n "surface_area",\n "count"\n ],\n "identifiers": {\n "draft": "opentakeoff.takeoff-document.v1",\n "canvas": "opentakeoff.takeoff_canvas.v1"\n },\n "calibration": "Calibration is feet per logical image pixel; metric display does not rewrite stored geometry.",\n "records": "Evidence/provenance and review fields are records and claims, not authentication. Unknown extension fields remain opaque.",\n "limits": [\n "Schemas do not prove polygon topology, geometry accuracy, quantity recomputation, source/PDF availability, complete history, active reference integrity, MCP transport preservation, or approval authenticity.",\n "Secondary families such as markups, RFIs, rules, and layout/workspace data are not fully semantically validated.",\n "MCP exportPayload() is a projection: it omits rules and browser stitches, strips RFI tombstones, and does not represent the complete browser workspace/project document.",\n "The $id URLs are stable identifiers for offline resolution, not a promise that the files are deployed at those URLs.",\n "Both the draft TakeoffDocument v1 and existing canvas identifier are described; neither writer is changed by this increment.",\n "MCP cannot create human approval; schema-valid records do not authenticate their author or grant review authority."\n ],\n "wiki": "takeoff://wiki/protocol",\n "resources": [\n {\n "uri": "takeoff://protocol/legacy/takeoff-canvas.v1.schema.json",\n "id": "https://opentakeoff.kentucky-ai.com/protocol/legacy/takeoff-canvas.v1.schema.json",\n "title": "takeoff_canvas.v1 structural profile (draft)",\n "source": "protocol/legacy/takeoff-canvas.v1.schema.json",\n "sha256": "111ca2e5294dca11d742d5ae240d2910dd78ad52a631075b791f1483c736f01d"\n },\n {\n "uri": "takeoff://protocol/v1/calibration.schema.json",\n "id": "https://opentakeoff.kentucky-ai.com/protocol/v1/calibration.schema.json",\n "title": "Calibration (draft)",\n "source": "protocol/v1/calibration.schema.json",\n "sha256": "53ab69f4df0ce57d869db576aef46f95f7592672315f7ba772e728f6c2d6ca12"\n },\n {\n "uri": "takeoff://protocol/v1/common.schema.json",\n "id": "https://opentakeoff.kentucky-ai.com/protocol/v1/common.schema.json",\n "title": "Shared value types (draft)",\n "source": "protocol/v1/common.schema.json",\n "sha256": "b5bea272a92f051b780b4a1615105416406387c4dba47b3ca65cf2892c3e8c11"\n },\n {\n "uri": "takeoff://protocol/v1/condition.schema.json",\n "id": "https://opentakeoff.kentucky-ai.com/protocol/v1/condition.schema.json",\n "title": "Condition (draft)",\n "source": "protocol/v1/condition.schema.json",\n "sha256": "fc5694ef95d4627cc07f9a7efa34b479345341ffd905931136e4d80fa9081d51"\n },\n {\n "uri": "takeoff://protocol/v1/document-fields.schema.json",\n "id": "https://opentakeoff.kentucky-ai.com/protocol/v1/document-fields.schema.json",\n "title": "Existing document collections (draft)",\n "source": "protocol/v1/document-fields.schema.json",\n "sha256": "9ec6b05869bae0eb0e4c9d6caa12a525b6565a0e795134cfb64e6a7a232416b4"\n },\n {\n "uri": "takeoff://protocol/v1/evidence.schema.json",\n "id": "https://opentakeoff.kentucky-ai.com/protocol/v1/evidence.schema.json",\n "title": "Evidence (draft)",\n "source": "protocol/v1/evidence.schema.json",\n "sha256": "5602648262758e35cf503a46fe65fb58f7e8d2d22717e313dd5e974664d771b9"\n },\n {\n "uri": "takeoff://protocol/v1/measurement.schema.json",\n "id": "https://opentakeoff.kentucky-ai.com/protocol/v1/measurement.schema.json",\n "title": "Measurement (draft)",\n "source": "protocol/v1/measurement.schema.json",\n "sha256": "1f554393245404b009481efd6a8a9847ca82a3639919a04adf5e1e619d96bbcb"\n },\n {\n "uri": "takeoff://protocol/v1/provenance.schema.json",\n "id": "https://opentakeoff.kentucky-ai.com/protocol/v1/provenance.schema.json",\n "title": "Provenance (draft)",\n "source": "protocol/v1/provenance.schema.json",\n "sha256": "fd5347aaed173391361b0372ef73c0d45547f9abdb4e7a6ca226de53ae24cd89"\n },\n {\n "uri": "takeoff://protocol/v1/review.schema.json",\n "id": "https://opentakeoff.kentucky-ai.com/protocol/v1/review.schema.json",\n "title": "Review (draft)",\n "source": "protocol/v1/review.schema.json",\n "sha256": "3d1f326ff85e3498521e81df0cc2f2758b998be803400a25c21c65014657f245"\n },\n {\n "uri": "takeoff://protocol/v1/stitch.schema.json",\n "id": "https://opentakeoff.kentucky-ai.com/protocol/v1/stitch.schema.json",\n "title": "Composite surface (draft)",\n "source": "protocol/v1/stitch.schema.json",\n "sha256": "db9ea1dffe5fd2360e1d231efc86ba84309316ba9add72c5c0fafef006e76154"\n },\n {\n "uri": "takeoff://protocol/v1/takeoff-document.schema.json",\n "id": "https://opentakeoff.kentucky-ai.com/protocol/v1/takeoff-document.schema.json",\n "title": "TakeoffDocument v1 (draft)",\n "source": "protocol/v1/takeoff-document.schema.json",\n "sha256": "9c7800a9423a12e9334cda507fdd3c216205a4304e220df376dd67753accbd69"\n }\n ]\n}\n';
15821
+ var PROTOCOL_INDEX_JSON = '{\n "protocol": "TakeoffDocument v1 (draft)",\n "legacy": "takeoff_canvas.v1 structural profile (draft)",\n "scope": "Machine-readable discovery and contract resources; writers and runtime validation are unchanged.",\n "coordinate_contract": "Tool coordinates are full-sheet image pixels at render scale 2.0, top-left origin, y-down; persisted verts_norm are normalized to the sheet/composite frame.",\n "roles": [\n "floor_area",\n "deduct",\n "linear",\n "surface_area",\n "count"\n ],\n "identifiers": {\n "draft": "opentakeoff.takeoff-document.v1",\n "canvas": "opentakeoff.takeoff_canvas.v1"\n },\n "calibration": "Calibration is feet per logical image pixel; metric display does not rewrite stored geometry.",\n "records": "Evidence/provenance and review fields are records and claims, not authentication. Unknown extension fields remain opaque.",\n "limits": [\n "Schemas do not prove polygon topology, geometry accuracy, quantity recomputation, source/PDF availability, complete history, active reference integrity, MCP transport preservation, or approval authenticity.",\n "Secondary families such as markups, RFIs, rules, and layout/workspace data are not fully semantically validated.",\n "MCP exportPayload() is a projection: it omits rules and browser stitches, strips RFI tombstones, and does not represent the complete browser workspace/project document.",\n "The $id URLs are stable identifiers for offline resolution, not a promise that the files are deployed at those URLs.",\n "Both the draft TakeoffDocument v1 and existing canvas identifier are described; neither writer is changed by this increment.",\n "MCP cannot create human approval; schema-valid records do not authenticate their author or grant review authority."\n ],\n "wiki": "takeoff://wiki/protocol",\n "resources": [\n {\n "uri": "takeoff://protocol/legacy/takeoff-canvas.v1.schema.json",\n "id": "https://opentakeoff.kentucky-ai.com/protocol/legacy/takeoff-canvas.v1.schema.json",\n "title": "takeoff_canvas.v1 structural profile (draft)",\n "source": "protocol/legacy/takeoff-canvas.v1.schema.json",\n "sha256": "111ca2e5294dca11d742d5ae240d2910dd78ad52a631075b791f1483c736f01d"\n },\n {\n "uri": "takeoff://protocol/v1/calibration.schema.json",\n "id": "https://opentakeoff.kentucky-ai.com/protocol/v1/calibration.schema.json",\n "title": "Calibration (draft)",\n "source": "protocol/v1/calibration.schema.json",\n "sha256": "53ab69f4df0ce57d869db576aef46f95f7592672315f7ba772e728f6c2d6ca12"\n },\n {\n "uri": "takeoff://protocol/v1/common.schema.json",\n "id": "https://opentakeoff.kentucky-ai.com/protocol/v1/common.schema.json",\n "title": "Shared value types (draft)",\n "source": "protocol/v1/common.schema.json",\n "sha256": "2c929da4d7db6af6f6d8d20d354bfb11216db6fd6edd04847fb6a0a4405e5539"\n },\n {\n "uri": "takeoff://protocol/v1/condition.schema.json",\n "id": "https://opentakeoff.kentucky-ai.com/protocol/v1/condition.schema.json",\n "title": "Condition (draft)",\n "source": "protocol/v1/condition.schema.json",\n "sha256": "c45284ba2c390bb0b0be9208f1b51fd37d969184956aa73b1b1e5276109aca86"\n },\n {\n "uri": "takeoff://protocol/v1/document-fields.schema.json",\n "id": "https://opentakeoff.kentucky-ai.com/protocol/v1/document-fields.schema.json",\n "title": "Existing document collections (draft)",\n "source": "protocol/v1/document-fields.schema.json",\n "sha256": "9ec6b05869bae0eb0e4c9d6caa12a525b6565a0e795134cfb64e6a7a232416b4"\n },\n {\n "uri": "takeoff://protocol/v1/evidence.schema.json",\n "id": "https://opentakeoff.kentucky-ai.com/protocol/v1/evidence.schema.json",\n "title": "Evidence (draft)",\n "source": "protocol/v1/evidence.schema.json",\n "sha256": "5602648262758e35cf503a46fe65fb58f7e8d2d22717e313dd5e974664d771b9"\n },\n {\n "uri": "takeoff://protocol/v1/measurement.schema.json",\n "id": "https://opentakeoff.kentucky-ai.com/protocol/v1/measurement.schema.json",\n "title": "Measurement (draft)",\n "source": "protocol/v1/measurement.schema.json",\n "sha256": "0e1b050d38963a1256f3647922ca90031eb04998d78e0f53bb3fb577bf590648"\n },\n {\n "uri": "takeoff://protocol/v1/provenance.schema.json",\n "id": "https://opentakeoff.kentucky-ai.com/protocol/v1/provenance.schema.json",\n "title": "Provenance (draft)",\n "source": "protocol/v1/provenance.schema.json",\n "sha256": "fd5347aaed173391361b0372ef73c0d45547f9abdb4e7a6ca226de53ae24cd89"\n },\n {\n "uri": "takeoff://protocol/v1/review.schema.json",\n "id": "https://opentakeoff.kentucky-ai.com/protocol/v1/review.schema.json",\n "title": "Review (draft)",\n "source": "protocol/v1/review.schema.json",\n "sha256": "3d1f326ff85e3498521e81df0cc2f2758b998be803400a25c21c65014657f245"\n },\n {\n "uri": "takeoff://protocol/v1/stitch.schema.json",\n "id": "https://opentakeoff.kentucky-ai.com/protocol/v1/stitch.schema.json",\n "title": "Composite surface (draft)",\n "source": "protocol/v1/stitch.schema.json",\n "sha256": "db9ea1dffe5fd2360e1d231efc86ba84309316ba9add72c5c0fafef006e76154"\n },\n {\n "uri": "takeoff://protocol/v1/takeoff-document.schema.json",\n "id": "https://opentakeoff.kentucky-ai.com/protocol/v1/takeoff-document.schema.json",\n "title": "TakeoffDocument v1 (draft)",\n "source": "protocol/v1/takeoff-document.schema.json",\n "sha256": "9c7800a9423a12e9334cda507fdd3c216205a4304e220df376dd67753accbd69"\n }\n ]\n}\n';
15420
15822
  var PROTOCOL_SCHEMAS = [
15421
15823
  {
15422
15824
  "uri": "takeoff://protocol/legacy/takeoff-canvas.v1.schema.json",
@@ -15439,16 +15841,93 @@ var PROTOCOL_SCHEMAS = [
15439
15841
  "id": "https://opentakeoff.kentucky-ai.com/protocol/v1/common.schema.json",
15440
15842
  "title": "Shared value types (draft)",
15441
15843
  "source": "protocol/v1/common.schema.json",
15442
- "sha256": "b5bea272a92f051b780b4a1615105416406387c4dba47b3ca65cf2892c3e8c11",
15443
- "schema_json": '{\n "$schema": "https://json-schema.org/draft/2020-12/schema",\n "$id": "https://opentakeoff.kentucky-ai.com/protocol/v1/common.schema.json",\n "title": "Shared value types (draft)",\n "$defs": {\n "point": {\n "type": "array",\n "prefixItems": [\n {\n "type": "number"\n },\n {\n "type": "number"\n }\n ],\n "items": false,\n "minItems": 2,\n "maxItems": 2,\n "description": "Two finite coordinates. Stored geometry is normalized to its sheet or stitch; values outside 0..1 are retained, not clamped. Tool input image pixels are a different frame."\n },\n "timestamp": {\n "type": "string",\n "format": "date-time"\n },\n "computed": {\n "type": "object",\n "properties": {\n "area_sf": {\n "type": "number"\n },\n "perimeter_lf": {\n "type": "number"\n },\n "count": {\n "type": "number"\n }\n },\n "additionalProperties": true,\n "description": "Stored quantity cache: square feet, linear feet, and each. Metric is display conversion. Validation never recomputes values."\n },\n "parentSnapshot": {\n "type": "object",\n "properties": {\n "verts_norm": {\n "type": "array",\n "items": {\n "$ref": "common.schema.json#/$defs/point"\n }\n },\n "verts_norm_holes": {\n "type": "array",\n "items": {\n "type": "array",\n "items": {\n "$ref": "common.schema.json#/$defs/point"\n }\n }\n },\n "computed": {\n "$ref": "#/$defs/computed"\n }\n },\n "required": [\n "verts_norm"\n ],\n "additionalProperties": true\n }\n }\n}\n'
15844
+ "sha256": "2c929da4d7db6af6f6d8d20d354bfb11216db6fd6edd04847fb6a0a4405e5539",
15845
+ "schema_json": `{
15846
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
15847
+ "$id": "https://opentakeoff.kentucky-ai.com/protocol/v1/common.schema.json",
15848
+ "title": "Shared value types (draft)",
15849
+ "$defs": {
15850
+ "point": {
15851
+ "type": "array",
15852
+ "prefixItems": [
15853
+ {
15854
+ "type": "number"
15855
+ },
15856
+ {
15857
+ "type": "number"
15858
+ }
15859
+ ],
15860
+ "items": false,
15861
+ "minItems": 2,
15862
+ "maxItems": 2,
15863
+ "description": "Two finite coordinates. Stored geometry is normalized to its sheet or stitch; values outside 0..1 are retained, not clamped. Tool input image pixels are a different frame."
15864
+ },
15865
+ "timestamp": {
15866
+ "type": "string",
15867
+ "format": "date-time"
15868
+ },
15869
+ "computed": {
15870
+ "type": "object",
15871
+ "properties": {
15872
+ "area_sf": {
15873
+ "type": "number"
15874
+ },
15875
+ "perimeter_lf": {
15876
+ "type": "number"
15877
+ },
15878
+ "count": {
15879
+ "type": "number"
15880
+ },
15881
+ "plan_lf": {
15882
+ "type": "number",
15883
+ "description": "Linear runs with a vertical leg: the flat X-Y trace alone. perimeter_lf is then plan_lf + vertical_lf."
15884
+ },
15885
+ "vertical_lf": {
15886
+ "type": "number",
15887
+ "description": "Linear runs with a vertical leg: rise + drop in feet."
15888
+ }
15889
+ },
15890
+ "additionalProperties": true,
15891
+ "description": "Stored quantity cache: square feet, linear feet (a linear run's perimeter_lf is its TOTAL incl. any rise + drop), and each. Metric is display conversion. Validation never recomputes values."
15892
+ },
15893
+ "parentSnapshot": {
15894
+ "type": "object",
15895
+ "properties": {
15896
+ "verts_norm": {
15897
+ "type": "array",
15898
+ "items": {
15899
+ "$ref": "common.schema.json#/$defs/point"
15900
+ }
15901
+ },
15902
+ "verts_norm_holes": {
15903
+ "type": "array",
15904
+ "items": {
15905
+ "type": "array",
15906
+ "items": {
15907
+ "$ref": "common.schema.json#/$defs/point"
15908
+ }
15909
+ }
15910
+ },
15911
+ "computed": {
15912
+ "$ref": "#/$defs/computed"
15913
+ }
15914
+ },
15915
+ "required": [
15916
+ "verts_norm"
15917
+ ],
15918
+ "additionalProperties": true
15919
+ }
15920
+ }
15921
+ }
15922
+ `
15444
15923
  },
15445
15924
  {
15446
15925
  "uri": "takeoff://protocol/v1/condition.schema.json",
15447
15926
  "id": "https://opentakeoff.kentucky-ai.com/protocol/v1/condition.schema.json",
15448
15927
  "title": "Condition (draft)",
15449
15928
  "source": "protocol/v1/condition.schema.json",
15450
- "sha256": "fc5694ef95d4627cc07f9a7efa34b479345341ffd905931136e4d80fa9081d51",
15451
- "schema_json": '{\n "$schema": "https://json-schema.org/draft/2020-12/schema",\n "$id": "https://opentakeoff.kentucky-ai.com/protocol/v1/condition.schema.json",\n "title": "Condition (draft)",\n "type": "object",\n "properties": {\n "id": {\n "type": "string",\n "minLength": 1\n },\n "finish_tag": {\n "type": "string"\n },\n "color": {\n "type": "string"\n },\n "fill": {\n "type": "string"\n },\n "hatch": {\n "type": "string"\n },\n "multiplier": {\n "type": "number"\n },\n "waste_pct": {\n "type": "number"\n },\n "height_ft": {\n "type": "number"\n },\n "thickness_in": {\n "type": "number"\n },\n "materials": {\n "type": "array",\n "items": {\n "type": "object",\n "properties": {\n "id": {\n "type": "string"\n },\n "name": {\n "type": "string"\n },\n "per": {\n "type": "number"\n },\n "basis": {\n "type": "string"\n },\n "unit": {\n "type": "string"\n },\n "round": {\n "type": "boolean"\n },\n "note": {\n "type": "string"\n },\n "lib_id": {\n "type": "string"\n },\n "origin_id": {\n "type": "string"\n },\n "inherited": {\n "type": "boolean"\n }\n },\n "additionalProperties": true\n }\n },\n "roll_setup": {\n "type": [\n "object",\n "null"\n ]\n },\n "tile_setup": {\n "type": [\n "object",\n "null"\n ]\n },\n "created_at": {\n "$ref": "common.schema.json#/$defs/timestamp"\n },\n "updated_at": {\n "$ref": "common.schema.json#/$defs/timestamp"\n }\n },\n "required": [\n "id"\n ],\n "additionalProperties": true,\n "description": "Existing condition configuration, not a computed report or price. Additional setup, variant, style, and material fields remain opaque and preserved in this draft."\n}\n'
15929
+ "sha256": "c45284ba2c390bb0b0be9208f1b51fd37d969184956aa73b1b1e5276109aca86",
15930
+ "schema_json": '{\n "$schema": "https://json-schema.org/draft/2020-12/schema",\n "$id": "https://opentakeoff.kentucky-ai.com/protocol/v1/condition.schema.json",\n "title": "Condition (draft)",\n "type": "object",\n "properties": {\n "id": {\n "type": "string",\n "minLength": 1\n },\n "finish_tag": {\n "type": "string"\n },\n "color": {\n "type": "string"\n },\n "fill": {\n "type": "string"\n },\n "hatch": {\n "type": "string"\n },\n "multiplier": {\n "type": "number"\n },\n "waste_pct": {\n "type": "number"\n },\n "height_ft": {\n "type": "number"\n },\n "thickness_in": {\n "type": "number"\n },\n "rise_ft": {\n "type": "number",\n "minimum": 0,\n "description": "Default vertical leg UP, in feet, every linear run of this condition adds to its plan length (LF = plan + rise + drop). A run may override it with its own rise_ft."\n },\n "drop_ft": {\n "type": "number",\n "minimum": 0,\n "description": "Default vertical leg DOWN, in feet, every linear run of this condition adds to its plan length. A run may override it with its own drop_ft."\n },\n "materials": {\n "type": "array",\n "items": {\n "type": "object",\n "properties": {\n "id": {\n "type": "string"\n },\n "name": {\n "type": "string"\n },\n "per": {\n "type": "number"\n },\n "basis": {\n "type": "string"\n },\n "unit": {\n "type": "string"\n },\n "round": {\n "type": "boolean"\n },\n "note": {\n "type": "string"\n },\n "lib_id": {\n "type": "string"\n },\n "origin_id": {\n "type": "string"\n },\n "inherited": {\n "type": "boolean"\n }\n },\n "additionalProperties": true\n }\n },\n "roll_setup": {\n "type": [\n "object",\n "null"\n ]\n },\n "tile_setup": {\n "type": [\n "object",\n "null"\n ]\n },\n "created_at": {\n "$ref": "common.schema.json#/$defs/timestamp"\n },\n "updated_at": {\n "$ref": "common.schema.json#/$defs/timestamp"\n }\n },\n "required": [\n "id"\n ],\n "additionalProperties": true,\n "description": "Existing condition configuration, not a computed report or price. Additional setup, variant, style, and material fields remain opaque and preserved in this draft."\n}\n'
15452
15931
  },
15453
15932
  {
15454
15933
  "uri": "takeoff://protocol/v1/document-fields.schema.json",
@@ -15471,8 +15950,104 @@ var PROTOCOL_SCHEMAS = [
15471
15950
  "id": "https://opentakeoff.kentucky-ai.com/protocol/v1/measurement.schema.json",
15472
15951
  "title": "Measurement (draft)",
15473
15952
  "source": "protocol/v1/measurement.schema.json",
15474
- "sha256": "1f554393245404b009481efd6a8a9847ca82a3639919a04adf5e1e619d96bbcb",
15475
- "schema_json": '{\n "$schema": "https://json-schema.org/draft/2020-12/schema",\n "$id": "https://opentakeoff.kentucky-ai.com/protocol/v1/measurement.schema.json",\n "title": "Measurement (draft)",\n "type": "object",\n "properties": {\n "id": {\n "type": "string",\n "minLength": 1\n },\n "sheet_id": {\n "type": "string",\n "minLength": 1\n },\n "condition_id": {\n "type": "string",\n "minLength": 1\n },\n "measure_role": {\n "enum": [\n "floor_area",\n "deduct",\n "linear",\n "surface_area",\n "count"\n ]\n },\n "verts_norm": {\n "type": "array",\n "items": {\n "$ref": "common.schema.json#/$defs/point"\n },\n "minItems": 1\n },\n "verts_norm_holes": {\n "type": "array",\n "items": {\n "type": "array",\n "items": {\n "$ref": "common.schema.json#/$defs/point"\n }\n }\n },\n "computed": {\n "$ref": "common.schema.json#/$defs/computed"\n },\n "height_ft": {\n "type": "number"\n },\n "label": {\n "type": "string"\n },\n "curved": {\n "type": "boolean"\n },\n "cuts_shape_id": {\n "type": "string",\n "minLength": 1\n },\n "origin": {\n "$ref": "provenance.schema.json"\n },\n "created_at": {\n "$ref": "common.schema.json#/$defs/timestamp"\n },\n "updated_at": {\n "$ref": "common.schema.json#/$defs/timestamp"\n },\n "author": {\n "type": "string"\n },\n "updated_by": {\n "type": "string"\n }\n },\n "required": [\n "id",\n "sheet_id",\n "condition_id",\n "measure_role",\n "verts_norm",\n "computed"\n ],\n "additionalProperties": true,\n "description": "One existing shape record. Legacy curved=true means spline control points; new baked arcs use ordinary vertices and origin.curved. Do not conflate or regenerate them."\n}\n'
15953
+ "sha256": "0e1b050d38963a1256f3647922ca90031eb04998d78e0f53bb3fb577bf590648",
15954
+ "schema_json": `{
15955
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
15956
+ "$id": "https://opentakeoff.kentucky-ai.com/protocol/v1/measurement.schema.json",
15957
+ "title": "Measurement (draft)",
15958
+ "type": "object",
15959
+ "properties": {
15960
+ "id": {
15961
+ "type": "string",
15962
+ "minLength": 1
15963
+ },
15964
+ "sheet_id": {
15965
+ "type": "string",
15966
+ "minLength": 1
15967
+ },
15968
+ "condition_id": {
15969
+ "type": "string",
15970
+ "minLength": 1
15971
+ },
15972
+ "measure_role": {
15973
+ "enum": [
15974
+ "floor_area",
15975
+ "deduct",
15976
+ "linear",
15977
+ "surface_area",
15978
+ "count"
15979
+ ]
15980
+ },
15981
+ "verts_norm": {
15982
+ "type": "array",
15983
+ "items": {
15984
+ "$ref": "common.schema.json#/$defs/point"
15985
+ },
15986
+ "minItems": 1
15987
+ },
15988
+ "verts_norm_holes": {
15989
+ "type": "array",
15990
+ "items": {
15991
+ "type": "array",
15992
+ "items": {
15993
+ "$ref": "common.schema.json#/$defs/point"
15994
+ }
15995
+ }
15996
+ },
15997
+ "computed": {
15998
+ "$ref": "common.schema.json#/$defs/computed"
15999
+ },
16000
+ "height_ft": {
16001
+ "type": "number"
16002
+ },
16003
+ "rise_ft": {
16004
+ "type": "number",
16005
+ "minimum": 0,
16006
+ "description": "Linear runs only: this run's own vertical leg UP in feet, overriding the condition's rise_ft (0 included). Absent means the condition default applies."
16007
+ },
16008
+ "drop_ft": {
16009
+ "type": "number",
16010
+ "minimum": 0,
16011
+ "description": "Linear runs only: this run's own vertical leg DOWN in feet, overriding the condition's drop_ft (0 included). Absent means the condition default applies."
16012
+ },
16013
+ "label": {
16014
+ "type": "string"
16015
+ },
16016
+ "curved": {
16017
+ "type": "boolean"
16018
+ },
16019
+ "cuts_shape_id": {
16020
+ "type": "string",
16021
+ "minLength": 1
16022
+ },
16023
+ "origin": {
16024
+ "$ref": "provenance.schema.json"
16025
+ },
16026
+ "created_at": {
16027
+ "$ref": "common.schema.json#/$defs/timestamp"
16028
+ },
16029
+ "updated_at": {
16030
+ "$ref": "common.schema.json#/$defs/timestamp"
16031
+ },
16032
+ "author": {
16033
+ "type": "string"
16034
+ },
16035
+ "updated_by": {
16036
+ "type": "string"
16037
+ }
16038
+ },
16039
+ "required": [
16040
+ "id",
16041
+ "sheet_id",
16042
+ "condition_id",
16043
+ "measure_role",
16044
+ "verts_norm",
16045
+ "computed"
16046
+ ],
16047
+ "additionalProperties": true,
16048
+ "description": "One existing shape record. Legacy curved=true means spline control points; new baked arcs use ordinary vertices and origin.curved. Do not conflate or regenerate them."
16049
+ }
16050
+ `
15476
16051
  },
15477
16052
  {
15478
16053
  "uri": "takeoff://protocol/v1/provenance.schema.json",
@@ -15777,7 +16352,7 @@ function nameTheStageInRefusals(server) {
15777
16352
  // package.json
15778
16353
  var package_default = {
15779
16354
  name: "opentakeoff-mcp",
15780
- version: "0.9.87",
16355
+ version: "0.9.90",
15781
16356
  mcpName: "io.github.Kentucky-ai/opentakeoff",
15782
16357
  type: "module",
15783
16358
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
@@ -15797,7 +16372,7 @@ var package_default = {
15797
16372
  "check:wiki": "node scripts/check-wiki.mjs",
15798
16373
  "check:versions": "node ../scripts/check-version-consistency.mjs",
15799
16374
  "check:tool-count": "node --import tsx scripts/check-tool-count.mjs",
15800
- test: "node --import tsx --test test/arc.test.ts test/checkers.test.ts test/conformance.test.ts test/constants.test.ts test/context.test.ts test/document.test.ts test/dxf.test.ts test/e2e.test.ts test/gate.test.ts test/graphEvalRowsym.test.ts test/labels.test.ts test/mep.test.ts test/overlap.test.ts test/parity.test.ts test/proposals.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/scope.test.ts test/session.test.ts test/staging.test.ts test/sweepguard.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts test/wiki.test.ts"
16375
+ test: "node --import tsx --test test/arc.test.ts test/checkers.test.ts test/conformance.test.ts test/constants.test.ts test/context.test.ts test/document.test.ts test/dxf.test.ts test/e2e.test.ts test/gate.test.ts test/graphEvalRowsym.test.ts test/labels.test.ts test/mep.test.ts test/overlap.test.ts test/parity.test.ts test/proposals.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/scope.test.ts test/session.test.ts test/splittags.test.ts test/staging.test.ts test/sweepguard.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts test/wiki.test.ts"
15801
16376
  },
15802
16377
  dependencies: {
15803
16378
  "@modelcontextprotocol/sdk": "^1.12.0",