opentakeoff-mcp 0.9.29 → 0.9.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/server-core.js +252 -14
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -113,6 +113,7 @@ includes document text, shape vertices, or result payload content.
|
|
|
113
113
|
| `measure_polygon` | Area + perimeter of a polygon you supply (min 3 verts). Requires scale. |
|
|
114
114
|
| `measure_line` | Length of an open polyline (min 2 points). Requires scale. |
|
|
115
115
|
| `derive_base` | **Base LF from committed rooms**: for every floor shape of a source condition, commits a linear base run tracing that room's boundary, quantified net of the door openings YOU state per room (`{shape_id, lf}` — your claim, recorded on `origin.derived`; the tool never guesses). All-or-nothing; one undo step. |
|
|
116
|
+
| `derive_transitions` | **The transition where two finishes meet**: pass two finish tags and the tag to commit under, and every committed room of each is compared against every room of the other. The catch this is built around — flood-traced rooms **do not share edges**, a partition puts 4–8″ between them — so proximity comes in two flavours and they are never conflated. A **butt joint** (rings running together inside one open space, within an inch) *is* the transition and commits as a linear shape, `origin.derived` naming both parents, the tags, and the measured gap. A **wall-separated** run means the rooms are adjacent across a partition, where the transition is a threshold in a doorway that nothing in the trace record locates (the flood engine reports how *much* boundary it sealed, never where) — those return in `withheld` with length, gap in inches, and an `at` point to `view_sheet`, as questions rather than a confident wrong number. `max_gap_in` (default 12) only ever turns more of the plan into questions, never into committed LF. All-or-nothing; one undo step. |
|
|
116
117
|
| `measure_surface` | **Wall SF**: an open run traced along the wall, quantified as traced LF × the condition's height (the canvas's H knob — pass `height_ft` to set it, or set it once with `edit_condition`). Wall tile, wainscot, wall systems. Refuses without a height, minting nothing. |
|
|
117
118
|
| `place_count` | **EA markers**: one point, one each — thresholds, stair nosings, floor boxes. No scale required (EA is scale-free). One shape per point; the whole call is one undo step. |
|
|
118
119
|
| `symbol_sweep` | **Every instance of a repeated plan symbol, from ONE example**: marquee a tight `seed_rect` around a single drain/threshold/fixture symbol and the vector linework is searched deterministically for every other placement — translation plus 0/90/180/270 rotation and mirroring (both on by default). Score = length-weighted fraction of the seed's segments matched within `tolerance_px`; ≥ 0.92 is a match, the 0.75–0.92 band returns in `withheld` with reasons (never committed, never dropped silently), and the work cap is disclosed when it bites. **`scope: "set"` sweeps the whole working set, counting on PLAN-role sheets only** (the sheet graph decides; every excluded sheet disclosed in `skipped` with role and reason) — and the seed rect may sit on a detail or legend sheet, which then serves as the fingerprint SOURCE while staying excluded from counting: the estimator's "click the assembly in the detail, count it on the plans" gesture. Per-sheet results carry their own match/withheld lists, per-sheet cap accounting, and wall-clock `elapsed_ms`. `commit: true` + `condition` commits every match center as an EA count marker — the whole sweep (set-wide included) is one undo step, `origin.method "symbol_sweep"` with per-marker score, transform, and seed source (`origin.symbol.seed`). No scale required. |
|
package/dist/server-core.js
CHANGED
|
@@ -479,14 +479,14 @@ function cloudBezier(x0, y0, x1, y1) {
|
|
|
479
479
|
const ax0 = Math.min(x0, x1), ay0 = Math.min(y0, y1), ax1 = Math.max(x0, x1), ay1 = Math.max(y0, y1);
|
|
480
480
|
const r = Math.max(6, Math.min(22, (ax1 - ax0 + ay1 - ay0) / 22));
|
|
481
481
|
const arc = (len) => Math.max(1, Math.round(len / (r * 1.6)));
|
|
482
|
-
const
|
|
482
|
+
const segments2 = [];
|
|
483
483
|
let px = ax0, py = ay0;
|
|
484
484
|
const edge = (fromX, fromY, toX, toY) => {
|
|
485
485
|
const n = arc(Math.hypot(toX - fromX, toY - fromY));
|
|
486
486
|
for (let i = 1; i <= n; i++) {
|
|
487
487
|
const qx = fromX + (toX - fromX) * (i / n), qy = fromY + (toY - fromY) * (i / n);
|
|
488
488
|
const [c1x, c1y, c2x, c2y] = arcToBezier(px, py, qx, qy, r, 0, 1);
|
|
489
|
-
|
|
489
|
+
segments2.push([[c1x, c1y], [c2x, c2y], [qx, qy]]);
|
|
490
490
|
px = qx;
|
|
491
491
|
py = qy;
|
|
492
492
|
}
|
|
@@ -495,7 +495,7 @@ function cloudBezier(x0, y0, x1, y1) {
|
|
|
495
495
|
edge(ax1, ay0, ax1, ay1);
|
|
496
496
|
edge(ax1, ay1, ax0, ay1);
|
|
497
497
|
edge(ax0, ay1, ax0, ay0);
|
|
498
|
-
return { start: [ax0, ay0], segments };
|
|
498
|
+
return { start: [ax0, ay0], segments: segments2 };
|
|
499
499
|
}
|
|
500
500
|
function buildSnapGrid(points, cell) {
|
|
501
501
|
const map = /* @__PURE__ */ new Map();
|
|
@@ -1203,7 +1203,7 @@ function sweepHatchRuns(segs, meta, ws) {
|
|
|
1203
1203
|
clusters.pop();
|
|
1204
1204
|
}
|
|
1205
1205
|
}
|
|
1206
|
-
const
|
|
1206
|
+
const median2 = (arr2) => {
|
|
1207
1207
|
const a = arr2.slice().sort((x, y) => x - y);
|
|
1208
1208
|
return a[a.length >> 1];
|
|
1209
1209
|
};
|
|
@@ -1240,17 +1240,17 @@ function sweepHatchRuns(segs, meta, ws) {
|
|
|
1240
1240
|
if (count < HATCH_MIN_RUN) return;
|
|
1241
1241
|
const gaps = [];
|
|
1242
1242
|
for (let k = a + 1; k <= b; k++) gaps.push(rows[k].d - rows[k - 1].d);
|
|
1243
|
-
const med =
|
|
1243
|
+
const med = median2(gaps);
|
|
1244
1244
|
if (!med) return;
|
|
1245
1245
|
let reg = 0;
|
|
1246
1246
|
for (const g of gaps) if (Math.abs(g - med) <= med * HATCH_PITCH_TOL) reg++;
|
|
1247
1247
|
if (reg / gaps.length < HATCH_MIN_REGULAR) return;
|
|
1248
1248
|
const widths = [];
|
|
1249
1249
|
for (let k = a; k <= b; k++) for (const s of rows[k].segs) widths.push(s.w);
|
|
1250
|
-
const modalW = Math.max(1,
|
|
1250
|
+
const modalW = Math.max(1, median2(widths));
|
|
1251
1251
|
const spans = [];
|
|
1252
1252
|
for (let k = a; k <= b; k++) spans.push(rows[k].t1 - rows[k].t0);
|
|
1253
|
-
const medSpan = Math.max(1,
|
|
1253
|
+
const medSpan = Math.max(1, median2(spans));
|
|
1254
1254
|
const memberIdx = [];
|
|
1255
1255
|
const softIdx = [];
|
|
1256
1256
|
let bx0 = Infinity, by0 = Infinity, bx1 = -Infinity, by1 = -Infinity;
|
|
@@ -3514,6 +3514,106 @@ function matchSymbol(fp, segs, opts = {}) {
|
|
|
3514
3514
|
};
|
|
3515
3515
|
}
|
|
3516
3516
|
|
|
3517
|
+
// ../web/src/lib/transitions.ts
|
|
3518
|
+
var COINCIDENT_PX = 1e-9;
|
|
3519
|
+
function segments(ring) {
|
|
3520
|
+
const out = [];
|
|
3521
|
+
for (let i = 0; i < ring.length; i++) out.push([ring[i], ring[(i + 1) % ring.length]]);
|
|
3522
|
+
return out;
|
|
3523
|
+
}
|
|
3524
|
+
function nearestOnSeg(p, a, b) {
|
|
3525
|
+
const vx = b[0] - a[0], vy = b[1] - a[1];
|
|
3526
|
+
const len2 = vx * vx + vy * vy;
|
|
3527
|
+
const t = len2 > 0 ? Math.max(0, Math.min(1, ((p[0] - a[0]) * vx + (p[1] - a[1]) * vy) / len2)) : 0;
|
|
3528
|
+
const at = [a[0] + vx * t, a[1] + vy * t];
|
|
3529
|
+
return { d: Math.hypot(p[0] - at[0], p[1] - at[1]), at };
|
|
3530
|
+
}
|
|
3531
|
+
function nearestOnRing(p, ring) {
|
|
3532
|
+
let best = { d: Infinity, at: p };
|
|
3533
|
+
for (const [a, b] of segments(ring)) {
|
|
3534
|
+
const hit = nearestOnSeg(p, a, b);
|
|
3535
|
+
if (hit.d < best.d) best = hit;
|
|
3536
|
+
}
|
|
3537
|
+
return best;
|
|
3538
|
+
}
|
|
3539
|
+
function sampleRing(ring, step) {
|
|
3540
|
+
const out = [];
|
|
3541
|
+
for (const [a, b] of segments(ring)) {
|
|
3542
|
+
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
3543
|
+
if (!(len > 0)) continue;
|
|
3544
|
+
const n = Math.max(1, Math.ceil(len / step));
|
|
3545
|
+
for (let k = 0; k < n; k++) {
|
|
3546
|
+
const t = k / n;
|
|
3547
|
+
out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
|
|
3548
|
+
}
|
|
3549
|
+
}
|
|
3550
|
+
return out;
|
|
3551
|
+
}
|
|
3552
|
+
function median(xs) {
|
|
3553
|
+
if (!xs.length) return Infinity;
|
|
3554
|
+
const s = [...xs].sort((p, q) => p - q);
|
|
3555
|
+
const m = s.length >> 1;
|
|
3556
|
+
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
3557
|
+
}
|
|
3558
|
+
function sharedRuns(ringA, ringB, opts) {
|
|
3559
|
+
if (ringA.length < 3 || ringB.length < 3) return [];
|
|
3560
|
+
const { step_px, touch_px, max_gap_px, min_len_px } = opts;
|
|
3561
|
+
const samples = sampleRing(ringA, step_px);
|
|
3562
|
+
if (samples.length < 2) return [];
|
|
3563
|
+
const near = samples.map((p) => nearestOnRing(p, ringB));
|
|
3564
|
+
const dists = near.map((h) => h.d);
|
|
3565
|
+
const alongside = samples.map((p, i) => {
|
|
3566
|
+
if (dists[i] > max_gap_px) return false;
|
|
3567
|
+
const prev = samples[(i - 1 + samples.length) % samples.length];
|
|
3568
|
+
const next = samples[(i + 1) % samples.length];
|
|
3569
|
+
const tx = next[0] - prev[0], ty = next[1] - prev[1];
|
|
3570
|
+
const tl = Math.hypot(tx, ty);
|
|
3571
|
+
const dx = near[i].at[0] - p[0], dy = near[i].at[1] - p[1];
|
|
3572
|
+
const dl = Math.hypot(dx, dy);
|
|
3573
|
+
if (tl === 0) return false;
|
|
3574
|
+
if (dl < COINCIDENT_PX) return true;
|
|
3575
|
+
return Math.abs((tx * dx + ty * dy) / (tl * dl)) <= 0.5;
|
|
3576
|
+
});
|
|
3577
|
+
const raw = [];
|
|
3578
|
+
let start = -1;
|
|
3579
|
+
for (let i = 0; i < samples.length; i++) {
|
|
3580
|
+
if (alongside[i] && start < 0) start = i;
|
|
3581
|
+
if (!alongside[i] && start >= 0) {
|
|
3582
|
+
raw.push({ from: start, to: i - 1 });
|
|
3583
|
+
start = -1;
|
|
3584
|
+
}
|
|
3585
|
+
}
|
|
3586
|
+
if (start >= 0) raw.push({ from: start, to: samples.length - 1 });
|
|
3587
|
+
if (raw.length > 1) {
|
|
3588
|
+
const first = raw[0], last = raw[raw.length - 1];
|
|
3589
|
+
if (first.from === 0 && last.to === samples.length - 1) {
|
|
3590
|
+
raw.pop();
|
|
3591
|
+
raw[0] = { from: last.from, to: first.to + samples.length };
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
const runs = [];
|
|
3595
|
+
for (const r of raw) {
|
|
3596
|
+
const idx = [];
|
|
3597
|
+
for (let i = r.from; i <= r.to; i++) idx.push(i % samples.length);
|
|
3598
|
+
const path5 = idx.map((i) => samples[i]);
|
|
3599
|
+
let length_px = 0;
|
|
3600
|
+
for (let i = 1; i < path5.length; i++) {
|
|
3601
|
+
length_px += Math.hypot(path5[i][0] - path5[i - 1][0], path5[i][1] - path5[i - 1][1]);
|
|
3602
|
+
}
|
|
3603
|
+
if (length_px < min_len_px) continue;
|
|
3604
|
+
const gap_px = median(idx.map((i) => dists[i]));
|
|
3605
|
+
const mid = path5[path5.length >> 1];
|
|
3606
|
+
runs.push({
|
|
3607
|
+
kind: gap_px <= touch_px ? "butt" : "wall",
|
|
3608
|
+
path: path5,
|
|
3609
|
+
length_px,
|
|
3610
|
+
gap_px,
|
|
3611
|
+
at: [mid[0], mid[1]]
|
|
3612
|
+
});
|
|
3613
|
+
}
|
|
3614
|
+
return runs;
|
|
3615
|
+
}
|
|
3616
|
+
|
|
3517
3617
|
// ../web/src/lib/provenance.js
|
|
3518
3618
|
var mintUuid = () => {
|
|
3519
3619
|
const c = globalThis.crypto;
|
|
@@ -4693,9 +4793,9 @@ var Session = class _Session {
|
|
|
4693
4793
|
kept = visible2.slice().sort((a, b) => b.len - a.len).slice(0, cap);
|
|
4694
4794
|
droppedCap = visible2.length - cap;
|
|
4695
4795
|
}
|
|
4696
|
-
const
|
|
4796
|
+
const segments2 = [], metaOut = [], family = [];
|
|
4697
4797
|
for (const { i } of kept) {
|
|
4698
|
-
|
|
4798
|
+
segments2.push([
|
|
4699
4799
|
round1(geo.segs[i * 4]),
|
|
4700
4800
|
round1(geo.segs[i * 4 + 1]),
|
|
4701
4801
|
round1(geo.segs[i * 4 + 2]),
|
|
@@ -4717,7 +4817,7 @@ var Session = class _Session {
|
|
|
4717
4817
|
region: [round1(r.x0), round1(r.y0), round1(r.x1), round1(r.y1)],
|
|
4718
4818
|
has_vector_linework: hasVectors,
|
|
4719
4819
|
vectors: {
|
|
4720
|
-
segments,
|
|
4820
|
+
segments: segments2,
|
|
4721
4821
|
meta: metaOut,
|
|
4722
4822
|
family,
|
|
4723
4823
|
kept: kept.length,
|
|
@@ -5396,6 +5496,100 @@ var Session = class _Session {
|
|
|
5396
5496
|
note: "Base runs trace each room's boundary; openings are your stated claim, recorded on origin.derived. Verify with view_sheet overlay:true."
|
|
5397
5497
|
};
|
|
5398
5498
|
}
|
|
5499
|
+
/** Mint the transition where two finishes meet (#202) — the derivation that
|
|
5500
|
+
* follows derive_base, and the one an estimator draws by hand on every job.
|
|
5501
|
+
*
|
|
5502
|
+
* The geometry lives in web/src/lib/transitions.ts, and its headline is that
|
|
5503
|
+
* flood-traced rooms DO NOT SHARE EDGES: a partition puts four to eight
|
|
5504
|
+
* inches between two rings, so what is actually there is proximity, in two
|
|
5505
|
+
* flavours that mean different things. A BUTT JOINT (the rings run together
|
|
5506
|
+
* inside one open space) is the transition, and commits. A WALL-SEPARATED run
|
|
5507
|
+
* means the rooms are adjacent across a partition — the transition there is a
|
|
5508
|
+
* threshold in the DOORWAY, and nothing in the trace record says where the
|
|
5509
|
+
* doorway is: the flood engine seals openings and reports only how much
|
|
5510
|
+
* boundary it synthesised, never where. Committing thirty-four feet of
|
|
5511
|
+
* threshold because two rooms share thirty-four feet of wall would be a wrong
|
|
5512
|
+
* bid with a machine's confidence behind it, so those come back in
|
|
5513
|
+
* `withheld` — length, gap, and a point to look at — as questions.
|
|
5514
|
+
*
|
|
5515
|
+
* All-or-nothing like derive_base: unknown tags, a transition landing on
|
|
5516
|
+
* either source tag, or an unscaled sheet refuses the whole call before
|
|
5517
|
+
* anything commits. The sweep is ONE journal gesture. */
|
|
5518
|
+
deriveTransitions(opts) {
|
|
5519
|
+
const findCond = (tag) => {
|
|
5520
|
+
const c = this.conditions.find((x) => x.finish_tag === tag);
|
|
5521
|
+
if (!c) throw new UserError(`No condition ${JSON.stringify(tag)} \u2014 tags: ${this.conditions.map((x) => x.finish_tag).join(", ") || "(none)"}.`);
|
|
5522
|
+
return c;
|
|
5523
|
+
};
|
|
5524
|
+
const a = findCond(opts.condition_a), b = findCond(opts.condition_b);
|
|
5525
|
+
if (a.id === b.id) throw new UserError("condition_a and condition_b must be different finishes \u2014 a tag does not transition to itself.");
|
|
5526
|
+
if (opts.condition === a.finish_tag || opts.condition === b.finish_tag) {
|
|
5527
|
+
throw new UserError(`The transition must land on its OWN tag (e.g. 'T-1') \u2014 committing onto ${opts.condition} would add its LF to one of the finishes it separates.`);
|
|
5528
|
+
}
|
|
5529
|
+
const maxGapIn = opts.max_gap_in ?? 12;
|
|
5530
|
+
const minRunIn = opts.min_run_in ?? 12;
|
|
5531
|
+
if (!(maxGapIn > 0)) throw new UserError("max_gap_in must be > 0.");
|
|
5532
|
+
if (!(minRunIn > 0)) throw new UserError("min_run_in must be > 0.");
|
|
5533
|
+
const floors = (c) => this.shapes.filter((x) => x.condition_id === c.id && x.measure_role === "floor_area");
|
|
5534
|
+
const fa = floors(a), fb = floors(b);
|
|
5535
|
+
for (const [tag, list] of [[a.finish_tag, fa], [b.finish_tag, fb]]) {
|
|
5536
|
+
if (!list.length) throw new UserError(`${tag} has no floor_area shapes to derive from \u2014 commit rooms first (one_click / detect_rooms).`);
|
|
5537
|
+
}
|
|
5538
|
+
const sheetsInPlay = [...new Set([...fa, ...fb].map((s) => s.sheet_id))];
|
|
5539
|
+
for (const key of sheetsInPlay) {
|
|
5540
|
+
const s = this.sheet(key);
|
|
5541
|
+
if (s.upp == null) throw new UserError(`${key} has no scale \u2014 a transition is a real length, so set_scale first (${this.scaleGate(s)})`);
|
|
5542
|
+
}
|
|
5543
|
+
const committed = [], withheld = [];
|
|
5544
|
+
for (const key of sheetsInPlay) {
|
|
5545
|
+
const s = this.sheet(key);
|
|
5546
|
+
const upp = s.upp;
|
|
5547
|
+
const pxPerFt = 1 / upp;
|
|
5548
|
+
const toPx = (sh) => sh.verts_norm.map(([x, y]) => [x * s.widthPx, y * s.heightPx]);
|
|
5549
|
+
const onSheetA = fa.filter((x) => x.sheet_id === key), onSheetB = fb.filter((x) => x.sheet_id === key);
|
|
5550
|
+
for (const ra of onSheetA) {
|
|
5551
|
+
for (const rb of onSheetB) {
|
|
5552
|
+
const runs = sharedRuns(toPx(ra), toPx(rb), {
|
|
5553
|
+
step_px: Math.max(1, pxPerFt * 0.25),
|
|
5554
|
+
// a quarter-foot walk — finer than any transition matters
|
|
5555
|
+
touch_px: pxPerFt * (1 / 12),
|
|
5556
|
+
// within an inch: one open space, not two rooms
|
|
5557
|
+
max_gap_px: pxPerFt * (maxGapIn / 12),
|
|
5558
|
+
min_len_px: pxPerFt * (minRunIn / 12)
|
|
5559
|
+
});
|
|
5560
|
+
for (const r of runs) this.recordRun(s, r, upp, opts.condition, ra.id, rb.id, a.finish_tag, b.finish_tag, committed, withheld);
|
|
5561
|
+
}
|
|
5562
|
+
}
|
|
5563
|
+
}
|
|
5564
|
+
if (committed.length) this.flushCommits("derive_transitions");
|
|
5565
|
+
return {
|
|
5566
|
+
condition: opts.condition,
|
|
5567
|
+
between: [a.finish_tag, b.finish_tag],
|
|
5568
|
+
committed: committed.length,
|
|
5569
|
+
total_lf: round2(committed.reduce((n, r) => n + r.length_lf, 0)),
|
|
5570
|
+
runs: committed,
|
|
5571
|
+
withheld,
|
|
5572
|
+
withheld_lf: round2(withheld.reduce((n, r) => n + r.length_lf, 0)),
|
|
5573
|
+
note: withheld.length ? `${withheld.length} run(s) are adjacency ACROSS A WALL, not a butt joint \u2014 the transition there is a threshold in the doorway, and the trace record does not say where the doorway is. view_sheet each \`at\` and place them with measure_line / place_count.` : "Every run was a butt joint inside one open space. Verify with view_sheet overlay:true before trusting the total."
|
|
5574
|
+
};
|
|
5575
|
+
}
|
|
5576
|
+
/** One shared run → committed transition, or a disclosed question. */
|
|
5577
|
+
recordRun(s, r, upp, condition, aId, bId, aTag, bTag, committed, withheld) {
|
|
5578
|
+
const length_lf = round2(r.length_px * upp);
|
|
5579
|
+
const gap_in = round1(r.gap_px * upp * 12);
|
|
5580
|
+
const row = { sheet: s.key, between_shape_ids: [aId, bId], length_lf, gap_in, at: [Math.round(r.at[0]), Math.round(r.at[1])] };
|
|
5581
|
+
if (r.kind === "wall") {
|
|
5582
|
+
withheld.push({ ...row, reason: "wall_separated", detail: `${aTag} and ${bTag} run ${length_lf} LF apart across ${gap_in}" of wall \u2014 adjacent rooms, not a butt joint. If a door opens here the transition is a threshold at the door, which this cannot see.` });
|
|
5583
|
+
return;
|
|
5584
|
+
}
|
|
5585
|
+
const shape = this.commit(s, condition, "linear", r.path, { area_sf: 0, perimeter_lf: length_lf }, {
|
|
5586
|
+
method: "agent_v1",
|
|
5587
|
+
actor: "agent",
|
|
5588
|
+
reviewed: false,
|
|
5589
|
+
derived: { between_shape_ids: [aId, bId], between: [aTag, bTag], case: "butt", gap_in }
|
|
5590
|
+
});
|
|
5591
|
+
committed.push({ ...row, shape_id: shape.id });
|
|
5592
|
+
}
|
|
5399
5593
|
/** Count markers — the canvas's Count tool (commitCount): one point, one EA,
|
|
5400
5594
|
* computed {count: 1}, NO scale required (EA is scale-free; the canvas's
|
|
5401
5595
|
* recompute skips count shapes for the same reason). One shape per point,
|
|
@@ -6951,6 +7145,27 @@ var deriveBaseOutput = {
|
|
|
6951
7145
|
total_lf: z.number().describe("Sum of net_lf across rooms"),
|
|
6952
7146
|
note: z.string()
|
|
6953
7147
|
};
|
|
7148
|
+
var transitionRun = {
|
|
7149
|
+
sheet: z.string(),
|
|
7150
|
+
between_shape_ids: z.array(z.string()).describe("The two floor_area shapes this run separates"),
|
|
7151
|
+
length_lf: z.number().describe("Run length along the first shape's boundary"),
|
|
7152
|
+
gap_in: z.number().describe("Median distance between the two rings across the run, in inches \u2014 0-ish is one open space, 4-8 is a partition"),
|
|
7153
|
+
at: z.array(z.number()).describe("Run midpoint (image px) \u2014 pass to view_sheet to look at it")
|
|
7154
|
+
};
|
|
7155
|
+
var deriveTransitionsOutput = {
|
|
7156
|
+
condition: z.string().describe("The tag the transitions committed under"),
|
|
7157
|
+
between: z.array(z.string()).describe("The two finish tags"),
|
|
7158
|
+
committed: z.number().int(),
|
|
7159
|
+
total_lf: z.number().describe("Sum of committed run lengths \u2014 butt joints only"),
|
|
7160
|
+
runs: z.array(z.object({ ...transitionRun, shape_id: z.string() })),
|
|
7161
|
+
withheld: z.array(z.object({
|
|
7162
|
+
...transitionRun,
|
|
7163
|
+
reason: z.literal("wall_separated"),
|
|
7164
|
+
detail: z.string()
|
|
7165
|
+
})).describe("Adjacency across a wall: real, measured, and NOT committed \u2014 the transition there is a threshold at a doorway this cannot locate"),
|
|
7166
|
+
withheld_lf: z.number().describe("Shared-wall length held back \u2014 never part of total_lf"),
|
|
7167
|
+
note: z.string()
|
|
7168
|
+
};
|
|
6954
7169
|
var listShapesOutput = {
|
|
6955
7170
|
shapes: z.array(z.object({
|
|
6956
7171
|
id: z.string(),
|
|
@@ -8836,6 +9051,27 @@ function registerTools(server, session) {
|
|
|
8836
9051
|
},
|
|
8837
9052
|
outputSchema: deriveBaseOutput
|
|
8838
9053
|
}, run("derive_base", (a) => session.deriveBase(a)));
|
|
9054
|
+
server.registerTool("derive_transitions", {
|
|
9055
|
+
description: `Mint the transition where two finishes MEET (#202) \u2014 the derivation that follows derive_base, and the line an estimator draws by hand on every job. Pass the two finish tags and the tag the transition commits under (e.g. condition_a 'CPT-1', condition_b 'PT-1', condition 'T-1'), and every committed room of each is compared against every committed room of the other.
|
|
9056
|
+
|
|
9057
|
+
WHAT THE GEOMETRY ACTUALLY IS, because it decides what you get back: flood-traced rooms DO NOT SHARE EDGES. A trace fills to the wall linework, so two rooms across a partition are separated by four to eight inches of nothing \u2014 testing for a shared edge finds zero transitions on a real planset. What is there is proximity, in two flavours that mean completely different things:
|
|
9058
|
+
|
|
9059
|
+
\u2022 BUTT JOINT \u2014 the two rings run together inside ONE open space (a lobby that changes from carpet to tile with no wall between). The transition IS that run, and it commits as a linear shape under your tag, origin.derived naming both parent shapes and the measured gap.
|
|
9060
|
+
|
|
9061
|
+
\u2022 WALL-SEPARATED \u2014 the rings run parallel across a partition. The rooms are adjacent, but the transition is NOT the shared wall: it is a threshold, in the doorway, and NOTHING in the trace record says where the doorway is (the flood engine seals openings and reports how MUCH boundary it synthesised, never where). Committing 34 LF of threshold because two rooms share 34 LF of wall would be a wrong bid with a machine's confidence behind it. These come back in \`withheld\` \u2014 measured, with their length, their gap in inches, and an \`at\` point \u2014 as questions you answer by LOOKING (view_sheet at \`at\`, then measure_line or place_count the threshold yourself). The symbol_sweep doctrine: a near-match is never a silent commit and never a silent drop.
|
|
9062
|
+
|
|
9063
|
+
Tuning: max_gap_in (default 12) is how far apart two rings can be and still count as adjacent at all \u2014 raise it for thick walls, and every extra inch turns more of the plan into wall_separated questions, never into committed LF. min_run_in (default 12) drops corner artifacts. The butt-joint threshold is fixed at one inch and is not a knob: "these two finishes touch" is not a judgement call.
|
|
9064
|
+
|
|
9065
|
+
All-or-nothing, like derive_base: an unknown tag, a transition landing on either source tag, the same tag twice, or a sheet without a scale refuses the whole call before anything commits. The whole sweep is ONE undo step. After it, LOOK \u2014 view_sheet {overlay: true} over each run \u2014 before trusting total_lf. ${COORDS}`,
|
|
9066
|
+
inputSchema: {
|
|
9067
|
+
condition_a: z2.string().describe("First finish tag, e.g. 'CPT-1' \u2014 its committed rooms are walked, and runs are traced along their boundaries"),
|
|
9068
|
+
condition_b: z2.string().describe("Second finish tag, e.g. 'PT-1'"),
|
|
9069
|
+
condition: z2.string().describe("Finish tag the transitions commit under (minted on first use), e.g. 'T-1'. Must differ from both sources"),
|
|
9070
|
+
max_gap_in: z2.number().positive().optional().describe("How far apart two rings can be and still count as adjacent, in inches (default 12 \u2014 a thick partition). Wider only produces more wall_separated QUESTIONS, never more committed LF"),
|
|
9071
|
+
min_run_in: z2.number().positive().optional().describe("Shortest run worth reporting, in inches (default 12) \u2014 below this is a corner where two rooms clip, not a transition")
|
|
9072
|
+
},
|
|
9073
|
+
outputSchema: deriveTransitionsOutput
|
|
9074
|
+
}, run("derive_transitions", (a) => session.deriveTransitions(a)));
|
|
8839
9075
|
server.registerTool("takeoff_summary", {
|
|
8840
9076
|
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. Numbers only: the deliverable that SHOWS the work on the drawings is export_marked_pdf. ${COORDS}`,
|
|
8841
9077
|
inputSchema: {},
|
|
@@ -9160,7 +9396,7 @@ function registerResources(server, session) {
|
|
|
9160
9396
|
// package.json
|
|
9161
9397
|
var package_default = {
|
|
9162
9398
|
name: "opentakeoff-mcp",
|
|
9163
|
-
version: "0.9.
|
|
9399
|
+
version: "0.9.30",
|
|
9164
9400
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
9165
9401
|
type: "module",
|
|
9166
9402
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -9176,7 +9412,7 @@ var package_default = {
|
|
|
9176
9412
|
mcpb: "npm run build && node scripts/build-mcpb.mjs",
|
|
9177
9413
|
prepublishOnly: "npm run typecheck && npm test && npm run build",
|
|
9178
9414
|
typecheck: "tsc --noEmit",
|
|
9179
|
-
test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/view.test.ts"
|
|
9415
|
+
test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/transitions.test.ts test/view.test.ts"
|
|
9180
9416
|
},
|
|
9181
9417
|
dependencies: {
|
|
9182
9418
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
@@ -9238,8 +9474,10 @@ function buildServer(session = new Session()) {
|
|
|
9238
9474
|
"A takeoff's deliverable is the marked-up planset, not a numbers report. Standard finish for ANY takeoff:",
|
|
9239
9475
|
"1. load_plan, then set_scale on each sheet you measure (quantities are px-only until the scale is set).",
|
|
9240
9476
|
"2. Commit shapes under finish-tag conditions (one_click / detect_rooms / measure_polygon / measure_line with `condition`; when the set carries a room-finish schedule, prefer detect_rooms assign_from_schedule so each room commits under its OWN row).",
|
|
9241
|
-
"3.
|
|
9242
|
-
"4.
|
|
9477
|
+
"3. DERIVE what follows from the rooms instead of re-measuring it: derive_base for base LF (perimeter \u2212 the door openings YOU state), derive_transitions for the line where two finishes meet. Both read committed floor shapes, so they come after step 2 and their output is audited in step 4 like anything else.",
|
|
9478
|
+
"4. LOOK at what landed with view_sheet overlay:true and fix misses with edit_shape before trusting totals \u2014 crop the work region tight (full-sheet renders downsample too far to audit a ring).",
|
|
9479
|
+
"5. Finish by writing the marked-up planset with export_marked_pdf and give the user its file path, alongside export_report for the numbers. Never end a takeoff with numbers alone.",
|
|
9480
|
+
"WITHHELD IS NOT A FAILURE \u2014 IT IS THE ANSWER. detect_rooms, symbol_sweep, sweep_schedule_row and derive_transitions all measure things they then decline to commit, and say why: a near-match in the score band, a room the schedule cannot answer for, adjacency across a WALL rather than a butt joint. Read those arrays, view_sheet the coordinates they hand you, and resolve them or report them. A withheld item you ignore is a hole in the bid; one you never mention is worse."
|
|
9243
9481
|
].join("\n")
|
|
9244
9482
|
});
|
|
9245
9483
|
registerTools(server, session);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opentakeoff-mcp",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.30",
|
|
4
4
|
"mcpName": "io.github.Kentucky-ai/opentakeoff",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "OpenTakeoff MCP server — drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"mcpb": "npm run build && node scripts/build-mcpb.mjs",
|
|
17
17
|
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
18
18
|
"typecheck": "tsc --noEmit",
|
|
19
|
-
"test": "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/view.test.ts"
|
|
19
|
+
"test": "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/transitions.test.ts test/view.test.ts"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@modelcontextprotocol/sdk": "^1.12.0",
|