opentakeoff-mcp 0.1.0

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.
@@ -0,0 +1,1217 @@
1
+ // src/hush.ts
2
+ console.log = console.error.bind(console);
3
+ if (typeof Promise.withResolvers !== "function") {
4
+ Promise.withResolvers = function withResolvers() {
5
+ let resolve;
6
+ let reject;
7
+ const promise = new Promise((res, rej) => {
8
+ resolve = res;
9
+ reject = rej;
10
+ });
11
+ return { promise, resolve, reject };
12
+ };
13
+ }
14
+
15
+ // server.ts
16
+ import { pathToFileURL } from "node:url";
17
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
+
20
+ // src/session.ts
21
+ import path2 from "node:path";
22
+
23
+ // src/pdf.ts
24
+ import { createRequire } from "node:module";
25
+ import { readFile } from "node:fs/promises";
26
+ import path from "node:path";
27
+ import * as pdfjs from "pdfjs-dist";
28
+
29
+ // ../web/src/lib/sheets.ts
30
+ import * as pdfjsLib from "pdfjs-dist";
31
+ var RENDER_SCALE = 2;
32
+ var PX_PER_IN = 72 * RENDER_SCALE;
33
+ var arch = (inPerFt) => 1 / inPerFt / PX_PER_IN;
34
+ var eng = (ftPerIn) => ftPerIn / PX_PER_IN;
35
+ var metric = (r) => r / 12 / PX_PER_IN;
36
+ var STANDARD_SCALES = [
37
+ { label: `1/16" = 1'-0"`, upp: arch(1 / 16) },
38
+ { label: `3/32" = 1'-0"`, upp: arch(3 / 32) },
39
+ { label: `1/8" = 1'-0"`, upp: arch(1 / 8) },
40
+ { label: `3/16" = 1'-0"`, upp: arch(3 / 16) },
41
+ { label: `1/4" = 1'-0"`, upp: arch(1 / 4) },
42
+ { label: `3/8" = 1'-0"`, upp: arch(3 / 8) },
43
+ { label: `1/2" = 1'-0"`, upp: arch(1 / 2) },
44
+ { label: `3/4" = 1'-0"`, upp: arch(3 / 4) },
45
+ { label: `1" = 1'-0"`, upp: arch(1) },
46
+ { label: `1-1/2" = 1'-0"`, upp: arch(1.5) },
47
+ { label: `3" = 1'-0"`, upp: arch(3) },
48
+ { label: `1" = 10'`, upp: eng(10) },
49
+ { label: `1" = 20'`, upp: eng(20) },
50
+ { label: `1" = 30'`, upp: eng(30) },
51
+ { label: `1" = 40'`, upp: eng(40) },
52
+ { label: `1" = 50'`, upp: eng(50) },
53
+ { label: `1" = 60'`, upp: eng(60) },
54
+ { label: "1:20", upp: metric(20) },
55
+ { label: "1:25", upp: metric(25) },
56
+ { label: "1:50", upp: metric(50) },
57
+ { label: "1:75", upp: metric(75) },
58
+ { label: "1:100", upp: metric(100) },
59
+ { label: "1:125", upp: metric(125) },
60
+ { label: "1:200", upp: metric(200) },
61
+ { label: "1:250", upp: metric(250) },
62
+ { label: "1:500", upp: metric(500) }
63
+ ];
64
+ var SHEET_NO_RE = /^[A-Z]{1,3}[-. ]?\d{1,3}(\.\d{1,2})?[A-Z]?$/;
65
+ function extractSheetNumber(textContent, viewport) {
66
+ const W = viewport.width, H = viewport.height;
67
+ let best = null, bestH = 0;
68
+ for (const it of textContent.items || []) {
69
+ const raw = (it.str || "").trim().toUpperCase().replace(/\s+/g, "");
70
+ if (raw.length < 2 || raw.length > 8 || !SHEET_NO_RE.test(raw)) continue;
71
+ const t = pdfjsLib.Util.transform(viewport.transform, it.transform);
72
+ const x = t[4], y = t[5], h = Math.hypot(t[2], t[3]) || it.height || 0;
73
+ if (x < W * 0.6 || y < H * 0.55) continue;
74
+ const score = h + x / W * 4 + y / H * 4;
75
+ if (score > bestH) {
76
+ bestH = score;
77
+ best = raw;
78
+ }
79
+ }
80
+ return best;
81
+ }
82
+ var _canonScaleText = (s) => s.replace(/[“”″]/g, '"').replace(/[‘’′]/g, "'").replace(/\s+/g, "").toUpperCase();
83
+ var SCALE_KEYS = STANDARD_SCALES.map((s) => {
84
+ const full = _canonScaleText(s.label);
85
+ const keys = /* @__PURE__ */ new Set([full]);
86
+ if (full.endsWith(`=1'-0"`)) keys.add(full.slice(0, -3));
87
+ else if (full.endsWith("'")) keys.add(`${full}-0"`);
88
+ return { ...s, keys: [...keys] };
89
+ });
90
+ function _findScales(canon) {
91
+ const out = [];
92
+ for (const sc of SCALE_KEYS) {
93
+ let hit = false;
94
+ for (const k of sc.keys) {
95
+ let i = canon.indexOf(k);
96
+ while (i !== -1 && !hit) {
97
+ const prev = canon[i - 1];
98
+ const next = canon[i + k.length];
99
+ if (!(prev >= "0" && prev <= "9") && prev !== "/" && prev !== "-" && !(next >= "0" && next <= "9")) hit = true;
100
+ else i = canon.indexOf(k, i + 1);
101
+ }
102
+ if (hit) break;
103
+ }
104
+ if (hit) out.push(sc);
105
+ }
106
+ return out;
107
+ }
108
+ function detectScale(textContent, viewport) {
109
+ const W = viewport.width, H = viewport.height;
110
+ let all = "", tb = "";
111
+ for (const it of textContent.items || []) {
112
+ const str = it.str || "";
113
+ if (!str.trim()) continue;
114
+ all += str + " ";
115
+ const t = pdfjsLib.Util.transform(viewport.transform, it.transform);
116
+ if (t[4] > W * 0.55 && t[5] > H * 0.5) tb += str + " ";
117
+ }
118
+ const tbHits = _findScales(_canonScaleText(tb));
119
+ const allHits = _findScales(_canonScaleText(all));
120
+ if (tbHits.length) return { upp: tbHits[0].upp, label: tbHits[0].label, multi: allHits.length > 1 };
121
+ if (allHits.length === 1) return { upp: allHits[0].upp, label: allHits[0].label, multi: false };
122
+ return null;
123
+ }
124
+
125
+ // src/pdf.ts
126
+ var requireHere = createRequire(import.meta.url);
127
+ var PDFJS_ROOT = path.dirname(requireHere.resolve("pdfjs-dist/package.json"));
128
+ var OPS2 = pdfjs.OPS;
129
+ async function openPdf(filePath) {
130
+ const bytes = await readFile(filePath);
131
+ const doc = await pdfjs.getDocument({
132
+ // getDocument({ data }) may DETACH the buffer it is handed — always pass a
133
+ // fresh copy (new Uint8Array(view) copies), never the read buffer itself.
134
+ data: new Uint8Array(bytes),
135
+ verbosity: 0,
136
+ standardFontDataUrl: path.join(PDFJS_ROOT, "standard_fonts") + path.sep,
137
+ cMapUrl: path.join(PDFJS_ROOT, "cmaps") + path.sep,
138
+ cMapPacked: true,
139
+ isEvalSupported: false
140
+ }).promise;
141
+ return {
142
+ numPages: doc.numPages,
143
+ async page(n) {
144
+ const page = await doc.getPage(n);
145
+ const vp = page.getViewport({ scale: RENDER_SCALE });
146
+ const vp1 = page.getViewport({ scale: 1 });
147
+ const textContent = await page.getTextContent();
148
+ return {
149
+ pageNum: n,
150
+ widthPt: vp1.width,
151
+ heightPt: vp1.height,
152
+ viewport: { width: vp.width, height: vp.height, transform: vp.transform },
153
+ textContent,
154
+ operatorList: async () => await page.getOperatorList()
155
+ };
156
+ },
157
+ destroy: () => doc.destroy().then(() => void 0)
158
+ };
159
+ }
160
+ function positionedText(ph) {
161
+ const out = [];
162
+ for (const it of ph.textContent.items || []) {
163
+ const str = it.str || "";
164
+ if (!str.trim()) continue;
165
+ const t = pdfjs.Util.transform(ph.viewport.transform, it.transform);
166
+ out.push({ str, x: +t[4].toFixed(1), y: +t[5].toFixed(1) });
167
+ }
168
+ return out;
169
+ }
170
+
171
+ // src/format.ts
172
+ var UserError = class extends Error {
173
+ };
174
+ var ok = (payload) => ({
175
+ content: [{ type: "text", text: JSON.stringify(payload) }]
176
+ });
177
+ var fail = (err) => ({
178
+ isError: true,
179
+ content: [{ type: "text", text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) }) }]
180
+ });
181
+ var round2 = (n) => +n.toFixed(2);
182
+ var round1 = (n) => +n.toFixed(1);
183
+
184
+ // ../web/src/lib/oneclick.ts
185
+ var MASK_MAX_DIM = 3e3;
186
+ var LEAK_FRACTION = 0.3;
187
+ var TINY_PX = 30;
188
+ var MIN_THICK = 4;
189
+ var CURVE_STEPS = 8;
190
+ var SEG_CURVE = 1;
191
+ var SEG_CLIP = 2;
192
+ var SEG_FILLONLY = 4;
193
+ var HATCH_ANGLE_TOL = 2;
194
+ var HATCH_MIN_RUN = 10;
195
+ var HATCH_MAX_PITCH = 24;
196
+ var HATCH_PITCH_TOL = 0.35;
197
+ var HATCH_MIN_REGULAR = 0.7;
198
+ var HATCH_OVERLAP_FRAC = 0.5;
199
+ var ROW_EPS = 1.5;
200
+ var WIDE_PROTECT_RATIO = 2;
201
+ var SPAN_PROTECT_RATIO = 3;
202
+ var HATCH_BOUND_FRAC = 0.7;
203
+ var HATCH_ESCALATE_FRAC = 0.35;
204
+ var HATCH_GROWTH_MAX = 2.5;
205
+ var SENS_STRICT = 0;
206
+ var SENS_BALANCED = 0.5;
207
+ var SENS_AGGRESSIVE = 1;
208
+ var SENS_ANCHORS = [
209
+ [SENS_STRICT, HATCH_BOUND_FRAC, 1.5],
210
+ // moderate band empties (escalateFrac == HATCH_BOUND_FRAC) ⇒ pre-#32
211
+ [SENS_BALANCED, HATCH_ESCALATE_FRAC, HATCH_GROWTH_MAX],
212
+ // calibrated on the sample plan (issue #32)
213
+ [SENS_AGGRESSIVE, 0.2, 4]
214
+ // cross more hatch, tolerate more growth
215
+ ];
216
+ function escalationParams(sensitivity) {
217
+ const s = Math.max(0, Math.min(1, Number.isFinite(sensitivity) ? sensitivity : SENS_BALANCED));
218
+ let a = SENS_ANCHORS[0], b = SENS_ANCHORS[SENS_ANCHORS.length - 1];
219
+ for (let i = 1; i < SENS_ANCHORS.length; i++) {
220
+ if (s <= SENS_ANCHORS[i][0]) {
221
+ a = SENS_ANCHORS[i - 1];
222
+ b = SENS_ANCHORS[i];
223
+ break;
224
+ }
225
+ }
226
+ const t = b[0] === a[0] ? 0 : (s - a[0]) / (b[0] - a[0]);
227
+ return { escalateFrac: a[1] + (b[1] - a[1]) * t, growthMax: a[2] + (b[2] - a[2]) * t };
228
+ }
229
+ function extractVectorGeometry(opList, transform, OPS3) {
230
+ const points = [];
231
+ const segs = [];
232
+ const metaArr = [];
233
+ let imageArea = 0;
234
+ let m = transform.slice();
235
+ let lw = 1;
236
+ const stack = [];
237
+ const mul = (a, b) => [a[0] * b[0] + a[2] * b[1], a[1] * b[0] + a[3] * b[1], a[0] * b[2] + a[2] * b[3], a[1] * b[2] + a[3] * b[3], a[0] * b[4] + a[2] * b[5] + a[4], a[1] * b[4] + a[3] * b[5] + a[5]];
238
+ const tx = (x, y) => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];
239
+ const fns = opList.fnArray, A = opList.argsArray;
240
+ const paintFlags = (i) => {
241
+ for (let j = i + 1; j < fns.length && j <= i + 3; j++) {
242
+ const f = fns[j];
243
+ if (f === OPS3.clip || f === OPS3.eoClip) continue;
244
+ if (f === OPS3.endPath) return SEG_CLIP;
245
+ if (f === OPS3.fill || f === OPS3.eoFill) return SEG_FILLONLY;
246
+ break;
247
+ }
248
+ return 0;
249
+ };
250
+ for (let i = 0; i < fns.length; i++) {
251
+ const fn = fns[i], args = A[i];
252
+ if (fn === OPS3.save) stack.push([m.slice(), lw]);
253
+ else if (fn === OPS3.restore) {
254
+ const p = stack.pop();
255
+ if (p) {
256
+ m = p[0];
257
+ lw = p[1];
258
+ }
259
+ } else if (fn === OPS3.transform) m = mul(m, args);
260
+ else if (fn === OPS3.setLineWidth) lw = args[0];
261
+ else if (fn === OPS3.setGState) {
262
+ for (const pr of args[0] || []) if (pr && pr[0] === "LW") lw = pr[1];
263
+ } else if (fn === OPS3.paintFormXObjectBegin) {
264
+ stack.push([m.slice(), lw]);
265
+ if (args && args[0]) m = mul(m, args[0]);
266
+ } else if (fn === OPS3.paintFormXObjectEnd) {
267
+ const p = stack.pop();
268
+ if (p) {
269
+ m = p[0];
270
+ lw = p[1];
271
+ }
272
+ } else if (fn === OPS3.paintImageXObject || fn === OPS3.paintInlineImageXObject || fn === OPS3.paintImageMaskXObject) {
273
+ imageArea += Math.abs(m[0] * m[3] - m[1] * m[2]);
274
+ } else if (fn === OPS3.paintImageXObjectRepeat) {
275
+ const [, scaleX, scaleY, positions] = args;
276
+ const count = positions ? positions.length >> 1 : 0;
277
+ imageArea += Math.abs(m[0] * m[3] - m[1] * m[2]) * Math.abs(scaleX * scaleY) * count;
278
+ } else if (fn === OPS3.paintImageMaskXObjectRepeat) {
279
+ const [, ra, rb, rc, rd, positions] = args;
280
+ const count = positions ? positions.length >> 1 : 0;
281
+ imageArea += Math.abs(m[0] * m[3] - m[1] * m[2]) * Math.abs(ra * rd - rb * rc) * count;
282
+ } else if (fn === OPS3.paintImageMaskXObjectGroup) {
283
+ const ctmDet = Math.abs(m[0] * m[3] - m[1] * m[2]);
284
+ for (const im of args[0] || []) {
285
+ const t = im && im.transform;
286
+ if (t) imageArea += ctmDet * Math.abs(t[0] * t[3] - t[1] * t[2]);
287
+ }
288
+ } else if (fn === OPS3.paintInlineImageXObjectGroup) {
289
+ const ctmDet = Math.abs(m[0] * m[3] - m[1] * m[2]);
290
+ for (const mp of args[1] || []) {
291
+ const t = mp && mp.transform;
292
+ if (t) imageArea += ctmDet * Math.abs(t[0] * t[3] - t[1] * t[2]);
293
+ }
294
+ } else if (fn === OPS3.constructPath) {
295
+ const devW = Math.min(15, Math.max(0, Math.ceil((lw || 0) * Math.sqrt(Math.abs(m[0] * m[3] - m[1] * m[2])))));
296
+ const flags = paintFlags(i) | devW << 4;
297
+ const ops = args[0], co = args[1];
298
+ let c = 0, cur = null, start = null;
299
+ const visit = (p) => {
300
+ points.push(p);
301
+ };
302
+ const lineTo = (p) => {
303
+ if (cur) {
304
+ segs.push(cur[0], cur[1], p[0], p[1]);
305
+ metaArr.push(flags);
306
+ }
307
+ cur = p;
308
+ visit(p);
309
+ };
310
+ for (const op of ops) {
311
+ if (op === OPS3.moveTo) {
312
+ cur = tx(co[c], co[c + 1]);
313
+ start = cur;
314
+ visit(cur);
315
+ c += 2;
316
+ } else if (op === OPS3.lineTo) {
317
+ lineTo(tx(co[c], co[c + 1]));
318
+ c += 2;
319
+ } else if (op === OPS3.curveTo || op === OPS3.curveTo2 || op === OPS3.curveTo3) {
320
+ let p1, p2, p3;
321
+ if (op === OPS3.curveTo) {
322
+ p1 = tx(co[c], co[c + 1]);
323
+ p2 = tx(co[c + 2], co[c + 3]);
324
+ p3 = tx(co[c + 4], co[c + 5]);
325
+ c += 6;
326
+ } else if (op === OPS3.curveTo2) {
327
+ p1 = cur || tx(co[c], co[c + 1]);
328
+ p2 = tx(co[c], co[c + 1]);
329
+ p3 = tx(co[c + 2], co[c + 3]);
330
+ c += 4;
331
+ } else {
332
+ p1 = tx(co[c], co[c + 1]);
333
+ p2 = p3 = tx(co[c + 2], co[c + 3]);
334
+ c += 4;
335
+ }
336
+ const p0 = cur || p1;
337
+ for (let k = 1; k <= CURVE_STEPS; k++) {
338
+ const t = k / CURVE_STEPS, u = 1 - t;
339
+ const q = [
340
+ u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0],
341
+ u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1]
342
+ ];
343
+ if (cur) {
344
+ segs.push(cur[0], cur[1], q[0], q[1]);
345
+ metaArr.push(flags | SEG_CURVE);
346
+ }
347
+ cur = q;
348
+ }
349
+ visit(p3);
350
+ } else if (op === OPS3.closePath) {
351
+ if (cur && start) {
352
+ segs.push(cur[0], cur[1], start[0], start[1]);
353
+ metaArr.push(flags);
354
+ cur = start;
355
+ }
356
+ } else if (op === OPS3.rectangle) {
357
+ const x = co[c], y = co[c + 1], w = co[c + 2], h = co[c + 3];
358
+ c += 4;
359
+ const q = [tx(x, y), tx(x + w, y), tx(x + w, y + h), tx(x, y + h)];
360
+ for (let k = 0; k < 4; k++) {
361
+ const a = q[k], b = q[(k + 1) % 4];
362
+ segs.push(a[0], a[1], b[0], b[1]);
363
+ metaArr.push(flags);
364
+ visit(a);
365
+ }
366
+ cur = q[0];
367
+ start = q[0];
368
+ }
369
+ }
370
+ }
371
+ }
372
+ return { points, segs, meta: Uint8Array.from(metaArr), imageArea };
373
+ }
374
+ function classifyHatchSegs(segs, meta, ws) {
375
+ const n = segs.length >> 2;
376
+ const soft = new Uint8Array(n);
377
+ if (!meta || !n) return soft;
378
+ const cand = [];
379
+ for (let i = 0; i < n; i++) {
380
+ const mt = meta[i];
381
+ if (mt & SEG_CURVE) continue;
382
+ if (mt & SEG_CLIP) {
383
+ soft[i] = 1;
384
+ continue;
385
+ }
386
+ if (mt & SEG_FILLONLY) continue;
387
+ const x1 = segs[i * 4] * ws, y1 = segs[i * 4 + 1] * ws, x2 = segs[i * 4 + 2] * ws, y2 = segs[i * 4 + 3] * ws;
388
+ const dx = x2 - x1, dy = y2 - y1;
389
+ const len = Math.hypot(dx, dy);
390
+ if (len < 0.75) continue;
391
+ let ang = Math.atan2(dy, dx) * 180 / Math.PI;
392
+ if (ang < 0) ang += 180;
393
+ if (ang >= 180) ang -= 180;
394
+ cand.push({ i, ang, x1, y1, x2, y2, w: meta[i] >> 4 });
395
+ }
396
+ if (cand.length < HATCH_MIN_RUN) return soft;
397
+ cand.sort((a, b) => a.ang - b.ang);
398
+ const clusters = [];
399
+ let cl = [cand[0]];
400
+ for (let k = 1; k < cand.length; k++) {
401
+ if (cand[k].ang - cand[k - 1].ang <= HATCH_ANGLE_TOL) cl.push(cand[k]);
402
+ else {
403
+ clusters.push(cl);
404
+ cl = [cand[k]];
405
+ }
406
+ }
407
+ clusters.push(cl);
408
+ if (clusters.length > 1) {
409
+ const first = clusters[0], last = clusters[clusters.length - 1];
410
+ if (first[0].ang < HATCH_ANGLE_TOL && last[last.length - 1].ang > 180 - HATCH_ANGLE_TOL) {
411
+ for (const s of last) s.ang -= 180;
412
+ clusters[0] = last.concat(first);
413
+ clusters.pop();
414
+ }
415
+ }
416
+ const median = (arr) => {
417
+ const a = arr.slice().sort((x, y) => x - y);
418
+ return a[a.length >> 1];
419
+ };
420
+ for (const members of clusters) {
421
+ if (members.length < HATCH_MIN_RUN) continue;
422
+ let sum = 0;
423
+ for (const s of members) sum += s.ang;
424
+ const th = sum / members.length * Math.PI / 180;
425
+ const dxu = Math.cos(th), dyu = Math.sin(th);
426
+ const nxu = -dyu, nyu = dxu;
427
+ const rowsIn = members.map((s) => ({
428
+ s,
429
+ d: (s.x1 + s.x2) / 2 * nxu + (s.y1 + s.y2) / 2 * nyu,
430
+ t0: Math.min(s.x1 * dxu + s.y1 * dyu, s.x2 * dxu + s.y2 * dyu),
431
+ t1: Math.max(s.x1 * dxu + s.y1 * dyu, s.x2 * dxu + s.y2 * dyu)
432
+ })).sort((a, b) => a.d - b.d);
433
+ const rows = [];
434
+ let row = { d: rowsIn[0].d, t0: rowsIn[0].t0, t1: rowsIn[0].t1, segs: [rowsIn[0].s] };
435
+ for (let k = 1; k < rowsIn.length; k++) {
436
+ const r = rowsIn[k];
437
+ if (r.d - row.d <= ROW_EPS) {
438
+ row.t0 = Math.min(row.t0, r.t0);
439
+ row.t1 = Math.max(row.t1, r.t1);
440
+ row.segs.push(r.s);
441
+ } else {
442
+ rows.push(row);
443
+ row = { d: r.d, t0: r.t0, t1: r.t1, segs: [r.s] };
444
+ }
445
+ }
446
+ rows.push(row);
447
+ let runStart = 0;
448
+ const flushRun = (a, b) => {
449
+ const count = b - a + 1;
450
+ if (count < HATCH_MIN_RUN) return;
451
+ const gaps = [];
452
+ for (let k = a + 1; k <= b; k++) gaps.push(rows[k].d - rows[k - 1].d);
453
+ const med = median(gaps);
454
+ if (!med) return;
455
+ let reg = 0;
456
+ for (const g of gaps) if (Math.abs(g - med) <= med * HATCH_PITCH_TOL) reg++;
457
+ if (reg / gaps.length < HATCH_MIN_REGULAR) return;
458
+ const widths = [];
459
+ for (let k = a; k <= b; k++) for (const s of rows[k].segs) widths.push(s.w);
460
+ const modalW = Math.max(1, median(widths));
461
+ const spans = [];
462
+ for (let k = a; k <= b; k++) spans.push(rows[k].t1 - rows[k].t0);
463
+ const medSpan = Math.max(1, median(spans));
464
+ for (let k = a + 1; k < b; k++) {
465
+ if (rows[k].t1 - rows[k].t0 > SPAN_PROTECT_RATIO * medSpan) continue;
466
+ for (const s of rows[k].segs)
467
+ if (s.w < WIDE_PROTECT_RATIO * modalW) soft[s.i] = 1;
468
+ }
469
+ };
470
+ for (let k = 1; k < rows.length; k++) {
471
+ const gap = rows[k].d - rows[k - 1].d;
472
+ const ov = Math.min(rows[k].t1, rows[k - 1].t1) - Math.max(rows[k].t0, rows[k - 1].t0);
473
+ const need = HATCH_OVERLAP_FRAC * Math.min(rows[k].t1 - rows[k].t0, rows[k - 1].t1 - rows[k - 1].t0);
474
+ if (gap > HATCH_MAX_PITCH || ov < need) {
475
+ flushRun(runStart, k - 1);
476
+ runStart = k;
477
+ }
478
+ }
479
+ flushRun(runStart, rows.length - 1);
480
+ }
481
+ return soft;
482
+ }
483
+ function buildMask(segs, imgW, imgH, maxDim = MASK_MAX_DIM, meta = null) {
484
+ const ws = Math.min(1, maxDim / Math.max(imgW, imgH, 1));
485
+ const mw = Math.max(2, Math.ceil(imgW * ws)), mh = Math.max(2, Math.ceil(imgH * ws));
486
+ const mask = new Uint8Array(mw * mh);
487
+ const soft = meta ? classifyHatchSegs(segs, meta, ws) : null;
488
+ let softCount = 0;
489
+ for (let i = 0, si = 0; i + 3 < segs.length; i += 4, si++) {
490
+ const v = soft && soft[si] ? 2 : 1;
491
+ if (v === 2) softCount++;
492
+ let x0 = Math.round(segs[i] * ws), y0 = Math.round(segs[i + 1] * ws);
493
+ const x1 = Math.round(segs[i + 2] * ws), y1 = Math.round(segs[i + 3] * ws);
494
+ const dx = Math.abs(x1 - x0), dy = -Math.abs(y1 - y0);
495
+ const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;
496
+ let e = dx + dy;
497
+ for (; ; ) {
498
+ if (x0 >= 0 && y0 >= 0 && x0 < mw && y0 < mh) mask[y0 * mw + x0] |= v;
499
+ if (x0 === x1 && y0 === y1) break;
500
+ const e2 = 2 * e;
501
+ if (e2 >= dy) {
502
+ e += dy;
503
+ x0 += sx;
504
+ }
505
+ if (e2 <= dx) {
506
+ e += dx;
507
+ y0 += sy;
508
+ }
509
+ }
510
+ }
511
+ return { mask, mw, mh, ws, softCount };
512
+ }
513
+ function floodPass(maskObj, ix, iy, barrier) {
514
+ const { mask, mw, mh, ws } = maskObj;
515
+ let sx = Math.round(ix * ws), sy = Math.round(iy * ws);
516
+ if (sx < 0 || sy < 0 || sx >= mw || sy >= mh) return { status: "boundary" };
517
+ if (mask[sy * mw + sx] & barrier) {
518
+ let found = null;
519
+ for (let r = 1; r <= 3 && !found; r++) {
520
+ for (let dy = -r; dy <= r && !found; dy++) for (let dx = -r; dx <= r; dx++) {
521
+ const nx = sx + dx, ny = sy + dy;
522
+ if (nx >= 0 && ny >= 0 && nx < mw && ny < mh && !(mask[ny * mw + nx] & barrier)) {
523
+ found = [nx, ny];
524
+ break;
525
+ }
526
+ }
527
+ }
528
+ if (!found) return { status: "boundary" };
529
+ sx = found[0];
530
+ sy = found[1];
531
+ }
532
+ const region = new Uint8Array(mw * mh);
533
+ const cap = Math.floor(mw * mh * LEAK_FRACTION);
534
+ let count = 0, leaked = false, hardHits = 0, softHits = 0;
535
+ let bx0 = sx, bx1 = sx, by0 = sy, by1 = sy;
536
+ const stack = [[sx, sy]];
537
+ while (stack.length) {
538
+ const popped = stack.pop();
539
+ const px = popped[0], py = popped[1];
540
+ let x0 = px;
541
+ while (x0 > 0 && !(mask[py * mw + x0 - 1] & barrier) && !region[py * mw + x0 - 1]) x0--;
542
+ if (x0 > 0 && mask[py * mw + x0 - 1] & barrier) {
543
+ if (mask[py * mw + x0 - 1] & 1) hardHits++;
544
+ else softHits++;
545
+ }
546
+ let x1 = px;
547
+ while (x1 < mw - 1 && !(mask[py * mw + x1 + 1] & barrier) && !region[py * mw + x1 + 1]) x1++;
548
+ if (x1 < mw - 1 && mask[py * mw + x1 + 1] & barrier) {
549
+ if (mask[py * mw + x1 + 1] & 1) hardHits++;
550
+ else softHits++;
551
+ }
552
+ if (x0 === 0 || x1 === mw - 1 || py === 0 || py === mh - 1) leaked = true;
553
+ if (x0 < bx0) bx0 = x0;
554
+ if (x1 > bx1) bx1 = x1;
555
+ if (py < by0) by0 = py;
556
+ if (py > by1) by1 = py;
557
+ let upOpen = false, downOpen = false;
558
+ for (let x = x0; x <= x1; x++) {
559
+ const idx = py * mw + x;
560
+ if (region[idx]) {
561
+ upOpen = downOpen = false;
562
+ continue;
563
+ }
564
+ region[idx] = 1;
565
+ count++;
566
+ if (py > 0) {
567
+ const u = idx - mw;
568
+ if (!(mask[u] & barrier) && !region[u]) {
569
+ if (!upOpen) {
570
+ stack.push([x, py - 1]);
571
+ upOpen = true;
572
+ }
573
+ } else {
574
+ if (mask[u] & barrier) {
575
+ if (mask[u] & 1) hardHits++;
576
+ else softHits++;
577
+ }
578
+ upOpen = false;
579
+ }
580
+ }
581
+ if (py < mh - 1) {
582
+ const d = idx + mw;
583
+ if (!(mask[d] & barrier) && !region[d]) {
584
+ if (!downOpen) {
585
+ stack.push([x, py + 1]);
586
+ downOpen = true;
587
+ }
588
+ } else {
589
+ if (mask[d] & barrier) {
590
+ if (mask[d] & 1) hardHits++;
591
+ else softHits++;
592
+ }
593
+ downOpen = false;
594
+ }
595
+ }
596
+ }
597
+ if (count > cap) return { status: "leak" };
598
+ }
599
+ if (leaked) return { status: "leak" };
600
+ if (count < TINY_PX || bx1 - bx0 + 1 < MIN_THICK || by1 - by0 + 1 < MIN_THICK) return { status: "tiny", count };
601
+ return { status: "ok", region, count, mw, mh, ws, hardHits, softHits };
602
+ }
603
+ function floodRegion(maskObj, ix, iy, sensitivity = SENS_BALANCED) {
604
+ const r1 = floodPass(maskObj, ix, iy, 3);
605
+ if (!maskObj.softCount) return r1;
606
+ if (r1.status === "leak") return r1;
607
+ const { escalateFrac, growthMax } = escalationParams(sensitivity);
608
+ let growthCap = Infinity;
609
+ if (r1.status === "ok") {
610
+ const blocks = (r1.hardHits || 0) + (r1.softHits || 0);
611
+ const softFrac = blocks ? (r1.softHits || 0) / blocks : 0;
612
+ if (softFrac < escalateFrac) return r1;
613
+ if (softFrac < HATCH_BOUND_FRAC) growthCap = growthMax;
614
+ }
615
+ const r2 = floodPass(maskObj, ix, iy, 1);
616
+ if (r2.status === "ok" && (r1.status !== "ok" || r2.count <= r1.count * growthCap)) {
617
+ r2.hatchFiltered = true;
618
+ return r2;
619
+ }
620
+ return r1;
621
+ }
622
+ function traceRegion(reg, epsMaskPx = 1.5) {
623
+ const { region, mw, mh, ws } = reg;
624
+ let s = -1;
625
+ for (let i = 0; i < region.length; i++) if (region[i]) {
626
+ s = i;
627
+ break;
628
+ }
629
+ if (s < 0) return [];
630
+ const sx = s % mw, sy = s / mw | 0;
631
+ const at = (x, y) => x >= 0 && y >= 0 && x < mw && y < mh && !!region[y * mw + x];
632
+ const N = [[-1, 0], [-1, -1], [0, -1], [1, -1], [1, 0], [1, 1], [0, 1], [-1, 1]];
633
+ const pts = [];
634
+ let cx = sx, cy = sy, dir = 6;
635
+ const maxSteps = mw * mh * 4;
636
+ for (let step = 0; step < maxSteps; step++) {
637
+ pts.push([cx, cy]);
638
+ let found = false;
639
+ for (let k = 0; k < 8; k++) {
640
+ const d = (dir + 6 + k) % 8;
641
+ const nx = cx + N[d][0], ny = cy + N[d][1];
642
+ if (at(nx, ny)) {
643
+ cx = nx;
644
+ cy = ny;
645
+ dir = d;
646
+ found = true;
647
+ break;
648
+ }
649
+ }
650
+ if (!found) break;
651
+ if (cx === sx && cy === sy && pts.length > 2) break;
652
+ }
653
+ const ring = rdpClosed(pts, epsMaskPx);
654
+ return ring.map(([x, y]) => [x / ws, y / ws]);
655
+ }
656
+ function perpDist(p, a, b) {
657
+ const dx = b[0] - a[0], dy = b[1] - a[1];
658
+ const L = Math.hypot(dx, dy);
659
+ if (!L) return Math.hypot(p[0] - a[0], p[1] - a[1]);
660
+ return Math.abs(dy * p[0] - dx * p[1] + b[0] * a[1] - b[1] * a[0]) / L;
661
+ }
662
+ function rdpOpen(pts, eps) {
663
+ if (pts.length < 3) return pts.slice();
664
+ let imax = 0, dmax = -1;
665
+ const a = pts[0], b = pts[pts.length - 1];
666
+ for (let i = 1; i < pts.length - 1; i++) {
667
+ const d = perpDist(pts[i], a, b);
668
+ if (d > dmax) {
669
+ dmax = d;
670
+ imax = i;
671
+ }
672
+ }
673
+ if (dmax <= eps) return [a, b];
674
+ const left = rdpOpen(pts.slice(0, imax + 1), eps);
675
+ const right = rdpOpen(pts.slice(imax), eps);
676
+ return left.slice(0, -1).concat(right);
677
+ }
678
+ function rdpClosed(pts, eps) {
679
+ if (pts.length < 4) return pts.slice();
680
+ let split = 0, dmax = -1;
681
+ for (let i = 1; i < pts.length; i++) {
682
+ const d = (pts[i][0] - pts[0][0]) ** 2 + (pts[i][1] - pts[0][1]) ** 2;
683
+ if (d > dmax) {
684
+ dmax = d;
685
+ split = i;
686
+ }
687
+ }
688
+ const h1 = rdpOpen(pts.slice(0, split + 1), eps);
689
+ const h2 = rdpOpen(pts.slice(split).concat([pts[0]]), eps);
690
+ const ring = h1.slice(0, -1).concat(h2.slice(0, -1));
691
+ return ring.length >= 3 ? ring : pts.slice();
692
+ }
693
+ function snapVertices(poly, nearest, tolPx = 6, minGapPx = 2) {
694
+ const snapped = poly.map(([x, y]) => {
695
+ const hit = nearest(x, y, tolPx);
696
+ return hit ? [hit[0], hit[1]] : [x, y];
697
+ });
698
+ const out = [];
699
+ for (const p of snapped) {
700
+ const prev = out[out.length - 1];
701
+ if (!prev || Math.hypot(p[0] - prev[0], p[1] - prev[1]) > minGapPx) out.push(p);
702
+ }
703
+ while (out.length > 1 && Math.hypot(out[0][0] - out[out.length - 1][0], out[0][1] - out[out.length - 1][1]) <= minGapPx) out.pop();
704
+ return out.length >= 3 ? out : poly;
705
+ }
706
+ function ringArea(pts) {
707
+ let a = 0;
708
+ for (let i = 0; i < pts.length; i++) {
709
+ const [x1, y1] = pts[i], [x2, y2] = pts[(i + 1) % pts.length];
710
+ a += x1 * y2 - x2 * y1;
711
+ }
712
+ return Math.abs(a) / 2;
713
+ }
714
+
715
+ // ../web/src/lib/geometry.js
716
+ function buildSnapGrid(points, cell) {
717
+ const map = /* @__PURE__ */ new Map();
718
+ for (const p of points) {
719
+ const k = `${Math.floor(p[0] / cell)},${Math.floor(p[1] / cell)}`;
720
+ let a = map.get(k);
721
+ if (!a) {
722
+ a = [];
723
+ map.set(k, a);
724
+ }
725
+ if (a.length < 40) a.push(p);
726
+ }
727
+ return { cell, map };
728
+ }
729
+ function nearestSnap(grid, x, y, maxDist) {
730
+ if (!grid) return null;
731
+ const { cell, map } = grid, cx = Math.floor(x / cell), cy = Math.floor(y / cell);
732
+ let best = null, bestD = maxDist * maxDist;
733
+ for (let gx = cx - 1; gx <= cx + 1; gx++) for (let gy = cy - 1; gy <= cy + 1; gy++) {
734
+ const a = map.get(`${gx},${gy}`);
735
+ if (!a) continue;
736
+ for (const p of a) {
737
+ const dx = p[0] - x, dy = p[1] - y, d = dx * dx + dy * dy;
738
+ if (d < bestD) {
739
+ bestD = d;
740
+ best = p;
741
+ }
742
+ }
743
+ }
744
+ return best;
745
+ }
746
+ function closedMetrics(pts) {
747
+ const n = pts.length;
748
+ if (n < 3) {
749
+ let perim2 = 0;
750
+ for (let i = 1; i < n; i++) perim2 += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
751
+ return { area: 0, perim: perim2 };
752
+ }
753
+ let area = 0, perim = 0;
754
+ for (let i = 0; i < n; i++) {
755
+ const [x1, y1] = pts[i], [x2, y2] = pts[(i + 1) % n];
756
+ area += x1 * y2 - x2 * y1;
757
+ perim += Math.hypot(x2 - x1, y2 - y1);
758
+ }
759
+ return { area: Math.abs(area) / 2, perim };
760
+ }
761
+ function openLen(pts) {
762
+ let L = 0;
763
+ for (let i = 1; i < pts.length; i++) L += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
764
+ return L;
765
+ }
766
+
767
+ // ../web/src/lib/num.js
768
+ var round22 = (n) => Math.round((n + Number.EPSILON) * 100) / 100;
769
+
770
+ // ../web/src/lib/totals.js
771
+ function accumulateRole(acc, s) {
772
+ const cp = s.computed || {};
773
+ switch (s.measure_role) {
774
+ case "deduct":
775
+ acc.floor -= cp.area_sf || 0;
776
+ break;
777
+ case "floor_area":
778
+ acc.floor += cp.area_sf || 0;
779
+ break;
780
+ case "surface_area":
781
+ acc.wall += cp.area_sf || 0;
782
+ break;
783
+ case "linear":
784
+ acc.lf += cp.perimeter_lf || 0;
785
+ acc.border += cp.area_sf || 0;
786
+ break;
787
+ case "count":
788
+ acc.ea += cp.count || 1;
789
+ break;
790
+ default:
791
+ break;
792
+ }
793
+ }
794
+ function conditionTotals(conditions, shapes) {
795
+ return conditions.map((c) => {
796
+ const mult = c.multiplier || 1;
797
+ const waste = Math.max(0, Number(c.waste_pct) || 0);
798
+ const w = 1 + waste / 100;
799
+ const cs = shapes.filter((s) => s.condition_id === c.id);
800
+ const acc = { floor: 0, wall: 0, border: 0, lf: 0, ea: 0 };
801
+ for (const s of cs) accumulateRole(acc, s);
802
+ let { floor, wall, border, lf, ea } = acc;
803
+ floor *= mult;
804
+ wall *= mult;
805
+ border *= mult;
806
+ lf *= mult;
807
+ ea *= mult;
808
+ const total = floor + wall + border;
809
+ const materials = (c.materials || []).filter((m) => m && m.name).map((m) => {
810
+ const per = Math.max(0, Number(m.per) || 0);
811
+ const basisVal = m.basis === "linear" ? lf : m.basis === "count" ? ea : total;
812
+ let qty = per > 0 ? basisVal / per : 0;
813
+ qty = m.round === false ? round22(qty) : Math.ceil(qty - 1e-9);
814
+ return { name: m.name, unit: m.unit || "", per, basis: m.basis || "area", round: m.round !== false, note: m.note || "", basis_qty: round22(basisVal), qty };
815
+ });
816
+ return {
817
+ id: c.id,
818
+ finish_tag: c.finish_tag,
819
+ color: c.color,
820
+ fill: c.fill,
821
+ hatch: c.hatch,
822
+ multiplier: mult,
823
+ waste_pct: waste,
824
+ shape_count: cs.length,
825
+ floor_sf: round22(floor),
826
+ wall_sf: round22(wall),
827
+ border_sf: round22(border),
828
+ lf: round22(lf),
829
+ ea,
830
+ total_sf: round22(total),
831
+ // waste-adjusted (order quantities)
832
+ floor_sf_net: round22(floor * w),
833
+ wall_sf_net: round22(wall * w),
834
+ border_sf_net: round22(border * w),
835
+ lf_net: round22(lf * w),
836
+ total_sf_net: round22(total * w),
837
+ sy_net: round22(total * w / 9),
838
+ materials
839
+ };
840
+ });
841
+ }
842
+ function grandTotals(rows) {
843
+ const sum = (k) => rows.reduce((n, r) => n + (r[k] || 0), 0);
844
+ return {
845
+ total_sf: round22(sum("total_sf")),
846
+ total_sf_net: round22(sum("total_sf_net")),
847
+ lf: round22(sum("lf")),
848
+ lf_net: round22(sum("lf_net")),
849
+ ea: sum("ea"),
850
+ sy_net: round22(sum("sy_net"))
851
+ };
852
+ }
853
+
854
+ // src/session.ts
855
+ var SNAP_CELL = 24;
856
+ var SNAP_TOL = 7;
857
+ var PALETTE = ["#c96442", "#2f7d54", "#2563eb", "#9333ea", "#b8860b", "#0d9488", "#be185d", "#1f2937", "#dc2626", "#0891b2"];
858
+ var HATCH_IDS = ["solid", "diag", "diag2", "cross", "diagdense", "horiz", "vert", "grid", "brick", "plank", "herring", "basket", "checker", "wave", "fleur", "speckle"];
859
+ var _idn = 0;
860
+ var uid = (p) => `${p}-${Date.now().toString(36)}-${(_idn++).toString(36)}`;
861
+ var ANN_SCHEMA = "opentakeoff.takeoff_canvas.v1";
862
+ var sheetSummary = (s) => ({
863
+ sheet: s.key,
864
+ page: s.pageNum,
865
+ width_pt: s.widthPt,
866
+ height_pt: s.heightPt,
867
+ width_px: s.widthPx,
868
+ height_px: s.heightPx,
869
+ ...s.sheetNumber ? { sheet_number: s.sheetNumber } : {},
870
+ ...s.detected ? { detected_scale: s.detected.label } : {}
871
+ });
872
+ var Session = class {
873
+ file = null;
874
+ doc = null;
875
+ sheets = /* @__PURE__ */ new Map();
876
+ conditions = [];
877
+ shapes = [];
878
+ /** load_plan replaces the session's document: the old doc is destroyed and
879
+ * ALL state — scales, caches, conditions, shapes — is cleared. */
880
+ async loadPlan(filePath) {
881
+ if (this.doc) await this.doc.destroy().catch(() => {
882
+ });
883
+ this.doc = null;
884
+ this.sheets.clear();
885
+ this.conditions = [];
886
+ this.shapes = [];
887
+ this.file = null;
888
+ const doc = await openPdf(filePath);
889
+ this.doc = doc;
890
+ this.file = path2.basename(filePath);
891
+ for (let n = 1; n <= doc.numPages; n++) {
892
+ const ph = await doc.page(n);
893
+ const key = n === 1 ? this.file : `${this.file}#${n}`;
894
+ this.sheets.set(key, {
895
+ key,
896
+ pageNum: n,
897
+ widthPt: ph.widthPt,
898
+ heightPt: ph.heightPt,
899
+ widthPx: ph.viewport.width,
900
+ heightPx: ph.viewport.height,
901
+ sheetNumber: extractSheetNumber(ph.textContent, ph.viewport),
902
+ detected: detectScale(ph.textContent, ph.viewport),
903
+ upp: null,
904
+ text: positionedText(ph),
905
+ page: ph
906
+ });
907
+ }
908
+ return {
909
+ file: this.file,
910
+ page_count: doc.numPages,
911
+ sheets: [...this.sheets.values()].map(sheetSummary),
912
+ note: "Replaced the previous session \u2014 all prior scales, conditions, and shapes were cleared."
913
+ };
914
+ }
915
+ sheet(name) {
916
+ if (!this.doc) throw new UserError("No plan loaded \u2014 call load_plan first.");
917
+ const hit = this.sheets.get(name);
918
+ if (hit) return hit;
919
+ const wanted = name.toUpperCase().replace(/\s+/g, "");
920
+ for (const s of this.sheets.values()) if (s.sheetNumber === wanted) return s;
921
+ throw new UserError(`Unknown sheet "${name}" \u2014 loaded sheets: ${[...this.sheets.keys()].join(", ")}.`);
922
+ }
923
+ async ensureGeometry(s) {
924
+ if (!s.geo) {
925
+ const opList = await s.page.operatorList();
926
+ s.geo = extractVectorGeometry(opList, s.page.viewport.transform, OPS2);
927
+ s.snap = buildSnapGrid(s.geo.points, SNAP_CELL);
928
+ }
929
+ return s.geo;
930
+ }
931
+ /** v1 masks come from the sheet's vector linework only. Raster seam: a scanned
932
+ * sheet would render via a node canvas into a future rastermask module that
933
+ * returns this same MaskObj shape. */
934
+ async ensureMask(name) {
935
+ const s = this.sheet(name);
936
+ if (s.mask === void 0) {
937
+ const geo = await this.ensureGeometry(s);
938
+ s.mask = geo.segs.length ? buildMask(geo.segs, s.widthPx, s.heightPx, MASK_MAX_DIM, geo.meta) : null;
939
+ }
940
+ return s.mask;
941
+ }
942
+ async sheetInfo(name) {
943
+ const s = this.sheet(name);
944
+ const geo = await this.ensureGeometry(s);
945
+ return {
946
+ ...sheetSummary(s),
947
+ seg_count: geo.segs.length >> 2,
948
+ has_vector_linework: geo.segs.length > 0,
949
+ scale_set: s.upp != null,
950
+ ...s.upp != null ? { upp: s.upp } : {},
951
+ shape_count: this.shapes.filter((x) => x.sheet_id === s.key).length
952
+ };
953
+ }
954
+ scaleGate(s) {
955
+ return `Set the scale for ${s.key} first \u2014 use set_scale${s.detected ? ` (detected: ${s.detected.label})` : ""}.`;
956
+ }
957
+ setScale(name, mode) {
958
+ const s = this.sheet(name);
959
+ let upp;
960
+ let label;
961
+ let source;
962
+ if (mode.label !== void 0) {
963
+ const sc = STANDARD_SCALES.find((x) => x.label === mode.label);
964
+ if (!sc) throw new UserError(`Unknown scale label ${JSON.stringify(mode.label)}. Valid labels: ${STANDARD_SCALES.map((x) => x.label).join(" | ")}`);
965
+ upp = sc.upp;
966
+ label = sc.label;
967
+ source = "label";
968
+ } else if (mode.upp !== void 0) {
969
+ if (!(mode.upp > 0)) throw new UserError("upp must be a positive number (real feet per image px at render scale 2.0).");
970
+ upp = mode.upp;
971
+ source = "upp";
972
+ } else if (mode.calibrate !== void 0) {
973
+ const { p1, p2, feet } = mode.calibrate;
974
+ const px = Math.hypot(p2[0] - p1[0], p2[1] - p1[1]);
975
+ if (!(px > 0)) throw new UserError("Calibration points are identical \u2014 click two points along a known dimension.");
976
+ if (!(feet > 0)) throw new UserError("Calibration feet must be positive.");
977
+ upp = feet / px;
978
+ source = "calibrate";
979
+ } else if (mode.use_detected) {
980
+ if (!s.detected) throw new UserError(`No detected scale for ${s.key} \u2014 read the title block with read_sheet_text, or calibrate from a known dimension.`);
981
+ upp = s.detected.upp;
982
+ label = s.detected.label;
983
+ source = "detected";
984
+ } else {
985
+ throw new UserError("Provide exactly one of: label, upp, calibrate, use_detected.");
986
+ }
987
+ s.upp = upp;
988
+ return { sheet: s.key, upp, ...label ? { label } : {}, source };
989
+ }
990
+ conditionFor(tag) {
991
+ let c = this.conditions.find((x) => x.finish_tag === tag);
992
+ if (!c) {
993
+ const lc = PALETTE[this.conditions.length % PALETTE.length];
994
+ c = {
995
+ id: uid("cnd"),
996
+ finish_tag: tag,
997
+ color: lc,
998
+ fill: lc,
999
+ hatch: HATCH_IDS[1 + this.conditions.length % (HATCH_IDS.length - 1)],
1000
+ multiplier: 1,
1001
+ waste_pct: 0,
1002
+ materials: []
1003
+ };
1004
+ this.conditions.push(c);
1005
+ }
1006
+ return c;
1007
+ }
1008
+ commit(s, tag, role, vertsPx, computed, origin) {
1009
+ const c = this.conditionFor(tag);
1010
+ const shape = {
1011
+ id: uid("shp"),
1012
+ sheet_id: s.key,
1013
+ condition_id: c.id,
1014
+ measure_role: role,
1015
+ verts_norm: vertsPx.map(([x, y]) => [x / s.widthPx, y / s.heightPx]),
1016
+ computed,
1017
+ ...origin ? { origin } : {}
1018
+ };
1019
+ this.shapes.push(shape);
1020
+ return shape;
1021
+ }
1022
+ async oneClick(name, x, y, opts) {
1023
+ const s = this.sheet(name);
1024
+ const mask = await this.ensureMask(name);
1025
+ if (!mask) throw new UserError("This sheet has no vector linework (likely a scan); raster fallback not yet available in the MCP server.");
1026
+ const f = floodRegion(mask, x, y);
1027
+ if (f.status === "leak") throw new UserError("That space isn't enclosed on the plan linework \u2014 the fill spilled through a gap or opening.");
1028
+ if (f.status !== "ok") throw new UserError("Landed in dense linework (hatching or text).");
1029
+ const ring = snapVertices(traceRegion(f), (px, py, d) => s.snap ? nearestSnap(s.snap, px, py, d) : null, SNAP_TOL);
1030
+ if (ring.length < 3) throw new UserError("Couldn't trace that space into a polygon.");
1031
+ const areaPx2 = ringArea(ring);
1032
+ const perimPx = closedMetrics(ring).perim;
1033
+ const common = {
1034
+ status: "ok",
1035
+ nverts: ring.length,
1036
+ ...f.hatchFiltered ? { hatch_filtered: true } : {},
1037
+ ...opts.returnVerts ? { verts: ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
1038
+ };
1039
+ if (s.upp == null) {
1040
+ return {
1041
+ ...common,
1042
+ area_px2: round1(areaPx2),
1043
+ perimeter_px: round1(perimPx),
1044
+ warning: `No scale set for ${s.key} \u2014 quantities unavailable. Call set_scale${s.detected ? ` (detected: ${s.detected.label})` : ""}.`
1045
+ };
1046
+ }
1047
+ const upp = s.upp;
1048
+ const area_sf = round2(areaPx2 * upp * upp);
1049
+ const perimeter_lf = round2(perimPx * upp);
1050
+ let shape_id;
1051
+ if (opts.condition) {
1052
+ shape_id = this.commit(s, opts.condition, opts.role, ring, { area_sf, perimeter_lf }, {
1053
+ method: "one_click_v1",
1054
+ seed_norm: [x / s.widthPx, y / s.heightPx],
1055
+ reviewed: true,
1056
+ ...f.hatchFiltered ? { hatch_filtered: true } : {}
1057
+ }).id;
1058
+ }
1059
+ return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id } : {} };
1060
+ }
1061
+ measurePolygon(name, verts, opts) {
1062
+ const s = this.sheet(name);
1063
+ if (s.upp == null) throw new UserError(this.scaleGate(s));
1064
+ const met = closedMetrics(verts);
1065
+ const area_sf = round2(met.area * s.upp * s.upp);
1066
+ const perimeter_lf = round2(met.perim * s.upp);
1067
+ let shape_id;
1068
+ if (opts.condition) shape_id = this.commit(s, opts.condition, opts.role, verts, { area_sf, perimeter_lf }).id;
1069
+ return { area_sf, perimeter_lf, nverts: verts.length, ...shape_id ? { shape_id } : {} };
1070
+ }
1071
+ measureLine(name, pts, opts) {
1072
+ const s = this.sheet(name);
1073
+ if (s.upp == null) throw new UserError(this.scaleGate(s));
1074
+ const length_lf = round2(openLen(pts) * s.upp);
1075
+ let shape_id;
1076
+ if (opts.condition) shape_id = this.commit(s, opts.condition, "linear", pts, { area_sf: 0, perimeter_lf: length_lf }).id;
1077
+ return { length_lf, npts: pts.length, ...shape_id ? { shape_id } : {} };
1078
+ }
1079
+ summary() {
1080
+ const rows = conditionTotals(this.conditions, this.shapes);
1081
+ const lean = rows.map(({ color, fill, hatch, materials, ...rest }) => rest);
1082
+ return { conditions: lean, totals: grandTotals(rows) };
1083
+ }
1084
+ deleteShape(id) {
1085
+ const i = this.shapes.findIndex((x) => x.id === id);
1086
+ if (i < 0) throw new UserError(`No shape with id ${JSON.stringify(id)}.`);
1087
+ this.shapes.splice(i, 1);
1088
+ return { deleted: id, shape_count: this.shapes.length };
1089
+ }
1090
+ /** The exact browser save payload (TakeoffCanvas.jsx autosave + the schema key
1091
+ * store.saveAnnotations stamps) — importable by the app. */
1092
+ exportPayload() {
1093
+ if (!this.doc) throw new UserError("No plan loaded \u2014 call load_plan first.");
1094
+ return {
1095
+ schema: ANN_SCHEMA,
1096
+ project_name: "",
1097
+ units: "imperial",
1098
+ sheets: [...this.sheets.values()].filter((s) => s.upp != null).map((s) => ({ sheet_id: s.key, units_per_px: s.upp })),
1099
+ conditions: this.conditions,
1100
+ shapes: this.shapes,
1101
+ markups: [],
1102
+ sheet_group: [],
1103
+ last_group: [],
1104
+ sheet_tabs: [],
1105
+ sheet_levels: {}
1106
+ };
1107
+ }
1108
+ readSheetText(name, region) {
1109
+ const s = this.sheet(name);
1110
+ const items = region ? s.text.filter((t) => t.x >= region.x0 && t.x <= region.x1 && t.y >= region.y0 && t.y <= region.y1) : s.text;
1111
+ return { sheet: s.key, items, text: items.map((t) => t.str).join(" ") };
1112
+ }
1113
+ };
1114
+
1115
+ // src/tools.ts
1116
+ import { z } from "zod";
1117
+ var COORDS = "Coordinates are image px at render scale 2.0: PDF pt \xD7 2, origin top-left, y down (the browser canvas's native space). Sheet payloads carry dims in both px and pt.";
1118
+ var pointSchema = z.tuple([z.number(), z.number()]);
1119
+ var roleSchema = z.enum(["floor_area", "deduct"]).default("floor_area");
1120
+ var run = (fn) => async (args) => {
1121
+ try {
1122
+ return ok(await fn(args));
1123
+ } catch (e) {
1124
+ return fail(e);
1125
+ }
1126
+ };
1127
+ function registerTools(server, session) {
1128
+ server.registerTool("load_plan", {
1129
+ description: `Open a plan PDF from disk and replace the whole session (previous document, scales, conditions, and shapes are cleared). Returns file, page_count, and one entry per sheet: dims, title-block sheet_number, and the detected drawn scale where present. ${COORDS}`,
1130
+ inputSchema: { path: z.string().describe("Path to a plan PDF on disk") }
1131
+ }, run(({ path: path3 }) => session.loadPlan(path3)));
1132
+ server.registerTool("sheet_info", {
1133
+ description: `Sheet detail: dims (px and pt), vector segment count, whether the sheet has vector linework (one_click needs it), scale status, the detected scale suggestion, and this sheet's committed shape count. ${COORDS}`,
1134
+ inputSchema: { sheet: z.string().describe('Sheet key ("plan.pdf", "plan.pdf#2") or title-block number ("A-101")') }
1135
+ }, run(({ sheet }) => session.sheetInfo(sheet)));
1136
+ server.registerTool("set_scale", {
1137
+ description: `Set a sheet's scale \u2014 exactly ONE of: label (a standard scale, e.g. '1/4" = 1'-0"'), upp (real feet per image px), calibrate (two points along a known dimension plus its real feet), or use_detected (adopt the drawn scale note read off the sheet). The detected scale is never applied automatically \u2014 setting it is always this explicit call. ${COORDS}`,
1138
+ inputSchema: {
1139
+ sheet: z.string(),
1140
+ label: z.string().optional().describe("A standard scale label, exactly as listed in the error on a miss"),
1141
+ upp: z.number().optional().describe("Real feet per image px at render scale 2.0"),
1142
+ calibrate: z.object({ p1: pointSchema, p2: pointSchema, feet: z.number() }).optional().describe("Two points (image px) a known real distance apart, and that distance in feet"),
1143
+ use_detected: z.boolean().optional().describe("true = adopt the sheet's detected scale")
1144
+ }
1145
+ }, run((a) => {
1146
+ const given = [a.label !== void 0, a.upp !== void 0, a.calibrate !== void 0, a.use_detected !== void 0].filter(Boolean).length;
1147
+ if (given !== 1) throw new UserError("Provide exactly one of: label, upp, calibrate, use_detected.");
1148
+ return session.setScale(a.sheet, a);
1149
+ }));
1150
+ server.registerTool("one_click", {
1151
+ description: `One-Click Area: click inside a room (image px) and the plan's vector linework bounds it \u2014 flood fill, contour trace, vertices snapped to true PDF endpoints. With the sheet's scale set, returns area_sf / perimeter_lf; pass condition (a finish tag, e.g. "CPT-1") to commit the traced shape to the takeoff. Without a scale it returns px-only quantities with a warning and commits nothing. role "deduct" makes the committed shape subtract. ${COORDS}`,
1152
+ inputSchema: {
1153
+ sheet: z.string(),
1154
+ x: z.number(),
1155
+ y: z.number(),
1156
+ condition: z.string().optional().describe("Finish tag to commit under (minted on first use)"),
1157
+ role: roleSchema,
1158
+ return_verts: z.boolean().default(false).describe("Include the traced polygon's vertices (image px)")
1159
+ }
1160
+ }, run((a) => session.oneClick(a.sheet, a.x, a.y, { condition: a.condition, role: a.role, returnVerts: a.return_verts })));
1161
+ server.registerTool("measure_polygon", {
1162
+ 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. ${COORDS}`,
1163
+ inputSchema: {
1164
+ sheet: z.string(),
1165
+ verts: z.array(pointSchema).min(3),
1166
+ condition: z.string().optional(),
1167
+ role: roleSchema
1168
+ }
1169
+ }, run((a) => session.measurePolygon(a.sheet, a.verts, { condition: a.condition, role: a.role })));
1170
+ server.registerTool("measure_line", {
1171
+ 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}`,
1172
+ inputSchema: {
1173
+ sheet: z.string(),
1174
+ pts: z.array(pointSchema).min(2),
1175
+ condition: z.string().optional()
1176
+ }
1177
+ }, run((a) => session.measureLine(a.sheet, a.pts, { condition: a.condition })));
1178
+ server.registerTool("takeoff_summary", {
1179
+ description: `Per-condition totals (floor/wall/border SF, LF, EA, SY, with and without waste) plus grand totals \u2014 the Report's numbers, computed by the same rules. ${COORDS}`,
1180
+ inputSchema: {}
1181
+ }, run(() => session.summary()));
1182
+ server.registerTool("export_takeoff", {
1183
+ description: `The full "opentakeoff.takeoff_canvas.v1" annotations payload \u2014 exactly what the app autosaves, importable by it. Returned inline; pass path to also write it to disk as JSON. ${COORDS}`,
1184
+ inputSchema: { path: z.string().optional().describe("File path to write the payload to") }
1185
+ }, run(async ({ path: outPath }) => {
1186
+ const payload = session.exportPayload();
1187
+ if (outPath) {
1188
+ const { writeFile } = await import("node:fs/promises");
1189
+ await writeFile(outPath, JSON.stringify(payload));
1190
+ }
1191
+ return payload;
1192
+ }));
1193
+ server.registerTool("delete_shape", {
1194
+ description: `Remove a committed shape by the id returned when it was committed. ${COORDS}`,
1195
+ inputSchema: { shape_id: z.string() }
1196
+ }, run(({ shape_id }) => session.deleteShape(shape_id)));
1197
+ server.registerTool("read_sheet_text", {
1198
+ description: `The sheet's text with positions \u2014 items [{str, x, y}] in image px plus the joined text. Optionally restrict to a region {x0, y0, x1, y1}. Use it to read title blocks, room labels, finish schedules, and scale notes. ${COORDS}`,
1199
+ inputSchema: {
1200
+ sheet: z.string(),
1201
+ region: z.object({ x0: z.number(), y0: z.number(), x1: z.number(), y1: z.number() }).optional()
1202
+ }
1203
+ }, run((a) => session.readSheetText(a.sheet, a.region)));
1204
+ }
1205
+
1206
+ // server.ts
1207
+ function buildServer(session = new Session()) {
1208
+ const server = new McpServer({ name: "opentakeoff", version: "0.1.0" });
1209
+ registerTools(server, session);
1210
+ return server;
1211
+ }
1212
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
1213
+ await buildServer().connect(new StdioServerTransport());
1214
+ }
1215
+ export {
1216
+ buildServer
1217
+ };