opentakeoff-mcp 0.9.86 → 0.9.87

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 +226 -25
  2. package/package.json +2 -2
@@ -6096,6 +6096,73 @@ function labelPlacements(placements, spans, segs, lum) {
6096
6096
  return out;
6097
6097
  }
6098
6098
 
6099
+ // ../web/src/lib/arc.js
6100
+ var TAU = Math.PI * 2;
6101
+ var norm2 = (a) => (a % TAU + TAU) % TAU;
6102
+ function circleThrough(a, m, b) {
6103
+ const [ax, ay] = a, [mx, my] = m, [bx, by] = b;
6104
+ const d = 2 * (ax * (my - by) + mx * (by - ay) + bx * (ay - my));
6105
+ if (!Number.isFinite(d) || Math.abs(d) < 1e-9) return null;
6106
+ const a2 = ax * ax + ay * ay, m2 = mx * mx + my * my, b2 = bx * bx + by * by;
6107
+ const cx = (a2 * (my - by) + m2 * (by - ay) + b2 * (ay - my)) / d;
6108
+ const cy = (a2 * (bx - mx) + m2 * (ax - bx) + b2 * (mx - ax)) / d;
6109
+ const r = Math.hypot(ax - cx, ay - cy);
6110
+ if (!Number.isFinite(r) || r > 1e7) return null;
6111
+ const a0 = Math.atan2(ay - cy, ax - cx);
6112
+ const a1 = Math.atan2(by - cy, bx - cx);
6113
+ const am = Math.atan2(my - cy, mx - cx);
6114
+ const fwd = norm2(a1 - a0);
6115
+ const fwdM = norm2(am - a0);
6116
+ const sweep = fwdM <= fwd ? fwd : fwd - TAU;
6117
+ return { cx, cy, r, a0, a1, am, sweep };
6118
+ }
6119
+ function stepsFor(r, sweep) {
6120
+ const len = Math.abs(sweep) * r;
6121
+ const bySpan = Math.abs(sweep) / (4 * Math.PI / 180);
6122
+ return Math.max(8, Math.min(96, Math.ceil(Math.max(len / 4, bySpan))));
6123
+ }
6124
+ function flattenArc(a, m, b) {
6125
+ const c = circleThrough(a, m, b);
6126
+ if (!c) return [];
6127
+ const total = stepsFor(c.r, c.sweep);
6128
+ const toM = c.sweep >= 0 ? norm2(c.am - c.a0) : -norm2(c.a0 - c.am);
6129
+ const f = Math.abs(c.sweep) > 1e-12 ? Math.min(1, Math.abs(toM) / Math.abs(c.sweep)) : 0;
6130
+ const n1 = Math.max(1, Math.min(total - 1, Math.round(total * f)));
6131
+ const n2 = Math.max(1, total - n1);
6132
+ const at = (t) => [c.cx + c.r * Math.cos(t), c.cy + c.r * Math.sin(t)];
6133
+ const out = [];
6134
+ for (let i = 1; i < n1; i++) out.push(at(c.a0 + toM * (i / n1)));
6135
+ out.push([m[0], m[1]]);
6136
+ for (let i = 1; i < n2; i++) out.push(at(c.am + (c.sweep - toM) * (i / n2)));
6137
+ return out;
6138
+ }
6139
+ function flattenArcRing(pts, throughAt, closed2 = true) {
6140
+ const src = pts || [];
6141
+ const n = src.length;
6142
+ const copy = () => src.map((p) => [p[0], p[1]]);
6143
+ const marks = throughAt instanceof Set ? throughAt : new Set(throughAt || []);
6144
+ if (n < 3 || !marks.size) return copy();
6145
+ const live = new Array(n).fill(false);
6146
+ for (let i = 0; i < n; i++) {
6147
+ if (!marks.has(i)) continue;
6148
+ const hasPrev = closed2 || i > 0;
6149
+ const hasNext = closed2 || i < n - 1;
6150
+ if (!hasPrev || !hasNext) continue;
6151
+ const pi = (i - 1 + n) % n, ni = (i + 1) % n;
6152
+ if (marks.has(pi) || marks.has(ni)) continue;
6153
+ live[i] = true;
6154
+ }
6155
+ const out = [];
6156
+ for (let i = 0; i < n; i++) {
6157
+ if (!live[i]) {
6158
+ out.push([src[i][0], src[i][1]]);
6159
+ continue;
6160
+ }
6161
+ for (const p of flattenArc(src[(i - 1 + n) % n], src[i], src[(i + 1) % n])) out.push(p);
6162
+ }
6163
+ return out;
6164
+ }
6165
+
6099
6166
  // ../web/src/lib/shapeMetrics.js
6100
6167
  function computeShapeMetrics(s, dims, upp, cond) {
6101
6168
  const pts = (s.verts_norm || []).map(([nx, ny]) => [nx * dims.w, ny * dims.h]);
@@ -8840,24 +8907,53 @@ var Session = class _Session {
8840
8907
  ...s.upp == null ? { warning: `No scale set for ${s.key} \u2014 quantities unavailable. Call set_scale${s.detected ? ` (detected: ${s.detected.label})` : ""}.` } : {}
8841
8908
  };
8842
8909
  }
8910
+ /** Bend a trace the way the canvas's Curve mode does (#284): `arcThrough`
8911
+ * lists the indices of points that are the MIDDLE of a three-point arc —
8912
+ * the boundary runs pts[i-1] → pts[i] → pts[i+1] as the unique circle
8913
+ * through those three instead of two chords. The arc is baked to ordinary
8914
+ * vertices here (the canvas's flattenArcRing, same steps, same budget), so
8915
+ * SF/LF, the marked set, edit_shape and every export keep seeing a plain
8916
+ * polygon — and the shape's origin carries `curved: true`, the canvas's own
8917
+ * stamp. Refusal over guessing: an index off the trace, a bow point at the
8918
+ * end of an open run (no far corner to bend to), or two bows in a row (no
8919
+ * clean triple) refuse whole rather than silently demoting to a corner. */
8920
+ bend(pts, arcThrough, closed2, tool) {
8921
+ if (!arcThrough?.length) return { pts, arcs: 0 };
8922
+ const n = pts.length;
8923
+ const marks = [...new Set(arcThrough)].sort((a, b) => a - b);
8924
+ for (const i of marks) {
8925
+ if (!Number.isInteger(i) || i < 0 || i >= n) throw new UserError(`${tool}: arc_through index ${i} is off the trace (${n} points, indices 0\u2013${n - 1}).`);
8926
+ if (!closed2 && (i === 0 || i === n - 1)) throw new UserError(`${tool}: arc_through ${i} is an END of the open run \u2014 a bow needs a corner on both sides. State the arc's start, its bow, and its far end as three consecutive points and mark the middle one.`);
8927
+ }
8928
+ for (let k = 1; k < marks.length; k++) {
8929
+ if (marks[k] - marks[k - 1] === 1 || closed2 && marks[0] === 0 && marks[marks.length - 1] === n - 1) {
8930
+ throw new UserError(`${tool}: arc_through marks ${marks[k - 1]} and ${marks[k]} are adjacent \u2014 every arc needs a corner between it and the next. Put a plain vertex where one arc ends and the next begins.`);
8931
+ }
8932
+ }
8933
+ if (n < 3) throw new UserError(`${tool}: an arc needs three points (start, bow, far end); got ${n}.`);
8934
+ const flat = flattenArcRing(pts, marks, closed2);
8935
+ return { pts: flat, arcs: marks.length };
8936
+ }
8843
8937
  measurePolygon(name, verts, opts) {
8844
8938
  const s = this.sheet(name);
8845
8939
  if (s.upp == null) throw new UserError(this.scaleGate(s));
8846
- const { area_sf = 0, perimeter_lf = 0 } = this.quantify(s, opts.role, verts);
8940
+ const { pts: ring, arcs } = this.bend(verts, opts.arc_through, true, "measure_polygon");
8941
+ const { area_sf = 0, perimeter_lf = 0 } = this.quantify(s, opts.role, ring);
8847
8942
  let shape_id;
8848
- if (opts.condition) shape_id = this.commit(s, opts.condition, opts.role, verts, { area_sf, perimeter_lf }, { method: "manual", actor: "agent" }).id;
8943
+ if (opts.condition) shape_id = this.commit(s, opts.condition, opts.role, ring, { area_sf, perimeter_lf }, { method: "manual", actor: "agent", ...arcs ? { curved: true } : {} }).id;
8849
8944
  this.flushCommits("measure_polygon");
8850
- const mixed = this.scaleWarningFor(s, verts);
8851
- return { area_sf, perimeter_lf, nverts: verts.length, ...shape_id ? { shape_id } : {}, ...mixed ? { warning: mixed } : {} };
8945
+ const mixed = this.scaleWarningFor(s, ring);
8946
+ return { area_sf, perimeter_lf, nverts: ring.length, ...arcs ? { arcs } : {}, ...shape_id ? { shape_id } : {}, ...mixed ? { warning: mixed } : {} };
8852
8947
  }
8853
8948
  measureLine(name, pts, opts) {
8854
8949
  const s = this.sheet(name);
8855
8950
  if (s.upp == null) throw new UserError(this.scaleGate(s));
8856
- const { perimeter_lf: length_lf = 0 } = this.quantify(s, "linear", pts);
8951
+ 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);
8857
8953
  let shape_id;
8858
- if (opts.condition) shape_id = this.commit(s, opts.condition, "linear", pts, { area_sf: 0, perimeter_lf: length_lf }, { method: "manual", actor: "agent" }).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;
8859
8955
  this.flushCommits("measure_line");
8860
- return { length_lf, npts: pts.length, ...shape_id ? { shape_id } : {} };
8956
+ return { length_lf, npts: run2.length, ...arcs ? { arcs } : {}, ...shape_id ? { shape_id } : {} };
8861
8957
  }
8862
8958
  /** Surface Area — the canvas's Surface tool (commitSurface): an OPEN run
8863
8959
  * traced along the wall in plan view, quantified as traced LF × height.
@@ -8868,6 +8964,8 @@ var Session = class _Session {
8868
8964
  measureSurface(name, pts, opts) {
8869
8965
  const s = this.sheet(name);
8870
8966
  if (s.upp == null) throw new UserError(this.scaleGate(s));
8967
+ const bent = this.bend(pts, opts.arc_through, false, "measure_surface");
8968
+ pts = bent.pts;
8871
8969
  const existing = this.conditions.find((x) => x.finish_tag === opts.condition);
8872
8970
  const h = opts.height_ft ?? (Number(existing?.height_ft) || 0);
8873
8971
  if (!(h > 0)) {
@@ -8881,8 +8979,9 @@ var Session = class _Session {
8881
8979
  const LF = openLen(pts) * s.upp;
8882
8980
  const shape = this.commit(s, opts.condition, "surface_area", pts, { area_sf: round2(LF * h), perimeter_lf: round2(LF) }, { method: "manual", actor: "agent" });
8883
8981
  shape.height_ft = h;
8982
+ if (bent.arcs && shape.origin) shape.origin = { ...shape.origin, curved: true };
8884
8983
  this.flushCommits("measure_surface");
8885
- return { condition: c.finish_tag, height_ft: h, length_lf: round2(LF), area_sf: round2(LF * h), npts: pts.length, shape_id: shape.id };
8984
+ return { condition: c.finish_tag, height_ft: h, length_lf: round2(LF), area_sf: round2(LF * h), npts: pts.length, ...bent.arcs ? { arcs: bent.arcs } : {}, shape_id: shape.id };
8886
8985
  }
8887
8986
  // ── Proposals (#365) ──────────────────────────────────────────────────────
8888
8987
  // A shape an agent commits lands reviewed:false and the estimator accepts
@@ -9535,10 +9634,10 @@ var Session = class _Session {
9535
9634
  const base = chain[0].origin.parent_prev;
9536
9635
  const r = recomposeCutouts(px(base.verts_norm), (base.verts_norm_holes ?? []).map(px), rest.map((x) => px(x.verts_norm)));
9537
9636
  if (!r) return null;
9538
- const norm2 = (ring) => ring.map(([x, y]) => [x / s.widthPx, y / s.heightPx]);
9637
+ const norm3 = (ring) => ring.map(([x, y]) => [x / s.widthPx, y / s.heightPx]);
9539
9638
  return {
9540
- verts_norm: norm2(r.outer),
9541
- ...r.holes.length ? { verts_norm_holes: r.holes.map(norm2) } : {},
9639
+ verts_norm: norm3(r.outer),
9640
+ ...r.holes.length ? { verts_norm_holes: r.holes.map(norm3) } : {},
9542
9641
  computed: { area_sf: round2(r.area * upp * upp), perimeter_lf: round2(r.perim * upp) }
9543
9642
  };
9544
9643
  }
@@ -11955,6 +12054,7 @@ var measurePolygonOutput = {
11955
12054
  area_sf: z.number(),
11956
12055
  perimeter_lf: z.number(),
11957
12056
  nverts: z.number().int(),
12057
+ 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"),
11958
12058
  shape_id: z.string().optional().describe("Present when condition was passed and the shape committed"),
11959
12059
  warning: z.string().optional().describe("Mixed-scale warning (#153): a scale note disagreeing with the sheet's sits in the measured region \u2014 verify before trusting these numbers")
11960
12060
  };
@@ -11964,6 +12064,7 @@ var measureSurfaceOutput = {
11964
12064
  length_lf: z.number().describe("The traced run's open length"),
11965
12065
  area_sf: z.number().describe("length_lf \xD7 height_ft \u2014 the wall SF committed"),
11966
12066
  npts: z.number().int(),
12067
+ 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"),
11967
12068
  shape_id: z.string()
11968
12069
  };
11969
12070
  var placeCountOutput = {
@@ -12064,6 +12165,7 @@ var symbolSweepOutput = {
12064
12165
  var measureLineOutput = {
12065
12166
  length_lf: z.number(),
12066
12167
  npts: z.number().int(),
12168
+ 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"),
12067
12169
  shape_id: z.string().optional().describe("Present when condition was passed and the shape committed")
12068
12170
  };
12069
12171
  var summaryRow = z.object({
@@ -13360,6 +13462,78 @@ function boostForDark(hex2) {
13360
13462
  return "#" + toHex2(nr) + toHex2(ng) + toHex2(nb);
13361
13463
  }
13362
13464
 
13465
+ // ../web/src/lib/markupText.js
13466
+ var NOTE_PT = 8.5;
13467
+ var NOTE_MAX_W_PT = 216;
13468
+ var LINE_PITCH = 1.28;
13469
+ var NOTE_PAD_PT = 3;
13470
+ function ptToImg(pt) {
13471
+ return pt * RENDER_SCALE;
13472
+ }
13473
+ function wrapLines(text, maxW, measure) {
13474
+ const src = String(text ?? "");
13475
+ if (!src.trim()) return [];
13476
+ const out = [];
13477
+ for (const para of src.split(/\r?\n/)) {
13478
+ const words = para.trim().split(/\s+/).filter(Boolean);
13479
+ if (!words.length) {
13480
+ out.push("");
13481
+ continue;
13482
+ }
13483
+ let line = "";
13484
+ for (const w of words) {
13485
+ const cand = line ? `${line} ${w}` : w;
13486
+ if (measure(cand) <= maxW || !line && measure(w) <= maxW) {
13487
+ line = cand;
13488
+ continue;
13489
+ }
13490
+ if (line) {
13491
+ out.push(line);
13492
+ line = "";
13493
+ }
13494
+ if (measure(w) <= maxW) {
13495
+ line = w;
13496
+ continue;
13497
+ }
13498
+ let piece = "";
13499
+ for (const ch of w) {
13500
+ if (measure(piece + ch) > maxW && piece) {
13501
+ out.push(piece);
13502
+ piece = ch;
13503
+ } else piece += ch;
13504
+ }
13505
+ line = piece;
13506
+ }
13507
+ out.push(line);
13508
+ }
13509
+ return out;
13510
+ }
13511
+ function layoutNote({ text, fontPx, measure, maxWPx, padPx }) {
13512
+ const ratio = fontPx / ptToImg(NOTE_PT);
13513
+ const maxW = maxWPx > 0 ? maxWPx : ptToImg(NOTE_MAX_W_PT) * ratio;
13514
+ const pad = padPx >= 0 ? padPx : ptToImg(NOTE_PAD_PT) * ratio;
13515
+ const lines = wrapLines(text, maxW - 2 * pad, measure);
13516
+ const lineH = fontPx * LINE_PITCH;
13517
+ const widest = lines.reduce((m, l) => Math.max(m, measure(l)), 0);
13518
+ return {
13519
+ lines,
13520
+ fontPx,
13521
+ lineH,
13522
+ padX: pad,
13523
+ padY: pad,
13524
+ w: lines.length ? widest + 2 * pad : 0,
13525
+ h: lines.length ? lines.length * lineH + 2 * pad : 0
13526
+ };
13527
+ }
13528
+ function noteBox(ax, ay, L) {
13529
+ const x0 = ax - L.padX;
13530
+ const y0 = ay - L.fontPx - L.padY;
13531
+ return { x0, y0, x1: x0 + L.w, y1: y0 + L.h };
13532
+ }
13533
+ function lineBaseline(ay, L, i) {
13534
+ return ay + i * L.lineH;
13535
+ }
13536
+
13363
13537
  // ../web/src/lib/markedsetSource.js
13364
13538
  function sourcePageMode({ dark = false, encrypted = false } = {}) {
13365
13539
  if (dark) return "raster-inverted";
@@ -13896,6 +14070,28 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
13896
14070
  const [px, py] = toPage(x, y);
13897
14071
  pg.drawText(winAnsiSafe(t), { x: px, y: py, size, font: fnt, color: colorRgb, rotate: chipRot });
13898
14072
  };
14073
+ const noteBlock = (raw, ax, ay, colorRgb, backing) => {
14074
+ const t = winAnsiSafe(raw);
14075
+ const fs = NOTE_PT / ptScale;
14076
+ const L = layoutNote({ text: t, fontPx: fs, measure: (str) => bold.widthOfTextAtSize(str, NOTE_PT) / ptScale });
14077
+ if (!L.lines.length) return;
14078
+ const b = noteBox(ax, ay, L);
14079
+ const dp = imageDrawParams(toPage, b.x0, b.y0, L.w, L.h);
14080
+ pg.drawRectangle({
14081
+ x: dp.x,
14082
+ y: dp.y,
14083
+ width: dp.width,
14084
+ height: dp.height,
14085
+ rotate: degrees(dp.rotateDeg),
14086
+ color: backing,
14087
+ opacity: 0.92,
14088
+ borderColor: colorRgb,
14089
+ borderWidth: 0.7
14090
+ });
14091
+ L.lines.forEach((ln, i) => {
14092
+ if (ln) text(ln, ax, lineBaseline(ay, L, i), NOTE_PT, colorRgb, bold);
14093
+ });
14094
+ };
13899
14095
  const chip = (raw, x, y, borderRgb) => {
13900
14096
  const t = winAnsiSafe(raw);
13901
14097
  const size = 7.5;
@@ -14040,7 +14236,9 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
14040
14236
  const [ptx, pty] = toPage(m.target[0] * W, m.target[1] * H);
14041
14237
  pg.drawSvgPath(arrowheadPath(pax, -pay, ptx, -pty, 5), { x: 0, y: 0, color: mcol, opacity: 0.9 });
14042
14238
  }
14043
- text(lbl(m.text), m.at[0] * W, m.at[1] * H, 8.5, mcol, bold);
14239
+ noteBlock(lbl(m.text), m.at[0] * W, m.at[1] * H, mcol, dark ? rgb(0.08, 0.1, 0.12) : rgb(1, 1, 1));
14240
+ } else if (m.type === "text" && m.at) {
14241
+ noteBlock(lbl(m.text), m.at[0] * W, m.at[1] * H, mcol, dark ? rgb(0.08, 0.1, 0.12) : rgb(1, 0.97, 0.93));
14044
14242
  } else if (m.type === "svg" && m.at && Array.isArray(m.vb) && typeof m.path === "string") {
14045
14243
  const { s: sx, bw, bh } = svgPlacedBox(m.vb, m.w, W);
14046
14244
  if (sx > 0) {
@@ -14591,15 +14789,16 @@ function registerTools(realServer, session, opts = {}) {
14591
14789
  outputSchema: proposeTakeoffOutput
14592
14790
  }, run("propose_takeoff", (a) => session.proposeTakeoff(a.label, a.rationale)));
14593
14791
  server.registerTool("measure_polygon", {
14594
- description: `Measure a closed polygon you supply (min 3 vertices, image px): area_sf and perimeter_lf at the sheet's scale. Requires the scale to be set. Pass condition to commit it; role "deduct" subtracts. A room ring belongs on the innermost wall-face strokes from get_sheet_vectors, crossing each door opening on the wall centerline and wrapping columns and stubs; never on a hatch edge, casework or a door leaf. Check it with view_sheet overlay:true on a tight crop and fix it with edit_shape. ${COORDS}`,
14792
+ description: `Measure a closed polygon you supply (min 3 vertices, image px): area_sf and perimeter_lf at the sheet's scale. Requires the scale to be set. Pass condition to commit it; role "deduct" subtracts. A room ring belongs on the innermost wall-face strokes from get_sheet_vectors, crossing each door opening on the wall centerline and wrapping columns and stubs; never on a hatch edge, casework or a door leaf. Check it with view_sheet overlay:true on a tight crop and fix it with edit_shape. A CURVED wall is a circle: do not chord it and do not hand-tessellate it \u2014 give the bow one point on the wall and list its index in arc_through. ${COORDS}`,
14595
14793
  inputSchema: {
14596
14794
  sheet: z2.string(),
14597
14795
  verts: z2.array(pointSchema).min(3),
14598
14796
  condition: z2.string().optional(),
14599
- role: roleSchema
14797
+ role: roleSchema,
14798
+ 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.")
14600
14799
  },
14601
14800
  outputSchema: measurePolygonOutput
14602
- }, run("measure_polygon", (a) => session.measurePolygon(a.sheet, a.verts, { condition: a.condition, role: a.role })));
14801
+ }, run("measure_polygon", (a) => session.measurePolygon(a.sheet, a.verts, { condition: a.condition, role: a.role, arc_through: a.arc_through })));
14603
14802
  server.registerTool("cut_out", {
14604
14803
  description: `Cut a REAL hole in a committed floor_area shape (#206) \u2014 the way the canvas cuts one (#137): the same lib/cutout.js boolean subtract, so the two surfaces can never disagree about what a hole holds. The parent keeps its outer ring plus the reconciled hole(s) (verts_norm_holes), its computed nets for real \u2014 N cuts compose, overlap between cuts never double-deducts (set subtraction), a hole ADDS perimeter \u2014 and the deduct commits carrying cuts_shape_id so the report and legend read the reconciled number, never a second arithmetic pass. This is the verb for a column, a floor drain, an island of casework INSIDE a room; an independent measure_polygon role:"deduct" stays the tool for a deduction that isn't a hole in one parent. Refusal over guessing: the ring must sit FULLY inside the parent's outer ring (an edge-crossing cut is a boundary correction \u2014 edit_shape the parent instead), and a cut that would erase the parent or split it in two refuses whole (trace the pieces as rooms). One journal entry \u2014 undo_last restores parent and hole together; delete_shape on the deduct later reverts the cut too (a multi-cut parent rebuilds from the chain's pristine snapshot minus the survivors). AN OPEN RUN IS CLIPPED, NOT SUBTRACTED: wall tile (surface_area) and base/transitions (linear) are polylines traced in plan, so the ring removes the stretch it covers, the run keeps its id and takes what survives, and a cut through the MIDDLE leaves the far side as its own shape (same condition, same height) \u2014 quantities ride the surviving length, which is exact, since wall SF is LF \xD7 height and a border's SF is LF \xD7 thickness. No deduct is minted for a run: there is no area for one to sit on, and a deduct's SF counts against the FLOOR total a run never fills. A ring that misses the run, one that swallows it whole (delete_shape it), and a curved run (its verts are control points) all refuse. A derived base with numeric openings also refuses: those deductions have no stored location; use measure_line for installed runs so a geometric cut cannot erase the numeric allowance. ${COORDS}`,
14605
14804
  inputSchema: {
@@ -14609,24 +14808,26 @@ function registerTools(realServer, session, opts = {}) {
14609
14808
  outputSchema: cutOutOutput
14610
14809
  }, run("cut_out", (a) => session.cutOut(a)));
14611
14810
  server.registerTool("measure_line", {
14612
- 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). ${COORDS}`,
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}`,
14613
14812
  inputSchema: {
14614
14813
  sheet: z2.string(),
14615
14814
  pts: z2.array(pointSchema).min(2),
14616
- condition: z2.string().optional()
14815
+ condition: z2.string().optional(),
14816
+ 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.")
14617
14817
  },
14618
14818
  outputSchema: measureLineOutput
14619
- }, run("measure_line", (a) => session.measureLine(a.sheet, a.pts, { condition: a.condition })));
14819
+ }, run("measure_line", (a) => session.measureLine(a.sheet, a.pts, { condition: a.condition, arc_through: a.arc_through })));
14620
14820
  server.registerTool("measure_surface", {
14621
14821
  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}`,
14622
14822
  inputSchema: {
14623
14823
  sheet: z2.string(),
14624
14824
  pts: z2.array(pointSchema).min(2).describe("The wall run, an open polyline (image px)"),
14625
14825
  condition: z2.string().describe("Finish tag to commit under (minted on first use), e.g. 'CT-W1'"),
14626
- height_ft: z2.number().positive().optional().describe("Wall height in feet \u2014 written to the condition's H knob first, then used")
14826
+ height_ft: z2.number().positive().optional().describe("Wall height in feet \u2014 written to the condition's H knob first, then used"),
14827
+ 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.")
14627
14828
  },
14628
14829
  outputSchema: measureSurfaceOutput
14629
- }, run("measure_surface", (a) => session.measureSurface(a.sheet, a.pts, { condition: a.condition, height_ft: a.height_ft })));
14830
+ }, run("measure_surface", (a) => session.measureSurface(a.sheet, a.pts, { condition: a.condition, height_ft: a.height_ft, arc_through: a.arc_through })));
14630
14831
  server.registerTool("place_count", {
14631
14832
  description: `Count markers \u2014 EA (#146): one point, one each. Thresholds, stair nosings, floor boxes, entrance mats \u2014 the scale-free quantity family. Commits one count shape per point (computed {count: 1}, exactly the canvas's Count tool), NO scale required, and the whole call is ONE undo step${oneClick ? " like a detect_rooms sweep" : ""}. takeoff_summary reports them as ea; the marked set draws each marker. ${COORDS}`,
14632
14833
  inputSchema: {
@@ -15138,7 +15339,7 @@ No review gate: the pencil-not-ink rule exists to stop an agent inventing geomet
15138
15339
  import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
15139
15340
 
15140
15341
  // src/wiki.generated.ts
15141
- var WIKI_VERSION = "0.9.86";
15342
+ var WIKI_VERSION = "0.9.87";
15142
15343
  var WIKI_PAGES = [
15143
15344
  {
15144
15345
  "key": "index",
@@ -15177,8 +15378,8 @@ var WIKI_PAGES = [
15177
15378
  "uri": "takeoff://wiki/workflows",
15178
15379
  "title": "Workflows",
15179
15380
  "source": "docs/wiki/workflows.md",
15180
- "source_sha256": "75afa0b82f7163038164851e49f9c63c8ed6c7a69eb2dc1e084423ad8424cc4a",
15181
- "text": "# Workflows\n\n## Human: open, stitch and measure\n\nOpen the plans and check their revision. Press **G** (or choose **Sheets \u2192 Open\ngallery\u2026**) to open the sheet gallery, then select the\n2\u20134 split sheets in left-to-right order and click **Stitch N into one surface**.\nOn the composite, choose **Align**, click a recognizable point near the joint,\nthen click the same drawn point on the other sheet. Check a second recognizable\npoint and the scale before tracing. Align translates a member; it does not\nrotate or resize it. Once shapes exist on a stitch, alignment is locked.\n\nIf the controls seem missing, **Stitch N into one surface** is in the gallery\nfooter and requires 2\u20134 selected sheets; **Align** appears only on an open\nstitched surface. Create a fresh stitch if Align refuses because takeoffs\nalready exist; deleting a stitch also refuses while takeoffs or markups exist.\n\nUse the composite as one measuring surface. Its marked-set page is labeled as\na composite, not an architect-issued sheet. Save the editable project archive\nwhen preserving the composite is required. MCP has no stitch/alignment tool and\nits current import/export path omits stitches: it cannot be used as a lossless\nhandoff for that document. Have an MCP agent measure source sheets individually\nwith explicit scope boundaries instead.\n\nSource: [Human stitching instructions](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/USER_GUIDE.md#stitching-a-floor-split-at-a-match-line),\n[stitch records](https://github.com/Kentucky-ai/opentakeoff/blob/main/web/src/lib/stitches.ts),\n[compatibility evidence](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/COMPATIBILITY.md).\n\n## Agent: source to reviewed handoff\n\n1. `load_plan` \u2192 inspect sheets/revisions \u2192 `set_scale` on each measured sheet.\n Confirm the relevant detail's scale, which may differ from the overall plan.\n2. Read source text and vectors; inspect the matching region with `view_sheet`.\n Use each room's schedule evidence for its finish. Missing/ambiguous evidence\n is a qualification or RFI, not an inferred assignment.\n3. `propose_takeoff` names a batch; it creates no geometry. Measure small batches\n with the appropriate area, run, surface or count tool.\n4. Inspect overlays and actual boundaries, openings, jambs and deductions.\n `edit_shape` corrects pending shapes. Physical base gaps use explicit runs;\n stepped wall faces use separate height bands. See the [geometry workflow](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/GEOMETRY_WORKFLOW.md).\n\n### Trace a room the way an estimator does\n\nBlind agent runs against reviewed references showed that the ring, not the\ntotal, is what fails. These rules are what the references are drawn to:\n\n1. The boundary is the **innermost interior wall face**. Never the wall\n centerline, never the far face, never under a wall. Casework, counters,\n fixtures, equipment, hatch patterns, dimension strings, text and leaders\n never define the boundary; the finish runs under casework and fixtures.\n2. Corners sit where two adjacent wall-face strokes meet. Read the strokes with\n `get_sheet_vectors` over a tight region and put each vertex on a stroke; do\n not trace a hatch edge or a raster guess.\n3. At every **door or cased opening** the ring follows the face to the jamb,\n turns into the opening, runs across it on the **wall centerline** (midway\n between that wall's two faces, the full wall on a composite wall), and\n returns along the far jamb. Both rooms sharing the door share that segment.\n A door leaf and its swing arc are never part of the boundary; a leaf drawn\n standing open looks like a wall line and is not one.\n4. **Windows** and other non-passable openings do not break the ring.\n5. Columns, chases, pilasters and wall stubs that project into the room are\n traced around; an enclosed cell drawn with wall-weight lines is not floor.\n6. A **finish split** inside one room is two rings sharing the drawn\n transition line exactly, no overlap and no gap. Where two rooms meet with no\n wall, split on the drawn transition line or the partition's centerline.\n7. Take the finish from the schedule row; a plan tag alone is a cross-check.\n8. After each ring, `view_sheet` a tight crop with `overlay: true` at a high\n `px` and look at every corner and notch before the next room; `edit_shape`\n fixes what the crop shows. A full-sheet render cannot audit a ring.\n\nWhen something is genuinely ambiguous, follow the rule most literally, carry it,\nand say so in the shape's label or an annotation. Do not stop.\n5. `takeoff_summary` and `export_report` check quantities and material coverage.\n Shorten notes through `list_annotations` \u2192 `edit_annotation` where permitted.\n6. Export editable takeoff JSON and a marked-set PDF, reopen the JSON against\n the same source, and check the handoff. Leave agent work pending for the\n human's review; exported files and agent verdicts do not create approval.\n\nThe [MCP route map](takeoff://wiki/mcp) gives the next tool at each step. The\n[agent guide](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/AGENT_GUIDE.md) covers staging and refusal recovery.\n\n## Evidence that another person can review\n\nKeep source/revision identifiers, calibration choices, measured geometry,\nexpected-versus-observed checks and an overlay or marked-set view. Compare\nspatial agreement as well as quantities. Disclose reference-assisted work;\nit is not a blind accuracy benchmark. Public PRs include screenshots, video or\nreproducible stats, using publishable fixtures. Private drawings and pricing stay\noutside the public repository. See the [repository guide](takeoff://wiki/repo-guide).\n"
15381
+ "source_sha256": "0281270b8f1a87532b6bf1aa1add8e4f39f4adb750a3676398d93b1ac4ee51c8",
15382
+ "text": "# Workflows\n\n## Human: open, stitch and measure\n\nOpen the plans and check their revision. Press **G** (or choose **Sheets \u2192 Open\ngallery\u2026**) to open the sheet gallery, then select the\n2\u20134 split sheets in left-to-right order and click **Stitch N into one surface**.\nOn the composite, choose **Align**, click a recognizable point near the joint,\nthen click the same drawn point on the other sheet. Check a second recognizable\npoint and the scale before tracing. Align translates a member; it does not\nrotate or resize it. Once shapes exist on a stitch, alignment is locked.\n\nIf the controls seem missing, **Stitch N into one surface** is in the gallery\nfooter and requires 2\u20134 selected sheets; **Align** appears only on an open\nstitched surface. Create a fresh stitch if Align refuses because takeoffs\nalready exist; deleting a stitch also refuses while takeoffs or markups exist.\n\nUse the composite as one measuring surface. Its marked-set page is labeled as\na composite, not an architect-issued sheet. Save the editable project archive\nwhen preserving the composite is required. MCP has no stitch/alignment tool and\nits current import/export path omits stitches: it cannot be used as a lossless\nhandoff for that document. Have an MCP agent measure source sheets individually\nwith explicit scope boundaries instead.\n\nSource: [Human stitching instructions](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/USER_GUIDE.md#stitching-a-floor-split-at-a-match-line),\n[stitch records](https://github.com/Kentucky-ai/opentakeoff/blob/main/web/src/lib/stitches.ts),\n[compatibility evidence](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/COMPATIBILITY.md).\n\n## Agent: source to reviewed handoff\n\n1. `load_plan` \u2192 inspect sheets/revisions \u2192 `set_scale` on each measured sheet.\n Confirm the relevant detail's scale, which may differ from the overall plan.\n2. Read source text and vectors; inspect the matching region with `view_sheet`.\n Use each room's schedule evidence for its finish. Missing/ambiguous evidence\n is a qualification or RFI, not an inferred assignment.\n3. `propose_takeoff` names a batch; it creates no geometry. Measure small batches\n with the appropriate area, run, surface or count tool.\n4. Inspect overlays and actual boundaries, openings, jambs and deductions.\n `edit_shape` corrects pending shapes. Physical base gaps use explicit runs;\n stepped wall faces use separate height bands. See the [geometry workflow](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/GEOMETRY_WORKFLOW.md).\n\n### Trace a room the way an estimator does\n\nBlind agent runs against reviewed references showed that the ring, not the\ntotal, is what fails. These rules are what the references are drawn to:\n\n1. The boundary is the **innermost interior wall face**. Never the wall\n centerline, never the far face, never under a wall. Casework, counters,\n fixtures, equipment, hatch patterns, dimension strings, text and leaders\n never define the boundary; the finish runs under casework and fixtures.\n2. Corners sit where two adjacent wall-face strokes meet. Read the strokes with\n `get_sheet_vectors` over a tight region and put each vertex on a stroke; do\n not trace a hatch edge or a raster guess.\n3. At every **door or cased opening** the ring follows the face to the jamb,\n turns into the opening, runs across it on the **wall centerline** (midway\n between that wall's two faces, the full wall on a composite wall), and\n returns along the far jamb. Both rooms sharing the door share that segment.\n A door leaf and its swing arc are never part of the boundary; a leaf drawn\n standing open looks like a wall line and is not one.\n4. **Windows** and other non-passable openings do not break the ring.\n5. Columns, chases, pilasters and wall stubs that project into the room are\n traced around; an enclosed cell drawn with wall-weight lines is not floor.\n6. A **finish split** inside one room is two rings sharing the drawn\n transition line exactly, no overlap and no gap. Where two rooms meet with no\n wall, split on the drawn transition line or the partition's centerline.\n7. Take the finish from the schedule row; a plan tag alone is a cross-check.\n8. After each ring, `view_sheet` a tight crop with `overlay: true` at a high\n `px` and look at every corner and notch before the next room; `edit_shape`\n fixes what the crop shows. A full-sheet render cannot audit a ring.\n9. A **curved wall is a circle**: the architect drew it with a center and a\n radius. Never chord it and never hand-place a run of points along it. Give\n the bow one point anywhere on the wall face between the arc's two ends and\n list that point's index in `arc_through` on `measure_polygon`,\n `measure_line` or `measure_surface`; the server lays the unique circle\n through the three and bakes it to vertices, exactly as the canvas's Curve\n mode does. `get_sheet_vectors` flags curve chords in its `meta` byte (bit 1)\n \u2014 a window full of them is a radius wall, so read the bow from a render.\n\nWhen something is genuinely ambiguous, follow the rule most literally, carry it,\nand say so in the shape's label or an annotation. Do not stop.\n5. `takeoff_summary` and `export_report` check quantities and material coverage.\n Shorten notes through `list_annotations` \u2192 `edit_annotation` where permitted.\n6. Export editable takeoff JSON and a marked-set PDF, reopen the JSON against\n the same source, and check the handoff. Leave agent work pending for the\n human's review; exported files and agent verdicts do not create approval.\n\nThe [MCP route map](takeoff://wiki/mcp) gives the next tool at each step. The\n[agent guide](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/AGENT_GUIDE.md) covers staging and refusal recovery.\n\n## Evidence that another person can review\n\nKeep source/revision identifiers, calibration choices, measured geometry,\nexpected-versus-observed checks and an overlay or marked-set view. Compare\nspatial agreement as well as quantities. Disclose reference-assisted work;\nit is not a blind accuracy benchmark. Public PRs include screenshots, video or\nreproducible stats, using publishable fixtures. Private drawings and pricing stay\noutside the public repository. See the [repository guide](takeoff://wiki/repo-guide).\n"
15182
15383
  },
15183
15384
  {
15184
15385
  "key": "mcp",
@@ -15576,7 +15777,7 @@ function nameTheStageInRefusals(server) {
15576
15777
  // package.json
15577
15778
  var package_default = {
15578
15779
  name: "opentakeoff-mcp",
15579
- version: "0.9.86",
15780
+ version: "0.9.87",
15580
15781
  mcpName: "io.github.Kentucky-ai/opentakeoff",
15581
15782
  type: "module",
15582
15783
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
@@ -15596,7 +15797,7 @@ var package_default = {
15596
15797
  "check:wiki": "node scripts/check-wiki.mjs",
15597
15798
  "check:versions": "node ../scripts/check-version-consistency.mjs",
15598
15799
  "check:tool-count": "node --import tsx scripts/check-tool-count.mjs",
15599
- test: "node --import tsx --test 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"
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"
15600
15801
  },
15601
15802
  dependencies: {
15602
15803
  "@modelcontextprotocol/sdk": "^1.12.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.9.86",
3
+ "version": "0.9.87",
4
4
  "mcpName": "io.github.Kentucky-ai/opentakeoff",
5
5
  "type": "module",
6
6
  "description": "OpenTakeoff MCP server — drive the takeoff engine from your MCP client over stdio.",
@@ -20,7 +20,7 @@
20
20
  "check:wiki": "node scripts/check-wiki.mjs",
21
21
  "check:versions": "node ../scripts/check-version-consistency.mjs",
22
22
  "check:tool-count": "node --import tsx scripts/check-tool-count.mjs",
23
- "test": "node --import tsx --test 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"
23
+ "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"
24
24
  },
25
25
  "dependencies": {
26
26
  "@modelcontextprotocol/sdk": "^1.12.0",