opentakeoff-mcp 0.9.76 → 0.9.80
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 +24 -6
- package/dist/server-core.js +484 -47
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -44,9 +44,9 @@ what's missing where rendering isn't available.
|
|
|
44
44
|
engine is re-validated against a wider plan corpus: `tools/list` never names them, the
|
|
45
45
|
initialize `instructions` say so and point at `measure_polygon`, and no other tool's
|
|
46
46
|
description sends an agent to a verb that is not there. A default build registers
|
|
47
|
-
|
|
47
|
+
**<!--tool-count-->53<!--/tool-count--> tools**. Everything else — sweeps, counts, `derive_base`, `derive_transitions`, the
|
|
48
48
|
exports — is unchanged. Set `OPENTAKEOFF_ONE_CLICK=1` in the server's environment to
|
|
49
|
-
register both verbs (
|
|
49
|
+
register both verbs (<!--tool-count-all-->55<!--/tool-count-all--> tools); the parity, conformance and e2e tests run that way, and
|
|
50
50
|
`test/gate.test.ts` pins both surfaces. The rows and examples below that use `one_click`
|
|
51
51
|
describe the lifted build. Design note: [`docs/design/ONE_CLICK_GATE.md`](../docs/design/ONE_CLICK_GATE.md).
|
|
52
52
|
|
|
@@ -121,7 +121,7 @@ includes document text, shape vertices, or result payload content.
|
|
|
121
121
|
|
|
122
122
|
### Staged tool exposure (opt-in)
|
|
123
123
|
|
|
124
|
-
By default every client gets all
|
|
124
|
+
By default every client gets all <!--tool-count-->53<!--/tool-count--> tool schemas on `tools/list`—the flat
|
|
125
125
|
contract every published client already expects. Fifty-two descriptions is real
|
|
126
126
|
token weight for an agent session that may never touch half of them, so the
|
|
127
127
|
server can instead stage the surface along the workflow it already teaches:
|
|
@@ -130,7 +130,7 @@ server can instead stage the surface along the workflow it already teaches:
|
|
|
130
130
|
OPENTAKEOFF_MCP_STAGED_TOOLS=1 npx -y opentakeoff-mcp
|
|
131
131
|
```
|
|
132
132
|
|
|
133
|
-
Staged, only the **setup** stage (load, scale, read the set
|
|
133
|
+
Staged, only the **setup** stage (load, scale, read the set—<!--tool-count-setup-->11<!--/tool-count-setup--> tools) starts
|
|
134
134
|
enabled, plus one opener: `open_tool_stage`. Calling it with `"measure"`,
|
|
135
135
|
`"revise"`, or `"handoff"` enables that stage's tools and fires
|
|
136
136
|
`tools/list_changed`, so any client that supports dynamic tool lists (Claude
|
|
@@ -143,6 +143,8 @@ reads the tool list once. ([#230](https://github.com/Kentucky-ai/opentakeoff/iss
|
|
|
143
143
|
|
|
144
144
|
## Tools
|
|
145
145
|
|
|
146
|
+
The [generated tool index](../docs/MCP_TOOL_INDEX.md) lists stages and required arguments from the runtime schemas; CI rejects stale output and missing reference rows.
|
|
147
|
+
|
|
146
148
|
| Tool | What it does |
|
|
147
149
|
|---|---|
|
|
148
150
|
| `load_plan` | Open a plan PDF from disk. Default replaces the whole session; **`merge: true` ADDS the document to the working set** (#152)—plans + schedule + addenda as one takeoff, sheet graph spanning the whole set, marked set covering every worked sheet. Returns per-sheet dims, title-block `sheet_number`, and the detected drawn scale where present. |
|
|
@@ -157,10 +159,11 @@ reads the tool list once. ([#230](https://github.com/Kentucky-ai/opentakeoff/iss
|
|
|
157
159
|
| `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. |
|
|
158
160
|
| `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. |
|
|
159
161
|
| `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. |
|
|
162
|
+
| `count_marks` | Count value-annotated device marks or schedule-keyed equipment labels, report withheld mentions, and optionally commit pending count shapes. |
|
|
160
163
|
| `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. **Counter-examples** (`exclude`, #259): rects around instances you do NOT mean, marqueed like the seed—the rect's own contents decide whether it rejects by extra contained linework or by the background line running THROUGH it that a real instance would break; every rejection disclosed in `rejected[]`, reinstatable with `place_count`, dead negatives refused with instructions. **Stroke-luminance gate** (`luminance_tolerance`, #260): for flattened exports where layers and pen weights are stripped but the file still states stroke color—a stated tolerance holds candidates to the seed's own pen, opt-in both ways, with `lum_gate` naming every placement the pen pulled under the bar. **Labels** (#308): for labeled families the sweep reads the drawing's own names—a fixture token written beside a placement or connected by its drawn leader (leader-following arms only on multi-pen sheets)—as `label`/`label_via` on every row plus `seed.label`; the reply flags shape-only matches in a labeled family, withheld rows carrying the seed's own tag, and matches the drawing names differently. Disclosure, never a recount. |
|
|
161
164
|
| `sweep_schedule_row` | **Take off a schedule row's mark from the row itself**: pass the row's key (for example, `T1`) and the tool reads the row from the set's schedule tables (the row is the condition's cited source), anchors a fingerprint on the marker the tag is DRAWN as on a plan sheet (a deterministic pad ladder around the tag text; where the tag occurs more than once the fingerprint must recur at a second occurrence—`anchor.corroborated`—before it is trusted), and sweeps every plan-role sheet. **The count is geometry AND text agreeing**: drafting reuses one bubble shape across many marks, so a match counts only when the row's own tag sits within the marker footprint (its bbox rides the match as `tag_at` evidence); a match labeled with a sibling key is `excluded` and says whose it is, an unlabeled match is `withheld` as a question, a tag drawn with no matching marker is `text_only`. Refusal over guessing, each with the reason and the fix: no such row, an ambiguous key, a tag drawn on no plan sheet, no repeatable marker linework—a fingerprint is never guessed from text alone. `commit: true` commits the counted matches under the row's own key—one undo step, `origin.assignment {source: "schedule"}` plus the anchor and row citation on `origin.symbol.seed`. No scale required. |
|
|
162
165
|
| `takeoff_summary` | Per-condition totals + grand totals, computed by the Report's rules. |
|
|
163
|
-
| `export_takeoff` | The full `opentakeoff.takeoff_canvas.v1` payload—exactly what the app autosaves. Inline, and to disk with `path` (see **Writing to disk** below). |
|
|
166
|
+
| `export_takeoff` | Includes calibration provenance (`scale_source`, `scale_confirmed`) when present and live RFIs in its declared output schema. The full `opentakeoff.takeoff_canvas.v1` payload—exactly what the app autosaves. Inline, and to disk with `path` (see **Writing to disk** below). |
|
|
164
167
|
| `delete_shape` | Remove a committed shape by id. |
|
|
165
168
|
| `propose_takeoff` | **Open a named batch** (#365). Every shape you commit from here on attaches to it (`origin.proposal_id`, stamped centrally at the commit—hand traces, sweeps, derives, `cut_out` alike), so the estimator sees ONE Accept pill for the batch instead of one per shape. `label` is what they read on the pill, `rationale` is what decided the batch; both required. Commits nothing itself. |
|
|
166
169
|
| `revise_proposal` | Replace **every still-pending shape** in a batch with a new set, as one journal step. All-or-nothing: validated (sheet, scale, vertex minimum, a height for `surface_area`) before the first pending shape is removed, the error naming the entry. Accepted shapes are ink and stay. One `undo_last` puts the previous batch back. |
|
|
@@ -183,6 +186,7 @@ reads the tool list once. ([#230](https://github.com/Kentucky-ai/opentakeoff/iss
|
|
|
183
186
|
| `undo_last` | Step back over your own last `n` mutations, newest first. Exact inverses: a commit is removed, an edit restored verbatim, a delete re-inserted where it was, a materials edit's whole array restored, a condition edit's waste/multiplier pair restored. A whole `detect_rooms` sweep is **one** step. |
|
|
184
187
|
| `annotate` | Place a note ABOUT the work—cloud/highlight (`rect`), text (`at`), callout (`at` + `target`), **arrow** (`from` + `to`—plank/seam direction), **bubble** (`at` + optional `r`—keynote circle, centered text), **dimension** (`from` + `to`—a dimension line with end ticks, labeled with the measured length at the sheet's scale; the one annotation the scale gate applies to—an unscaled sheet refuses like the measure tools). Attach to a condition and it wears that scope's color on the canvas and in the marked set. No review gate: notes are not geometry. |
|
|
185
188
|
| `list_annotations` | Every annotation with its condition RESOLVED to a finish tag, coordinates back in image px; filter by sheet/condition. `unattached` counts the link_annotation candidates. `verdicts[]` is the approval family's inventory—every mark with its actor stated, a condition filter reaching a verdict through its target shape. |
|
|
189
|
+
| `edit_annotation` | Replace or clear only annotation text; coordinates, dimensions, links, quantities and review stay unchanged. RFI-linked notes refuse. `undo_last` restores the previous text. |
|
|
186
190
|
| `link_annotation` | Attach an existing annotation to a condition (or detach with an empty tag)—the canvas's Attach/Detach control, reachable by an agent. |
|
|
187
191
|
| `mark_verdict` | The **agent's pencil-signature** on work it checked—the agent half of the approval family (#176). Mints the graphite AGENT diamond, and structurally nothing else: the tool takes no actor input, so the estimator's APPROVED ring stays behind the canvas's human-only Approve tool. Target a committed `shape_id` (anchored on the shape—a room's centroid, a run's midpoint—with the id recorded as provenance) or a `sheet` + `at` point; optional short `text` rides the record. Touches no quantity; renders on the canvas and in the marked set, whose cover tallies the split (`Approval stamps: N estimator-approved · M agent-marked`); rides the annotations payload through `export_takeoff`/`import_takeoff` and the app's own saves. One mark per shape. |
|
|
188
192
|
| `delete_verdict` | Lift an agent verdict mark by id. Agent marks only—the estimator's seal is human ink and is refused, the same line `edit_shape` holds on reviewed shapes. `undo_last` re-seats a lifted mark exactly where it was. |
|
|
@@ -194,7 +198,7 @@ reads the tool list once. ([#230](https://github.com/Kentucky-ai/opentakeoff/iss
|
|
|
194
198
|
| `find_text` | **Locate** a known string—the complement to `read_sheet_text` (which returns what a region *says*; this finds *where* a string sits). Case-insensitive substring match per pdf.js text run; each hit's center feeds straight into `one_click`'s seed. |
|
|
195
199
|
| `sheet_graph` | The plan-set INDEX (#87): every sheet's role with evidence, the schedule tables found, every room tag with its stacked name, the detail callouts, and every revision marker (text `Δ2`/`REV 2` tags AND drawn deltas—a bare digit inside a triangle of linework, proven from the sheet's vector geometry—in `revisions`)—how an agent decides WHAT to measure without a human enumerating rooms. |
|
|
196
200
|
| `resolve_tag` | ONE room tag → its room-finish schedule row → each code's finish/material definition, every edge cited (sheet + literal text + bbox). Refusal over guessing: `unresolved` comes back with a reason, never as silence. A delta/REV marker on the answering row rides the result as `revisions`—the codes are the post-revision answer, and you're told the ink changed. |
|
|
197
|
-
| `find_schedule` | Locate a schedule table by kind ("room finish", "material")—sheet, title, headers, row count, a `view_sheet`-ready region, and `revised_rows` when delta/REV-marked rows exist. |
|
|
201
|
+
| `find_schedule` | Locate a schedule table by kind ("room finish", "material", or "equipment"—device schedules of any trade: fans, pumps, heaters, light fixtures, plumbing fixtures, diffusers, keyed by mark and proven by a device column such as CFM, WATTS, GPM, LAMPS, NECK)—sheet, title, headers, row count, a `view_sheet`-ready region, and `revised_rows` when delta/REV-marked rows exist. Every stacked schedule on a sheet is read, top to bottom. |
|
|
198
202
|
| `sheet_context` | The region's STRUCTURE in one frame: classified vector segments (endpoints as drawn, meta byte per segment), text spans with bboxes, and hatch-family instances with content-derived ids—same pattern spec ⇒ same id anywhere on the sheet, so plan↔legend matching is `id === id`. Decimation is declared and counted on every reply: `kept + dropped === total_in_region`, cap applies longest-first so walls survive. |
|
|
199
203
|
| `get_sheet_vectors` | The STROKES (#367): the sheet's vector layer exactly as the engine is fed it—flat `[x1, y1, x2, y2, …]` points in image px, one meta byte per segment (curve / clip / fill-only / polyline-arc flags, pen width in the high nibble), per-segment stroke luminance, the drawn figure each segment belongs to, the placed-image area, and the PDF layer table with a per-segment layer index. Nothing classified, decimated, or merged—so a reader can run its own room finder, symbol matcher, or wall classifier against the same array and commit through the existing verbs. Paged (default 20,000 segments, ceiling 100,000) with `offset + returned + dropped === total` on every page; `next_cursor` recovers exactly what `dropped` counts. Read-only and stateless. Refuses on a scan and names `view_sheet` as the path. |
|
|
200
204
|
| `view_sheet` | The agent's eyes: render the sheet (or an image-px crop) to PNG. `overlay` burns committed shapes in (solid = human-affirmed, dashed = unreviewed) to verify geometry landed; `grid` burns in a calibrated 1-ft/5-ft measuring grid with foot labels (`"auto"` from the set scale, or the drawing scale like `"1/4"`) so dimensions are counted off cells, not guessed; `marks` (#297) burns disclosure layers in — `question` (withheld placements, orange ?-circle), `struck` (rejections, magenta struck ×), `ring` (the sweep's seed, violet double ring) — in colors off the common CAD pens, so what a reply names, the picture shows. |
|
|
@@ -233,6 +237,20 @@ output, so it declares no schema by design). Failures come back as
|
|
|
233
237
|
|
|
234
238
|
## Resources — browse before you measure
|
|
235
239
|
|
|
240
|
+
The [wiki index](../docs/wiki/README.md) is always available as `takeoff://wiki`,
|
|
241
|
+
including before `load_plan` and while measurement stages are closed. Its eight
|
|
242
|
+
linked pages use `takeoff://wiki/{page}`: status, architecture, protocol,
|
|
243
|
+
workflows, mcp, domain, repo-guide and tool-index. Read only the page relevant
|
|
244
|
+
to the current task. These are static public Markdown resources, not tools;
|
|
245
|
+
reading them cannot change geometry, scale or review.
|
|
246
|
+
|
|
247
|
+
Pages are embedded in the published MCP bundle with its version and an
|
|
248
|
+
LF-normalized source hash. Wiki links navigate to packaged resources; links to
|
|
249
|
+
repository code browse `main` and may be newer. Unknown resource paths refuse;
|
|
250
|
+
there is no filesystem path or remote URL input. `npm run check:wiki` compares
|
|
251
|
+
the embedded copy to source; `-- --write` regenerates it. Build and CI fail when
|
|
252
|
+
it is stale. The distribution smoke check reads all nine pages over stdio.
|
|
253
|
+
|
|
236
254
|
Tools let an agent act; resources let it **see**. When a plan loads, the sheet
|
|
237
255
|
set becomes browsable natively (`resources/list` re-announces itself through
|
|
238
256
|
`list_changed`):
|
package/dist/server-core.js
CHANGED
|
@@ -3257,6 +3257,12 @@ function isLabelBubblePx(ring, b) {
|
|
|
3257
3257
|
// ../web/src/lib/sheetgraph.ts
|
|
3258
3258
|
var bboxOf = (s) => [s.x, s.y, s.x + (s.w || 0), s.y + (s.h || 0)];
|
|
3259
3259
|
var merge = (a, b) => [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[2], b[2]), Math.max(a[3], b[3])];
|
|
3260
|
+
var overlapFrac = (a, b) => {
|
|
3261
|
+
const w = Math.min(a[2], b[2]) - Math.max(a[0], b[0]), h = Math.min(a[3], b[3]) - Math.max(a[1], b[1]);
|
|
3262
|
+
if (w <= 0 || h <= 0) return 0;
|
|
3263
|
+
const area = Math.max(1, (a[2] - a[0]) * (a[3] - a[1]));
|
|
3264
|
+
return w * h / area;
|
|
3265
|
+
};
|
|
3260
3266
|
var norm = (s) => (s || "").trim().toUpperCase();
|
|
3261
3267
|
var isVertical = (s) => s.rot != null ? Math.abs(s.rot % 180) === 90 : (s.str || "").trim().length >= 4 && (s.w || 0) > 0 && (s.h || 0) > 2 * s.w;
|
|
3262
3268
|
var SCHEDULE_TITLE_RE = /^[A-Z][A-Z ()/&.'’-]* SCHEDULE( *[-–] *[A-Z0-9 ()/&.'’-]+)?( *\(?(?:CONTINUATION|CONTINUED|CONT['’]?D?)\.?\)?)?$/;
|
|
@@ -3406,6 +3412,159 @@ var rowY = (r) => r.reduce((s, t) => s + t.y, 0) / r.length;
|
|
|
3406
3412
|
var SURFACE_WORDS = /* @__PURE__ */ new Set(["FLOOR", "BASE", "WALL", "WALLS", "CEILING", "NORTH", "SOUTH", "EAST", "WEST", "WAINSCOT"]);
|
|
3407
3413
|
var ROOM_HEADERS = ["ROOM", "NO", "NUMBER", "NAME", "MARK", "LOCATION", "FLOOR", "BASE", "WALL", "WALLS", "NORTH", "SOUTH", "EAST", "WEST", "CEILING", "WAINSCOT", "REMARKS", "CLG", "HT", "HEIGHT", "FINISH", "MAT", "MATERIAL", "COMMENTS", "CASEWORK", "CABINET", "COUNTER", "COUNTERTOP", "BLDG", "BUILDING"];
|
|
3408
3414
|
var FINISH_HEADERS = ["CODE", "MARK", "SYMBOL", "TAG", "MATERIAL", "MANUFACTURER", "PRODUCT", "STYLE", "COLOR", "SIZE", "REMARKS", "DESCRIPTION", "PATTERN", "COMMENTS"];
|
|
3415
|
+
var EQUIPMENT_HEADERS = [
|
|
3416
|
+
// identity + the shared columns
|
|
3417
|
+
"ID",
|
|
3418
|
+
"MARK",
|
|
3419
|
+
"TAG",
|
|
3420
|
+
"SYMBOL",
|
|
3421
|
+
"UNIT",
|
|
3422
|
+
"DESIGNATION",
|
|
3423
|
+
"TYPE",
|
|
3424
|
+
"FIXTURE",
|
|
3425
|
+
"DESCRIPTION",
|
|
3426
|
+
"MANUFACTURER",
|
|
3427
|
+
"MFR",
|
|
3428
|
+
"MODEL",
|
|
3429
|
+
"CATALOG",
|
|
3430
|
+
"SERVICE",
|
|
3431
|
+
"SERVES",
|
|
3432
|
+
"LOCATION",
|
|
3433
|
+
"AREA",
|
|
3434
|
+
"SIZE",
|
|
3435
|
+
"LENGTH",
|
|
3436
|
+
"WEIGHT",
|
|
3437
|
+
"QTY",
|
|
3438
|
+
"QUANTITY",
|
|
3439
|
+
"REMARKS",
|
|
3440
|
+
"NOTES",
|
|
3441
|
+
"COMMENTS",
|
|
3442
|
+
// mechanical
|
|
3443
|
+
"CFM",
|
|
3444
|
+
"GPM",
|
|
3445
|
+
"WATTS",
|
|
3446
|
+
"KW",
|
|
3447
|
+
"VOLTS",
|
|
3448
|
+
"VOLTAGE",
|
|
3449
|
+
"PHASE",
|
|
3450
|
+
"PH",
|
|
3451
|
+
"HZ",
|
|
3452
|
+
"HP",
|
|
3453
|
+
"MBH",
|
|
3454
|
+
"BTUH",
|
|
3455
|
+
"BTU",
|
|
3456
|
+
"TONS",
|
|
3457
|
+
"RPM",
|
|
3458
|
+
"ESP",
|
|
3459
|
+
"SP",
|
|
3460
|
+
"FLA",
|
|
3461
|
+
"MCA",
|
|
3462
|
+
"MOCP",
|
|
3463
|
+
"AMPS",
|
|
3464
|
+
"EAT",
|
|
3465
|
+
"LAT",
|
|
3466
|
+
"EWT",
|
|
3467
|
+
"LWT",
|
|
3468
|
+
"PSI",
|
|
3469
|
+
"FPM",
|
|
3470
|
+
"NECK",
|
|
3471
|
+
"THROW",
|
|
3472
|
+
"MOUNTING",
|
|
3473
|
+
"DAMPER",
|
|
3474
|
+
"FRAME",
|
|
3475
|
+
"AIRFLOW",
|
|
3476
|
+
// electrical
|
|
3477
|
+
"LAMP",
|
|
3478
|
+
"LAMPS",
|
|
3479
|
+
"LUMENS",
|
|
3480
|
+
"BALLAST",
|
|
3481
|
+
"DRIVER",
|
|
3482
|
+
"CIRCUIT",
|
|
3483
|
+
"BREAKER",
|
|
3484
|
+
"POLES",
|
|
3485
|
+
"KVA",
|
|
3486
|
+
"AIC",
|
|
3487
|
+
"WIRE",
|
|
3488
|
+
"CONDUIT",
|
|
3489
|
+
"FEEDER",
|
|
3490
|
+
"LOAD",
|
|
3491
|
+
"KVAR",
|
|
3492
|
+
// plumbing / fire
|
|
3493
|
+
"CW",
|
|
3494
|
+
"HW",
|
|
3495
|
+
"WASTE",
|
|
3496
|
+
"VENT",
|
|
3497
|
+
"TRAP",
|
|
3498
|
+
"DFU",
|
|
3499
|
+
"WSFU",
|
|
3500
|
+
"CONNECTION",
|
|
3501
|
+
"SUPPLY",
|
|
3502
|
+
"DRAIN",
|
|
3503
|
+
"SPRINKLER",
|
|
3504
|
+
"ORIFICE",
|
|
3505
|
+
"TEMPERATURE"
|
|
3506
|
+
];
|
|
3507
|
+
var EQUIPMENT_KEY_HEADERS = ["ID", "MARK", "TAG", "SYMBOL", "UNIT", "DESIGNATION", "TYPE", "FIXTURE"];
|
|
3508
|
+
var EQUIPMENT_ONLY = /* @__PURE__ */ new Set([
|
|
3509
|
+
"CFM",
|
|
3510
|
+
"GPM",
|
|
3511
|
+
"WATTS",
|
|
3512
|
+
"KW",
|
|
3513
|
+
"VOLTS",
|
|
3514
|
+
"VOLTAGE",
|
|
3515
|
+
"PHASE",
|
|
3516
|
+
"PH",
|
|
3517
|
+
"HZ",
|
|
3518
|
+
"HP",
|
|
3519
|
+
"MBH",
|
|
3520
|
+
"BTUH",
|
|
3521
|
+
"BTU",
|
|
3522
|
+
"TONS",
|
|
3523
|
+
"RPM",
|
|
3524
|
+
"ESP",
|
|
3525
|
+
"SP",
|
|
3526
|
+
"FLA",
|
|
3527
|
+
"MCA",
|
|
3528
|
+
"MOCP",
|
|
3529
|
+
"AMPS",
|
|
3530
|
+
"EAT",
|
|
3531
|
+
"LAT",
|
|
3532
|
+
"EWT",
|
|
3533
|
+
"LWT",
|
|
3534
|
+
"PSI",
|
|
3535
|
+
"FPM",
|
|
3536
|
+
"NECK",
|
|
3537
|
+
"THROW",
|
|
3538
|
+
"MOUNTING",
|
|
3539
|
+
"DAMPER",
|
|
3540
|
+
"AIRFLOW",
|
|
3541
|
+
"LAMP",
|
|
3542
|
+
"LAMPS",
|
|
3543
|
+
"LUMENS",
|
|
3544
|
+
"BALLAST",
|
|
3545
|
+
"DRIVER",
|
|
3546
|
+
"CIRCUIT",
|
|
3547
|
+
"BREAKER",
|
|
3548
|
+
"POLES",
|
|
3549
|
+
"KVA",
|
|
3550
|
+
"AIC",
|
|
3551
|
+
"WIRE",
|
|
3552
|
+
"CONDUIT",
|
|
3553
|
+
"FEEDER",
|
|
3554
|
+
"KVAR",
|
|
3555
|
+
"CW",
|
|
3556
|
+
"HW",
|
|
3557
|
+
"WASTE",
|
|
3558
|
+
"VENT",
|
|
3559
|
+
"TRAP",
|
|
3560
|
+
"DFU",
|
|
3561
|
+
"WSFU",
|
|
3562
|
+
"DRAIN",
|
|
3563
|
+
"SPRINKLER",
|
|
3564
|
+
"ORIFICE"
|
|
3565
|
+
]);
|
|
3566
|
+
var EQUIP_KEY_RE = /^[A-Z]{1,4}(?:-\d{1,3}|\d{1,2})[A-Z]?$/;
|
|
3567
|
+
var TYPE_KEY_RE = /^[A-Z]{1,2}\d{0,2}[A-Z]?$/;
|
|
3409
3568
|
var headerLabel = (s, vocab) => headerLabels(s, vocab)[0] ?? null;
|
|
3410
3569
|
var headerLabels = (s, vocab) => {
|
|
3411
3570
|
const out = [];
|
|
@@ -3623,9 +3782,15 @@ var isNonFinishSchedule = (title) => {
|
|
|
3623
3782
|
const u = norm(title);
|
|
3624
3783
|
return OTHER_FAMILY_RE.test(u) && !/\b(FINISH|MATERIAL)S?\b/.test(u);
|
|
3625
3784
|
};
|
|
3626
|
-
function rowKeyOf(raw, kind, buildings) {
|
|
3785
|
+
function rowKeyOf(raw, kind, buildings, typeKeyed = false) {
|
|
3627
3786
|
const kept = norm(raw).replace(/[^A-Z0-9/-]/g, "");
|
|
3628
3787
|
const key = kept.replace(/\//g, "");
|
|
3788
|
+
if (kind === "equipment") {
|
|
3789
|
+
const parts = kept.split("/").filter(Boolean);
|
|
3790
|
+
const ok2 = (p) => EQUIP_KEY_RE.test(p) || typeKeyed && TYPE_KEY_RE.test(p);
|
|
3791
|
+
if (parts.length > 1 && parts.every(ok2)) return { key: parts.join("/") };
|
|
3792
|
+
return ok2(key) ? { key } : null;
|
|
3793
|
+
}
|
|
3629
3794
|
if (kind === "finish") {
|
|
3630
3795
|
const parts = kept.split("/").filter(Boolean);
|
|
3631
3796
|
if (parts.length > 1 && parts.every((p) => CODE_RE.test(p))) return { key: parts.join("/") };
|
|
@@ -3722,6 +3887,7 @@ function columnStarts(rows, anchors, cfg, x0, x1) {
|
|
|
3722
3887
|
}
|
|
3723
3888
|
function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
|
|
3724
3889
|
const { x0, x1, medGap } = bandLimits(anchors);
|
|
3890
|
+
const typeKeyed = kind === "equipment" && (anchors[0]?.label === "TYPE" || anchors[0]?.label === "FIXTURE");
|
|
3725
3891
|
const cols = columnStarts(rows, anchors, cfg, x0, x1);
|
|
3726
3892
|
const keyTol = cols && cols.cols.length > 1 ? Math.max(8, (cols.cols[1].start - cols.cols[0].start) * 0.5) : 40;
|
|
3727
3893
|
const out = [];
|
|
@@ -3774,11 +3940,20 @@ function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
|
|
|
3774
3940
|
if (t.x >= x0 && t.x <= x1) banded.push(t);
|
|
3775
3941
|
}
|
|
3776
3942
|
if (!banded.length) continue;
|
|
3777
|
-
|
|
3943
|
+
if (kind === "equipment" && out.length) {
|
|
3944
|
+
if (rows[i].some((t) => /SCHEDULE/.test(norm(t.str)) && !EQUIP_KEY_RE.test(norm(t.str).replace(/[^A-Z0-9-]/g, "")))) break;
|
|
3945
|
+
const hh = headerHits(rows[i], EQUIPMENT_HEADERS);
|
|
3946
|
+
if (hh.length >= 3 && hh.some((h) => EQUIPMENT_KEY_HEADERS.includes(h.label))) break;
|
|
3947
|
+
}
|
|
3948
|
+
const keyed = rowKeyOf(banded[0].str, kind, buildings, typeKeyed);
|
|
3778
3949
|
if (!keyed) {
|
|
3779
3950
|
orphans.push({ toks: banded, y: rowY(rows[i]) });
|
|
3780
3951
|
continue;
|
|
3781
3952
|
}
|
|
3953
|
+
if (/\d{3}/.test(keyed.key) && cfg.sheetNumbers?.has(keyed.key.replace(/[^A-Z0-9]/g, ""))) {
|
|
3954
|
+
orphans.push({ toks: banded, y: rowY(rows[i]) });
|
|
3955
|
+
continue;
|
|
3956
|
+
}
|
|
3782
3957
|
if (cols && Math.abs((cols.coord === "left" ? banded[0].x : centerX(banded[0])) - cols.cols[0].start) > keyTol) continue;
|
|
3783
3958
|
if (cfg.keyAlign && Math.abs(centerX(banded[0]) - cfg.keyAlign.x) > cfg.keyAlign.tol) continue;
|
|
3784
3959
|
const row = { key: keyed.key, sheet: sheetKey, cells: {} };
|
|
@@ -3835,11 +4010,35 @@ function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
|
|
|
3835
4010
|
return { out, region };
|
|
3836
4011
|
}
|
|
3837
4012
|
function extractTable(sheet, kind, opts = {}) {
|
|
4013
|
+
const r = extractTableCore(sheet, kind, opts);
|
|
4014
|
+
return r && "table" in r ? r.table : null;
|
|
4015
|
+
}
|
|
4016
|
+
function extractTables(sheet, kind, opts = {}) {
|
|
4017
|
+
const out = [];
|
|
4018
|
+
let spans = sheet.spans;
|
|
4019
|
+
for (let guard = 0; guard < 12 && spans.length; guard++) {
|
|
4020
|
+
const r = extractTableCore({ ...sheet, spans }, kind, opts);
|
|
4021
|
+
if (!r) break;
|
|
4022
|
+
const mask = "table" in r ? r.table.region : r.skip;
|
|
4023
|
+
if ("table" in r) {
|
|
4024
|
+
out.push(r.table);
|
|
4025
|
+
if (r.table.title) mask[1] = Math.min(mask[1], r.table.title.bbox[1]);
|
|
4026
|
+
}
|
|
4027
|
+
const before = spans.length;
|
|
4028
|
+
spans = spans.filter((t) => {
|
|
4029
|
+
const cx = t.x + (t.w || 0) / 2, cy = t.y + (t.h || 0) / 2;
|
|
4030
|
+
return !(cx >= mask[0] - 1 && cx <= mask[2] + 1 && cy >= mask[1] - 1 && cy <= mask[3] + 1);
|
|
4031
|
+
});
|
|
4032
|
+
if (spans.length === before) break;
|
|
4033
|
+
}
|
|
4034
|
+
return out;
|
|
4035
|
+
}
|
|
4036
|
+
function extractTableCore(sheet, kind, opts = {}) {
|
|
3838
4037
|
const horiz = sheet.spans.filter((s) => !isVertical(s));
|
|
3839
4038
|
const vert = sheet.spans.filter(isVertical);
|
|
3840
4039
|
const rows = clusterRows(horiz);
|
|
3841
|
-
const vocab = kind === "room-finish" ? ROOM_HEADERS : FINISH_HEADERS;
|
|
3842
|
-
const required = kind === "room-finish" ? ["FLOOR", "BASE"] : ["CODE", "MARK", "SYMBOL", "TAG"];
|
|
4040
|
+
const vocab = kind === "room-finish" ? ROOM_HEADERS : kind === "equipment" ? EQUIPMENT_HEADERS : FINISH_HEADERS;
|
|
4041
|
+
const required = kind === "room-finish" ? ["FLOOR", "BASE"] : kind === "equipment" ? EQUIPMENT_KEY_HEADERS : ["CODE", "MARK", "SYMBOL", "TAG"];
|
|
3843
4042
|
const minHits = kind === "room-finish" ? 4 : 3;
|
|
3844
4043
|
let anchors;
|
|
3845
4044
|
let headerSpans;
|
|
@@ -3864,16 +4063,24 @@ function extractTable(sheet, kind, opts = {}) {
|
|
|
3864
4063
|
titleFrom = rows.findIndex((r) => rowY(r) >= rot.top) - 1;
|
|
3865
4064
|
if (titleFrom < -1) titleFrom = rows.length - 1;
|
|
3866
4065
|
}
|
|
4066
|
+
if (kind === "equipment" && !anchors.some((a) => EQUIPMENT_ONLY.has(a.label))) {
|
|
4067
|
+
let hb = null;
|
|
4068
|
+
for (const t of headerSpans) hb = hb ? merge(hb, bboxOf(t)) : bboxOf(t);
|
|
4069
|
+
return hb ? { skip: hb } : null;
|
|
4070
|
+
}
|
|
3867
4071
|
const hdrBand = bandLimits(anchors);
|
|
3868
4072
|
let region = null;
|
|
3869
4073
|
for (const t of headerSpans) {
|
|
3870
4074
|
if (centerX(t) < hdrBand.x0 || centerX(t) > hdrBand.x1) continue;
|
|
3871
4075
|
region = region ? merge(region, bboxOf(t)) : bboxOf(t);
|
|
3872
4076
|
}
|
|
3873
|
-
const banded = bandDataRows(rows, anchors, kind, sheet.key, opts.buildings, { fromIdx: dataFrom, belowY: dataBelowY, deltas: opts.deltas });
|
|
4077
|
+
const banded = bandDataRows(rows, anchors, kind, sheet.key, opts.buildings, { fromIdx: dataFrom, belowY: dataBelowY, deltas: opts.deltas, sheetNumbers: opts.sheetNumbers });
|
|
3874
4078
|
const out = banded.out;
|
|
3875
4079
|
if (banded.region) region = region ? merge(region, banded.region) : banded.region;
|
|
3876
|
-
if (!out.length)
|
|
4080
|
+
if (!out.length) {
|
|
4081
|
+
if (kind === "equipment" && region) return { skip: region };
|
|
4082
|
+
return null;
|
|
4083
|
+
}
|
|
3877
4084
|
const { x0, x1 } = bandLimits(anchors);
|
|
3878
4085
|
let title = null;
|
|
3879
4086
|
for (let i = titleFrom; i >= 0 && i >= titleFrom - 5 && !title; i--) {
|
|
@@ -3882,7 +4089,7 @@ function extractTable(sheet, kind, opts = {}) {
|
|
|
3882
4089
|
}
|
|
3883
4090
|
const table = { kind, sheet: sheet.key, title, headers: anchors.map((a) => a.label), rows: out, region, anchors };
|
|
3884
4091
|
if (rotated) table.rotated_headers = true;
|
|
3885
|
-
return table;
|
|
4092
|
+
return { table };
|
|
3886
4093
|
}
|
|
3887
4094
|
var CONT_TAIL_RE = /[\s\-–—:.,(]*(?:CONTINUATION|CONTINUED|CONT['’]?D?)[\s.)]*$/;
|
|
3888
4095
|
var isContinuationTitle = (text) => {
|
|
@@ -4028,14 +4235,25 @@ function buildSheetGraph(sheets) {
|
|
|
4028
4235
|
const ctx = sheetBuilding(s);
|
|
4029
4236
|
if (ctx) ctxBySheet.set(s.key, ctx.building);
|
|
4030
4237
|
}
|
|
4238
|
+
const sheetNumberSet = /* @__PURE__ */ new Set();
|
|
4239
|
+
for (const s of sheets) {
|
|
4240
|
+
const n = norm(s.sheet_number || "").replace(/[^A-Z0-9]/g, "");
|
|
4241
|
+
if (n) sheetNumberSet.add(n);
|
|
4242
|
+
}
|
|
4031
4243
|
const roles = /* @__PURE__ */ new Map();
|
|
4032
4244
|
const fragments = [];
|
|
4033
4245
|
const fragmentKinds = /* @__PURE__ */ new Map();
|
|
4034
4246
|
for (const s of withText) {
|
|
4035
4247
|
roles.set(s.key, classifySheetRole(s));
|
|
4248
|
+
const sheetFrags = [];
|
|
4249
|
+
const found2 = [];
|
|
4036
4250
|
for (const kind of ["room-finish", "finish"]) {
|
|
4037
|
-
const t = extractTable(s, kind, { buildings, deltas: deltasBySheet.get(s.key) });
|
|
4038
|
-
if (
|
|
4251
|
+
const t = extractTable(s, kind, { buildings, deltas: deltasBySheet.get(s.key), sheetNumbers: sheetNumberSet });
|
|
4252
|
+
if (t) found2.push(t);
|
|
4253
|
+
}
|
|
4254
|
+
found2.push(...extractTables(s, "equipment", { buildings, deltas: deltasBySheet.get(s.key), sheetNumbers: sheetNumberSet }));
|
|
4255
|
+
for (const t of found2) {
|
|
4256
|
+
const kind = t.kind;
|
|
4039
4257
|
if (kind === "finish" && t.title && isNonFinishSchedule(t.title.text)) {
|
|
4040
4258
|
notes.push(`${s.key}: "${t.title.text}" names another schedule family, not a finish/material schedule \u2014 its ${t.rows.length} rows are NOT indexed as finish definitions`);
|
|
4041
4259
|
continue;
|
|
@@ -4044,9 +4262,18 @@ function buildSheetGraph(sheets) {
|
|
|
4044
4262
|
const b = titleB.length === 1 ? titleB[0] : ctxBySheet.get(s.key);
|
|
4045
4263
|
if (b) t.building = b;
|
|
4046
4264
|
for (const r of t.rows) if (r.building) buildings.add(r.building);
|
|
4265
|
+
sheetFrags.push(t);
|
|
4266
|
+
}
|
|
4267
|
+
const equip = sheetFrags.filter((t) => t.kind === "equipment");
|
|
4268
|
+
for (const t of sheetFrags) {
|
|
4269
|
+
const shadow = t.kind === "finish" ? equip.find((e) => overlapFrac(t.region, e.region) >= 0.5) : void 0;
|
|
4270
|
+
if (shadow) {
|
|
4271
|
+
notes.push(`${s.key}: "${t.title?.text || "untitled finish table"}" overlaps the equipment schedule "${shadow.title?.text || "untitled"}" \u2014 indexed once, as equipment, not as a finish definition`);
|
|
4272
|
+
continue;
|
|
4273
|
+
}
|
|
4047
4274
|
fragments.push(t);
|
|
4048
4275
|
if (!fragmentKinds.has(s.key)) fragmentKinds.set(s.key, /* @__PURE__ */ new Set());
|
|
4049
|
-
fragmentKinds.get(s.key).add(kind);
|
|
4276
|
+
fragmentKinds.get(s.key).add(t.kind);
|
|
4050
4277
|
}
|
|
4051
4278
|
}
|
|
4052
4279
|
const tables = [];
|
|
@@ -6218,7 +6445,8 @@ function scopeCollisions(shapes, conditions, frameFor, opts = {}) {
|
|
|
6218
6445
|
continue;
|
|
6219
6446
|
}
|
|
6220
6447
|
const shared = inter ? inter.getArea() : 0;
|
|
6221
|
-
|
|
6448
|
+
const noiseArea = 64 * Number.EPSILON * Math.max(A.area, B.area);
|
|
6449
|
+
if (!(shared > noiseArea)) continue;
|
|
6222
6450
|
const smaller = Math.min(A.area, B.area);
|
|
6223
6451
|
const fraction = shared / smaller;
|
|
6224
6452
|
if (fraction < minFraction) continue;
|
|
@@ -6237,6 +6465,7 @@ function scopeCollisions(shapes, conditions, frameFor, opts = {}) {
|
|
|
6237
6465
|
a: side(A),
|
|
6238
6466
|
b: side(B),
|
|
6239
6467
|
shared_sf: round23(shared * sfPer),
|
|
6468
|
+
...round23(shared * sfPer) === 0 ? { note: "Positive overlap below 0.01 SF; the displayed area rounds to zero. Inspect the boundary before changing geometry." } : {},
|
|
6240
6469
|
fraction_of_smaller: round23(fraction * 100) / 100,
|
|
6241
6470
|
iou: round23(iou * 100) / 100,
|
|
6242
6471
|
same_condition: same,
|
|
@@ -9106,6 +9335,10 @@ var Session = class _Session {
|
|
|
9106
9335
|
* curved run (whose verts are control points, not the line) refuses and says
|
|
9107
9336
|
* what to do instead. */
|
|
9108
9337
|
cutRunOut(parent, verts) {
|
|
9338
|
+
const derived = parent.origin?.derived;
|
|
9339
|
+
if (derived && "openings_lf" in derived && derived.openings_lf > 0) {
|
|
9340
|
+
throw new UserError("This derived base already has numeric openings with no stored locations. Use measure_line for the installed runs; clipping this gross perimeter would lose the existing allowance.");
|
|
9341
|
+
}
|
|
9109
9342
|
if (parent.origin?.reviewed === true) {
|
|
9110
9343
|
throw new UserError(`Shape ${parent.id} was affirmed by a human \u2014 reviewed work is ink, and clipping it would mutate what the estimator signed.`);
|
|
9111
9344
|
}
|
|
@@ -9319,7 +9552,7 @@ var Session = class _Session {
|
|
|
9319
9552
|
const table = tb.title?.text || `${tb.kind} schedule`;
|
|
9320
9553
|
for (const row of tb.rows) {
|
|
9321
9554
|
for (const part of canon(row.key).split("/").filter(Boolean)) {
|
|
9322
|
-
if (!rowCite.has(part)) rowCite.set(part, { sheet: tb.sheet, key: row.key, table });
|
|
9555
|
+
if (!rowCite.has(part)) rowCite.set(part, { sheet: tb.sheet, key: row.key, table, kind: tb.kind });
|
|
9323
9556
|
}
|
|
9324
9557
|
}
|
|
9325
9558
|
}
|
|
@@ -9378,7 +9611,7 @@ var Session = class _Session {
|
|
|
9378
9611
|
}
|
|
9379
9612
|
const paired = values.find((v) => Math.abs((v.x0 + v.x1) / 2 - cx) <= Math.max(sp.x1 - sp.x0, 1.5 * h) && v.y0 >= sp.y1 - 0.4 * h && v.y0 <= sp.y1 + 2.4 * h);
|
|
9380
9613
|
if (paired) {
|
|
9381
|
-
rec.counted.push({ at: [round1(cx), round1(cy)], value: paired.str.trim(), sheet: sh.key });
|
|
9614
|
+
rec.counted.push({ at: [round1(cx), round1(cy)], value: paired.str.trim(), sheet: sh.key, by: "value" });
|
|
9382
9615
|
counts[m] = (counts[m] || 0) + 1;
|
|
9383
9616
|
} else {
|
|
9384
9617
|
if (segs === null) segs = (await this.ensureGeometry(sh)).segs;
|
|
@@ -9388,6 +9621,12 @@ var Session = class _Session {
|
|
|
9388
9621
|
for (let i = 0; i + 3 < segs.length && n < 3; i += 4) {
|
|
9389
9622
|
if (segs[i] >= bx0 && segs[i] <= bx1 && segs[i + 1] >= by0 && segs[i + 1] <= by1 && segs[i + 2] >= bx0 && segs[i + 2] <= bx1 && segs[i + 3] >= by0 && segs[i + 3] <= by1) n++;
|
|
9390
9623
|
}
|
|
9624
|
+
const equipmentRow = rowCite.get(m)?.kind === "equipment";
|
|
9625
|
+
if (equipmentRow && _Session.lineworkNear(segs, [sp.x0, sp.y0, sp.x1, sp.y1], pad) >= 3) {
|
|
9626
|
+
rec.counted.push({ at: [round1(cx), round1(cy)], sheet: sh.key, by: "label" });
|
|
9627
|
+
counts[m] = (counts[m] || 0) + 1;
|
|
9628
|
+
continue;
|
|
9629
|
+
}
|
|
9391
9630
|
rec.withheld.push({
|
|
9392
9631
|
at: [round1(cx), round1(cy)],
|
|
9393
9632
|
sheet: sh.key,
|
|
@@ -9427,10 +9666,12 @@ var Session = class _Session {
|
|
|
9427
9666
|
const cite = rowCite.get(m);
|
|
9428
9667
|
const c = cap(rec.counted, 150);
|
|
9429
9668
|
const w = cap(rec.withheld, 60);
|
|
9669
|
+
const byLabel = rec.counted.filter((h) => h.by === "label").length;
|
|
9430
9670
|
return {
|
|
9431
9671
|
mark: m,
|
|
9432
9672
|
count: rec.counted.length,
|
|
9433
|
-
...
|
|
9673
|
+
...byLabel ? { counted_by_label: byLabel } : {},
|
|
9674
|
+
...cite ? { row: { sheet: cite.sheet, key: cite.key, table: cite.table } } : { unscheduled: true },
|
|
9434
9675
|
occurrences: c.list,
|
|
9435
9676
|
...c.elided ? { occurrences_elided: c.elided } : {},
|
|
9436
9677
|
withheld: w.list,
|
|
@@ -9459,6 +9700,18 @@ var Session = class _Session {
|
|
|
9459
9700
|
withheld: named.slice(off + matches.length)
|
|
9460
9701
|
};
|
|
9461
9702
|
}
|
|
9703
|
+
/** Segments whose extent touches the box padded by `pad` — a leader, a device
|
|
9704
|
+
* outline crossing it, a tick — capped at 3 (the callers only ask "≥ 3?"). */
|
|
9705
|
+
static lineworkNear(segs, bbox, pad) {
|
|
9706
|
+
const bx0 = bbox[0] - pad, by0 = bbox[1] - pad, bx1 = bbox[2] + pad, by1 = bbox[3] + pad;
|
|
9707
|
+
let n = 0;
|
|
9708
|
+
for (let i = 0; i + 3 < segs.length && n < 3; i += 4) {
|
|
9709
|
+
const sx0 = Math.min(segs[i], segs[i + 2]), sx1 = Math.max(segs[i], segs[i + 2]);
|
|
9710
|
+
const sy0 = Math.min(segs[i + 1], segs[i + 3]), sy1 = Math.max(segs[i + 1], segs[i + 3]);
|
|
9711
|
+
if (sx1 >= bx0 && sx0 <= bx1 && sy1 >= by0 && sy0 <= by1) n++;
|
|
9712
|
+
}
|
|
9713
|
+
return n;
|
|
9714
|
+
}
|
|
9462
9715
|
static labelFields(l) {
|
|
9463
9716
|
return l ? { label: l.label, label_via: l.via } : {};
|
|
9464
9717
|
}
|
|
@@ -9863,11 +10116,22 @@ var Session = class _Session {
|
|
|
9863
10116
|
return sh.spans.filter((sp) => sp.str.trim().toUpperCase() === key).map((sp) => ({ cx: (sp.x0 + sp.x1) / 2, cy: (sp.y0 + sp.y1) / 2, h: Math.max(sp.y1 - sp.y0, 6), bbox: [sp.x0, sp.y0, sp.x1, sp.y1] })).sort((a, b) => a.cy - b.cy || a.cx - b.cx);
|
|
9864
10117
|
};
|
|
9865
10118
|
const occBySheet = planSheets.map((sh) => ({ sh, occ: occOf(sh, t) }));
|
|
9866
|
-
const
|
|
10119
|
+
const tableRegionsOf = (key) => graph.tables.filter((x) => x.sheet === key).map((x) => x.region);
|
|
10120
|
+
const amidLinework = (segs, o) => _Session.lineworkNear(segs, o.bbox, 2.5 * o.h) >= 3;
|
|
10121
|
+
const inTable = (key, o) => tableRegionsOf(key).some((r2) => o.cx >= r2[0] && o.cx <= r2[2] && o.cy >= r2[1] && o.cy <= r2[3]);
|
|
10122
|
+
const drawnBySheet = [];
|
|
10123
|
+
for (const { sh, occ } of occBySheet) {
|
|
10124
|
+
const geo = await this.ensureGeometry(sh);
|
|
10125
|
+
const drawn = geo.segs.length ? occ.filter((o) => !inTable(sh.key, o) && amidLinework(geo.segs, o)) : [];
|
|
10126
|
+
drawnBySheet.push({ sh, occ: drawn });
|
|
10127
|
+
}
|
|
10128
|
+
const totalOcc = drawnBySheet.reduce((n, e) => n + e.occ.length, 0);
|
|
10129
|
+
const mentions = occBySheet.reduce((n, e) => n + e.occ.length, 0) - totalOcc;
|
|
9867
10130
|
if (!totalOcc) {
|
|
10131
|
+
if (mentions) throw new UserError(`Schedule row "${t}" (${table} on ${tb.sheet}) is mentioned ${mentions}\xD7 on plan sheets but never drawn \u2014 every occurrence is bare text with no linework near it (a note), so there is no instance to count. If the device is drawn without its tag, marquee one instance with symbol_sweep {scope: "set"}.`);
|
|
9868
10132
|
throw new UserError(`Schedule row "${t}" (${table} on ${tb.sheet}) cannot be geometrically anchored \u2014 its tag is not drawn on any plan sheet, and a fingerprint is never guessed from text alone. If the marker is drawn untagged, marquee one instance with symbol_sweep {scope: "set"}.`);
|
|
9869
10133
|
}
|
|
9870
|
-
const withOcc =
|
|
10134
|
+
const withOcc = drawnBySheet.filter((e) => e.occ.length > 0).sort((a, b) => b.occ.length - a.occ.length || a.sh.ord - b.sh.ord);
|
|
9871
10135
|
const anchorSheet = withOcc[0].sh;
|
|
9872
10136
|
const anchor = withOcc[0].occ[0];
|
|
9873
10137
|
const anchorGeo = await this.ensureGeometry(anchorSheet);
|
|
@@ -9921,9 +10185,8 @@ var Session = class _Session {
|
|
|
9921
10185
|
break;
|
|
9922
10186
|
}
|
|
9923
10187
|
}
|
|
9924
|
-
|
|
9925
|
-
|
|
9926
|
-
}
|
|
10188
|
+
const labelOnly = !fp || !anchorRect;
|
|
10189
|
+
const labelReason = labelOnly ? corro ? `the linework around its drawn tag on ${anchorSheet.key} does not recur at the tag's other occurrences \u2014 no repeatable marker geometry` : `no fingerprintable marker linework sits around its drawn tag on ${anchorSheet.key}` : null;
|
|
9927
10190
|
const radiusFor = (sc) => (sc ? sc.footprint_px : fp.footprint) / 2 + anchor.h;
|
|
9928
10191
|
const byPos = (a, b) => a.at[1] - b.at[1] || a.at[0] - b.at[0];
|
|
9929
10192
|
const perSheet = [];
|
|
@@ -9933,6 +10196,17 @@ var Session = class _Session {
|
|
|
9933
10196
|
skipped.push({ sheet: sh.key, role: "plan", reason: "no vector linework (likely a scan) \u2014 symbol matching reads the drawn segments" });
|
|
9934
10197
|
continue;
|
|
9935
10198
|
}
|
|
10199
|
+
if (labelOnly) {
|
|
10200
|
+
const label_only2 = [];
|
|
10201
|
+
const text_only2 = [];
|
|
10202
|
+
for (const o of occ) {
|
|
10203
|
+
if (inTable(sh.key, o)) continue;
|
|
10204
|
+
if (amidLinework(g2.segs, o)) label_only2.push({ at: [round1(o.cx), round1(o.cy)], tag_at: o.bbox });
|
|
10205
|
+
else text_only2.push({ at: [round1(o.cx), round1(o.cy)] });
|
|
10206
|
+
}
|
|
10207
|
+
perSheet.push({ state: sh, matches: [], withheld: [], excluded: [], text_only: text_only2, label_only: label_only2, candidates: { considered: 0, dropped: 0 }, complete: true, elapsed_ms: 0, scale: this.sweepRatio(anchorSheet, sh) });
|
|
10208
|
+
continue;
|
|
10209
|
+
}
|
|
9936
10210
|
const ratio = this.sweepRatio(anchorSheet, sh);
|
|
9937
10211
|
const t0 = process.hrtime.bigint();
|
|
9938
10212
|
let res;
|
|
@@ -9950,6 +10224,7 @@ var Session = class _Session {
|
|
|
9950
10224
|
const excluded = [];
|
|
9951
10225
|
const withheld = [];
|
|
9952
10226
|
const matchedOcc = /* @__PURE__ */ new Set();
|
|
10227
|
+
let duplicates = 0;
|
|
9953
10228
|
for (const m of res.matches) {
|
|
9954
10229
|
let oi = -1;
|
|
9955
10230
|
for (let k = 0; k < occ.length; k++) {
|
|
@@ -9958,6 +10233,10 @@ var Session = class _Session {
|
|
|
9958
10233
|
break;
|
|
9959
10234
|
}
|
|
9960
10235
|
}
|
|
10236
|
+
if (oi >= 0 && matchedOcc.has(oi)) {
|
|
10237
|
+
duplicates++;
|
|
10238
|
+
continue;
|
|
10239
|
+
}
|
|
9961
10240
|
if (oi >= 0) {
|
|
9962
10241
|
matchedOcc.add(oi);
|
|
9963
10242
|
matches.push({ ...m, tag_at: occ[oi].bbox });
|
|
@@ -9977,14 +10256,27 @@ var Session = class _Session {
|
|
|
9977
10256
|
matches.sort(byPos);
|
|
9978
10257
|
excluded.sort(byPos);
|
|
9979
10258
|
withheld.sort(byPos);
|
|
9980
|
-
const
|
|
9981
|
-
|
|
9982
|
-
|
|
9983
|
-
|
|
10259
|
+
const unmatched = occ.filter((o, k) => !matchedOcc.has(k) && !res.withheld.some((w) => Math.hypot(w.at[0] - o.cx, w.at[1] - o.cy) <= R) && !inTable(sh.key, o));
|
|
10260
|
+
const label_only = unmatched.filter((o) => amidLinework(g2.segs, o)).map((o) => ({ at: [round1(o.cx), round1(o.cy)], tag_at: o.bbox }));
|
|
10261
|
+
const text_only = unmatched.filter((o) => !amidLinework(g2.segs, o)).map((o) => ({ at: [round1(o.cx), round1(o.cy)] }));
|
|
10262
|
+
if (duplicates) skipped.push({ sheet: sh.key, role: "plan", reason: `${duplicates} extra fingerprint hit(s) around an already-counted tag were folded into their instance \u2014 one drawn tag is one unit` });
|
|
10263
|
+
perSheet.push({ state: sh, matches, withheld, excluded, text_only, label_only, candidates: res.candidates, complete: res.complete, elapsed_ms, scale: ratio, ...res.scaled ? { scaled: res.scaled } : {} });
|
|
10264
|
+
}
|
|
10265
|
+
const foundByGeometry = perSheet.reduce((n, p) => n + p.matches.length, 0);
|
|
10266
|
+
const foundByLabel = perSheet.reduce((n, p) => n + p.label_only.length, 0);
|
|
10267
|
+
const found = foundByGeometry + foundByLabel;
|
|
9984
10268
|
let committed;
|
|
9985
10269
|
if (opts.commit && found) {
|
|
9986
10270
|
const ids = [];
|
|
9987
10271
|
for (const ps of perSheet) {
|
|
10272
|
+
for (const l of ps.label_only) {
|
|
10273
|
+
ids.push(this.commit(ps.state, t, "count", [l.at], { count: 1 }, {
|
|
10274
|
+
method: "manual",
|
|
10275
|
+
actor: "agent",
|
|
10276
|
+
reviewed: false,
|
|
10277
|
+
assignment: { source: "schedule", schedule_sheet: tb.sheet }
|
|
10278
|
+
}).id);
|
|
10279
|
+
}
|
|
9988
10280
|
for (const m of ps.matches) {
|
|
9989
10281
|
ids.push(this.commit(ps.state, t, "count", [m.at], { count: 1 }, {
|
|
9990
10282
|
method: "symbol_sweep",
|
|
@@ -10010,7 +10302,9 @@ var Session = class _Session {
|
|
|
10010
10302
|
const firstCell = r.cells[Object.keys(r.cells)[0]];
|
|
10011
10303
|
const capped = perSheet.filter((p) => p.candidates.dropped > 0);
|
|
10012
10304
|
const notes = [];
|
|
10013
|
-
if (
|
|
10305
|
+
if (labelOnly) notes.push(`Counted BY LABEL: ${labelReason} \u2014 so every drawn "${t}" tag amid linework on a plan sheet counts as one instance (${foundByLabel}), and a bare mention counts for nothing. Look at each before pricing.`);
|
|
10306
|
+
else if (foundByLabel) notes.push(`${foundByLabel} of ${found} counted BY LABEL: the tag is drawn there amid linework the marker fingerprint did not reach (a variant of the device \u2014 a different length or size \u2014 or a rotated placement outside the sweep's reach).`);
|
|
10307
|
+
if (!corroborated && !labelOnly) notes.push(`The tag "${t}" is drawn ${totalOcc === 1 ? "exactly once" : "too sparsely to cross-check"} \u2014 the fingerprint could not corroborate at a second occurrence; audit the matches with view_sheet before trusting the count.`);
|
|
10014
10308
|
if (opts.commit && !found) notes.push("commit requested but nothing cleared the bar \u2014 no shapes were committed.");
|
|
10015
10309
|
const rowRescaled = perSheet.filter((p) => p.scaled);
|
|
10016
10310
|
if (rowRescaled.length) {
|
|
@@ -10029,7 +10323,7 @@ var Session = class _Session {
|
|
|
10029
10323
|
cells,
|
|
10030
10324
|
citation: { sheet: tb.sheet, text: `${table} row ${t}`, bbox: _Session.wireBox(firstCell?.bbox || tb.region) }
|
|
10031
10325
|
},
|
|
10032
|
-
anchor: {
|
|
10326
|
+
anchor: labelOnly ? null : {
|
|
10033
10327
|
sheet: anchorSheet.key,
|
|
10034
10328
|
at: [round1(anchor.cx), round1(anchor.cy)],
|
|
10035
10329
|
rect: [round1(anchorRect[0][0]), round1(anchorRect[0][1]), round1(anchorRect[1][0]), round1(anchorRect[1][1])],
|
|
@@ -10039,6 +10333,9 @@ var Session = class _Session {
|
|
|
10039
10333
|
occurrences: totalOcc
|
|
10040
10334
|
},
|
|
10041
10335
|
found,
|
|
10336
|
+
found_by_geometry: foundByGeometry,
|
|
10337
|
+
found_by_label: foundByLabel,
|
|
10338
|
+
counted_by: foundByLabel === 0 ? "geometry" : foundByGeometry === 0 ? "label" : "mixed",
|
|
10042
10339
|
sheets: perSheet.map((p) => ({
|
|
10043
10340
|
sheet: p.state.key,
|
|
10044
10341
|
found: p.matches.length,
|
|
@@ -10046,6 +10343,7 @@ var Session = class _Session {
|
|
|
10046
10343
|
withheld: p.withheld.map((w) => ({ at: [round1(w.at[0]), round1(w.at[1])], score: w.score, rotation: w.rotation, mirrored: w.mirrored, reason: w.reason })),
|
|
10047
10344
|
excluded: p.excluded.map((e) => ({ at: [round1(e.at[0]), round1(e.at[1])], tag: e.tag })),
|
|
10048
10345
|
text_only: p.text_only,
|
|
10346
|
+
label_only: p.label_only.map((l) => ({ at: l.at, tag_at: _Session.wireBox(l.tag_at) })),
|
|
10049
10347
|
candidates: p.candidates,
|
|
10050
10348
|
complete: p.complete,
|
|
10051
10349
|
elapsed_ms: p.elapsed_ms,
|
|
@@ -10581,6 +10879,10 @@ var Session = class _Session {
|
|
|
10581
10879
|
const i = this.shapes.findIndex((x) => x.id === e.before.id);
|
|
10582
10880
|
if (i >= 0) this.shapes[i] = e.before;
|
|
10583
10881
|
undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: i >= 0 ? 1 : 0 });
|
|
10882
|
+
} else if (e.op === "annotation_text") {
|
|
10883
|
+
const m = this.markups.find((x) => x.id === e.id);
|
|
10884
|
+
if (m) m.text = e.before;
|
|
10885
|
+
undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 0 });
|
|
10584
10886
|
} else if (e.op === "materials") {
|
|
10585
10887
|
const put = (condition_id, before, dropped) => {
|
|
10586
10888
|
const cc = this.conditions.find((x) => x.id === condition_id);
|
|
@@ -10831,6 +11133,17 @@ var Session = class _Session {
|
|
|
10831
11133
|
verdict_count: seals.length
|
|
10832
11134
|
};
|
|
10833
11135
|
}
|
|
11136
|
+
/** Change only the note text. Verdicts are a separate family and RFI
|
|
11137
|
+
* context must stay attached to the question the estimator is reviewing. */
|
|
11138
|
+
editAnnotation(id, text) {
|
|
11139
|
+
const m = this.markups.find((x) => x.id === id);
|
|
11140
|
+
if (!m) throw new UserError(`No annotation ${JSON.stringify(id)} \u2014 call list_annotations for annotation ids; verdicts cannot be edited here.`);
|
|
11141
|
+
if (m.rfi_id) throw new UserError("This annotation is linked to an RFI. Review its context in the browser RFI register; edit_annotation cannot rewrite it.");
|
|
11142
|
+
if (m.text === text) throw new UserError("The annotation already has that text \u2014 nothing changed.");
|
|
11143
|
+
this.record({ op: "annotation_text", tool: "edit_annotation", id, before: m.text });
|
|
11144
|
+
m.text = text;
|
|
11145
|
+
return { id, text, note: "Text updated; geometry, dimensions, links and review records are unchanged. undo_last restores the previous text." };
|
|
11146
|
+
}
|
|
10834
11147
|
/** Attach an existing annotation to a condition, or detach it with "". The
|
|
10835
11148
|
* canvas's Attach/Detach, reachable by an agent. */
|
|
10836
11149
|
linkAnnotation(id, condition) {
|
|
@@ -11344,7 +11657,7 @@ var Session = class _Session {
|
|
|
11344
11657
|
const g = await this.ensureGraph();
|
|
11345
11658
|
if (!g.available) throw new UserError("This set has no text layer (a scan) \u2014 the sheet graph is unavailable.");
|
|
11346
11659
|
const k = (kind || "").toLowerCase();
|
|
11347
|
-
const want = /room/.test(k) ? "room-finish" : /finish|material|product|code|mark/.test(k) ? "finish" : k;
|
|
11660
|
+
const want = /room/.test(k) ? "room-finish" : /equip|mechanical|mep|hvac|plumb|electr|fan|pump|heater|unit|valve|diffuser|grille|fixture|device/.test(k) ? "equipment" : /finish|material|product|code|mark/.test(k) ? "finish" : k;
|
|
11348
11661
|
const hits = g.tables.filter((t) => t.kind === want);
|
|
11349
11662
|
if (!hits.length) {
|
|
11350
11663
|
const found = g.tables.map((t) => `${t.kind} on ${t.sheet}`).join(" | ");
|
|
@@ -11682,7 +11995,8 @@ var scopePairRow = z.object({
|
|
|
11682
11995
|
sheet_id: z.string(),
|
|
11683
11996
|
a: scopeSide,
|
|
11684
11997
|
b: scopeSide,
|
|
11685
|
-
shared_sf: z.number().describe("
|
|
11998
|
+
shared_sf: z.number().describe("Polygon intersection through the sheet's scale, rounded to two decimals"),
|
|
11999
|
+
note: z.string().optional().describe("Explains a positive overlap whose SF rounds to zero"),
|
|
11686
12000
|
fraction_of_smaller: z.number().describe("shared \xF7 the smaller shape's area (1 = the smaller sits entirely inside the other)"),
|
|
11687
12001
|
iou: z.number().describe("Symmetric intersection-over-union \u2014 \u2265 0.5 is the room eval's own 'same space claimed twice' bar"),
|
|
11688
12002
|
same_condition: z.boolean().describe("true = a double trace on ONE condition (its own list), false = two conditions claiming one floor"),
|
|
@@ -11803,7 +12117,12 @@ var exportTakeoffOutput = {
|
|
|
11803
12117
|
schema: z.string(),
|
|
11804
12118
|
project_name: z.string(),
|
|
11805
12119
|
units: z.string(),
|
|
11806
|
-
sheets: z.array(z.object({
|
|
12120
|
+
sheets: z.array(z.object({
|
|
12121
|
+
sheet_id: z.string(),
|
|
12122
|
+
units_per_px: z.number(),
|
|
12123
|
+
scale_source: z.string().optional().describe("How the exported calibration was established"),
|
|
12124
|
+
scale_confirmed: z.boolean().optional().describe("False for agent-set calibration until a human confirms it")
|
|
12125
|
+
})),
|
|
11807
12126
|
conditions: z.array(z.object({
|
|
11808
12127
|
id: z.string(),
|
|
11809
12128
|
finish_tag: z.string(),
|
|
@@ -11825,6 +12144,7 @@ var exportTakeoffOutput = {
|
|
|
11825
12144
|
}).passthrough()),
|
|
11826
12145
|
markups: z.array(z.unknown()),
|
|
11827
12146
|
approvals: z.array(z.unknown()).optional().describe("Approval-family records (#176) \u2014 the estimator's APPROVED seals and the agent's verdict marks {id, actor, ts, sheet_id, at:[nx,ny], shape_id?, text?}. Present only when any exist (the canvas payload's own convention), so a verdict-free export stays byte-identical"),
|
|
12147
|
+
rfis: z.array(z.unknown()).optional().describe("Live RFI records; withdrawn tombstones are omitted"),
|
|
11828
12148
|
sheet_group: z.array(z.unknown()),
|
|
11829
12149
|
last_group: z.array(z.unknown()),
|
|
11830
12150
|
sheet_tabs: z.array(z.unknown()),
|
|
@@ -11969,6 +12289,7 @@ var undoLastOutput = {
|
|
|
11969
12289
|
"commit",
|
|
11970
12290
|
"scale",
|
|
11971
12291
|
"edit",
|
|
12292
|
+
"annotation_text",
|
|
11972
12293
|
"delete",
|
|
11973
12294
|
"materials",
|
|
11974
12295
|
"condition",
|
|
@@ -12222,15 +12543,19 @@ var sweepScheduleRowOutput = {
|
|
|
12222
12543
|
length_px: z.number(),
|
|
12223
12544
|
corroborated: z.boolean().describe("true = the fingerprint recurred at a second tag occurrence before being trusted; false = the tag is drawn too sparsely to cross-check (see note)"),
|
|
12224
12545
|
occurrences: z.number().int().describe("Drawn occurrences of the tag across all plan sheets")
|
|
12225
|
-
}),
|
|
12226
|
-
found: z.number().int().describe("
|
|
12546
|
+
}).nullable().describe("null when the sweep counted BY LABEL: no repeatable marker geometry sits around the drawn tag (the equipment convention \u2014 a device drawn to its own size, tagged by a leader), so nothing was fingerprinted"),
|
|
12547
|
+
found: z.number().int().describe("Instances counted across every plan sheet: geometry matches carrying the row's own tag PLUS drawn tags counted by label \u2014 see counted_by"),
|
|
12548
|
+
found_by_geometry: z.number().int().describe("\u2026of which marker-fingerprint matches corroborated by the tag"),
|
|
12549
|
+
found_by_label: z.number().int().describe("\u2026of which drawn tags amid linework the fingerprint did not (or could not) reach \u2014 an installed instance by the equipment convention, disclosed as label-counted"),
|
|
12550
|
+
counted_by: z.enum(["geometry", "label", "mixed"]).describe('How the count was made \u2014 "label" means no marker geometry was matched at all; look at each label_only placement before pricing'),
|
|
12227
12551
|
sheets: z.array(z.object({
|
|
12228
12552
|
sheet: z.string(),
|
|
12229
12553
|
found: z.number().int(),
|
|
12230
12554
|
matches: z.array(z.object({ ...rowSweepPlacement, tag_at: wireBox.describe("The corroborating tag text's bbox \u2014 the evidence that this marker is THIS row's") })),
|
|
12231
12555
|
withheld: z.array(z.object({ ...rowSweepPlacement, reason: z.string() })).describe("Questions, never counts: markers matching the geometry but carrying no tag (an unlabeled instance or a shared bubble shape), and near-miss scores in the [0.75, 0.92) band"),
|
|
12232
12556
|
excluded: z.array(z.object({ at: z.tuple([z.number(), z.number()]), tag: z.string() })).describe("Markers matching the geometry but labeled with a SIBLING row's tag \u2014 the bubble shape is shared across marks, so these belong to that row, not this one"),
|
|
12233
|
-
text_only: z.array(z.object({ at: z.tuple([z.number(), z.number()]) })).describe("The tag drawn with
|
|
12557
|
+
text_only: z.array(z.object({ at: z.tuple([z.number(), z.number()]) })).describe("The tag drawn with no marker match AND no linework near it \u2014 a note mentioning the mark; a question, never a count"),
|
|
12558
|
+
label_only: z.array(z.object({ at: z.tuple([z.number(), z.number()]), tag_at: wireBox })).describe("Instances counted BY LABEL: the row's tag drawn amid linework that the marker fingerprint did not match (or none was anchored) \u2014 the equipment convention; each is counted and each deserves a look"),
|
|
12234
12559
|
candidates: z.object({ considered: z.number().int(), dropped: z.number().int() }),
|
|
12235
12560
|
complete: z.boolean().describe("True when every proposed placement on this sheet was scored \u2014 false means this sheet's count is a FLOOR, not a total (#261)"),
|
|
12236
12561
|
elapsed_ms: z.number().describe("Wall-clock for this sheet's sweep"),
|
|
@@ -12316,6 +12641,7 @@ var annotationRow = z.object({
|
|
|
12316
12641
|
r: z.number().optional().describe("Bubble radius (image px)"),
|
|
12317
12642
|
length_lf: z.number().optional().describe("Dimension only: the measured length in real feet, snapshotted at annotate time from the sheet scale")
|
|
12318
12643
|
});
|
|
12644
|
+
var editAnnotationOutput = { id: z.string(), text: z.string(), note: z.string() };
|
|
12319
12645
|
var annotateOutput = {
|
|
12320
12646
|
id: z.string(),
|
|
12321
12647
|
sheet: z.string(),
|
|
@@ -12404,8 +12730,9 @@ var deleteRfiOutput = {
|
|
|
12404
12730
|
};
|
|
12405
12731
|
var censusOccurrence = {
|
|
12406
12732
|
at: z.tuple([z.number(), z.number()]).describe("The tag's center (image px)"),
|
|
12407
|
-
value: z.string().describe("The paired value drawn under the tag (CFM, GPM, a count \u2014 the annotation that makes it an instance)"),
|
|
12408
|
-
sheet: z.string()
|
|
12733
|
+
value: z.string().optional().describe("The paired value drawn under the tag (CFM, GPM, a count \u2014 the annotation that makes it an instance); absent when counted by label"),
|
|
12734
|
+
sheet: z.string(),
|
|
12735
|
+
by: z.enum(["value", "label"]).describe('"value" = tag-over-value (air devices, fixtures); "label" = an EQUIPMENT-schedule mark drawn amid linework with no value \u2014 the leader-tag convention, counted as one instance and said so')
|
|
12409
12736
|
};
|
|
12410
12737
|
var censusWithheld = {
|
|
12411
12738
|
at: z.tuple([z.number(), z.number()]).describe("The tag's center (image px) \u2014 view_sheet here"),
|
|
@@ -12415,7 +12742,8 @@ var censusWithheld = {
|
|
|
12415
12742
|
var countMarksOutput = {
|
|
12416
12743
|
marks: z.array(z.object({
|
|
12417
12744
|
mark: z.string(),
|
|
12418
|
-
count: z.number().int().describe("
|
|
12745
|
+
count: z.number().int().describe("Instances counted on plan-role sheets \u2014 value-paired tags, plus (for an equipment-schedule mark) tags drawn amid linework, see counted_by_label"),
|
|
12746
|
+
counted_by_label: z.number().int().optional().describe("\u2026of which counted by label (an equipment mark with a leader, no value under it)"),
|
|
12419
12747
|
row: z.object({ sheet: z.string(), key: z.string(), table: z.string() }).optional().describe("The schedule row that answers for this mark (a compound key answers for each part)"),
|
|
12420
12748
|
unscheduled: z.boolean().optional().describe("true when the mark was stated by the caller but no schedule row answers for it"),
|
|
12421
12749
|
occurrences: z.array(z.object(censusOccurrence)),
|
|
@@ -13253,7 +13581,12 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
|
|
|
13253
13581
|
shows(r.ea, 0) ? `${num(r.ea, 0)} EA` : ""
|
|
13254
13582
|
].filter(Boolean).join(" \xB7 ");
|
|
13255
13583
|
draw(qty || "-", { x: 190, y, size: 10, font, color: ink });
|
|
13256
|
-
|
|
13584
|
+
const orderQty = [
|
|
13585
|
+
shows(uA(r.total_sf_net)) ? `${num(uA(r.total_sf_net))} ${AU}` : "",
|
|
13586
|
+
shows(uL(r.lf_net)) ? `${num(uL(r.lf_net))} ${LU}` : ""
|
|
13587
|
+
].filter(Boolean).join(" \xB7 ");
|
|
13588
|
+
const allowance = orderQty ? `waste ${r.waste_pct}% -> ${orderQty}` : "";
|
|
13589
|
+
draw([c.hatch && c.hatch !== "solid" ? c.hatch : "", allowance].filter(Boolean).join(" \xB7 "), { x: 420, y, size: 8.5, font, color: muted });
|
|
13257
13590
|
y -= 15;
|
|
13258
13591
|
if (y < 120) break;
|
|
13259
13592
|
}
|
|
@@ -14185,7 +14518,7 @@ function registerTools(realServer, session, opts = {}) {
|
|
|
14185
14518
|
outputSchema: measurePolygonOutput
|
|
14186
14519
|
}, run("measure_polygon", (a) => session.measurePolygon(a.sheet, a.verts, { condition: a.condition, role: a.role })));
|
|
14187
14520
|
server.registerTool("cut_out", {
|
|
14188
|
-
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. ${COORDS}`,
|
|
14521
|
+
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}`,
|
|
14189
14522
|
inputSchema: {
|
|
14190
14523
|
parent_shape_id: z2.string().describe("A committed floor_area shape id, or an open run (surface_area / linear) to clip (list_shapes)"),
|
|
14191
14524
|
verts: z2.array(pointSchema).min(3).describe("The ring, image px \u2014 fully inside the parent for an area; over the stretch to remove for a run")
|
|
@@ -14251,7 +14584,7 @@ function registerTools(realServer, session, opts = {}) {
|
|
|
14251
14584
|
commitSeed: a.commit_seed
|
|
14252
14585
|
})));
|
|
14253
14586
|
server.registerTool("sweep_schedule_row", {
|
|
14254
|
-
description: `Take off a schedule row's mark from the row itself \u2014 the estimator's own gesture: a transition type sometimes exists only as a schedule row plus tag markers scattered across the plan sheets, and this tool mints the condition FROM the row and finds every occurrence. Pass the row's key (e.g. 'T1') and the tool (1) reads the row from the set's schedule tables (the sheet_graph/find_schedule machinery \u2014 the row is the condition's cited source), (2) anchors a geometric fingerprint on the marker the tag is DRAWN as on a plan sheet (a deterministic pad ladder around the tag text; where the tag occurs more than once the fingerprint must recur at a second occurrence before it is trusted \u2014 \`anchor.corroborated\`), and (3) sweeps every PLAN-role sheet for it. The count is geometry AND text agreeing: drafting reuses one bubble shape across many marks, so a match counts ONLY when the row's own tag sits within the marker footprint (its bbox rides the match as \`tag_at\` evidence); a match labeled with a SIBLING row's tag is excluded and says whose it is, an unlabeled match is withheld as a question, and a tag drawn with no matching marker is disclosed as text_only. REFUSAL over guessing, with the reason and the fix: no such row; the same key in two tables (ambiguous); a tag drawn on no plan sheet; no repeatable marker linework around the tag \u2014 a fingerprint is never guessed from text alone (the fallback is always: marquee one instance with symbol_sweep). commit: true commits the counted matches as EA markers under the row's own key \u2014 one undo step for the whole set-wide sweep, every marker carrying origin.assignment {source: "schedule"} plus the anchor and row citation on origin.symbol.seed. The COUNT is scale-free (EA), but matching is not: where the anchor sheet and a target sheet both carry a scale, the marker is resized by their exact ratio before matching (\`scaled\` per sheet), and where one does not, the sweep runs at 1:1 and discloses it (\`scale_assumed\`) rather than reporting a confident zero. After committing, LOOK: view_sheet {overlay: true} over each swept sheet. ${COORDS}
|
|
14587
|
+
description: `Take off a schedule row's mark from the row itself \u2014 the estimator's own gesture: a transition type sometimes exists only as a schedule row plus tag markers scattered across the plan sheets, and this tool mints the condition FROM the row and finds every occurrence. Pass the row's key (e.g. 'T1') and the tool (1) reads the row from the set's schedule tables (the sheet_graph/find_schedule machinery \u2014 the row is the condition's cited source), (2) anchors a geometric fingerprint on the marker the tag is DRAWN as on a plan sheet (a deterministic pad ladder around the tag text; where the tag occurs more than once the fingerprint must recur at a second occurrence before it is trusted \u2014 \`anchor.corroborated\`), and (3) sweeps every PLAN-role sheet for it. The count is geometry AND text agreeing: drafting reuses one bubble shape across many marks, so a match counts ONLY when the row's own tag sits within the marker footprint (its bbox rides the match as \`tag_at\` evidence); a match labeled with a SIBLING row's tag is excluded and says whose it is, an unlabeled match is withheld as a question, and a tag drawn with no matching marker is disclosed as text_only. REFUSAL over guessing, with the reason and the fix: no such row; the same key in two tables (ambiguous); a tag drawn on no plan sheet; no repeatable marker linework around the tag \u2014 a fingerprint is never guessed from text alone (the fallback is always: marquee one instance with symbol_sweep). commit: true commits the counted matches as EA markers under the row's own key \u2014 one undo step for the whole set-wide sweep, every marker carrying origin.assignment {source: "schedule"} plus the anchor and row citation on origin.symbol.seed. The COUNT is scale-free (EA), but matching is not: where the anchor sheet and a target sheet both carry a scale, the marker is resized by their exact ratio before matching (\`scaled\` per sheet), and where one does not, the sweep runs at 1:1 and discloses it (\`scale_assumed\`) rather than reporting a confident zero. After committing, LOOK: view_sheet {overlay: true} over each swept sheet. ${COORDS} LABEL-FIRST for devices (any trade): when the marker cannot be fingerprinted or does not reach a drawn tag amid linework \u2014 a heater bar or fan drawn to its own size, tagged by a leader \u2014 every such tag on a plan sheet counts as ONE instance BY LABEL, disclosed in found_by_label / label_only / counted_by; a bare mention in a note (no linework near it) is text_only, never a count. Rows come from every schedule family the sheet graph reads: room-finish, finish/material, and equipment (mechanical, electrical, plumbing, fire).`,
|
|
14255
14588
|
inputSchema: {
|
|
14256
14589
|
tag: z2.string().min(1).describe("The schedule row's key exactly as drawn, e.g. 'T1', 'TR-2' \u2014 it becomes the condition tag on commit"),
|
|
14257
14590
|
commit: z2.boolean().default(false).describe("Commit every counted match as one EA count marker (excluded/withheld/text_only never commit)"),
|
|
@@ -14267,7 +14600,7 @@ function registerTools(realServer, session, opts = {}) {
|
|
|
14267
14600
|
tolerancePx: a.tolerance_px
|
|
14268
14601
|
})));
|
|
14269
14602
|
server.registerTool("count_marks", {
|
|
14270
|
-
description: `The COUNT TAKEOFF in one deterministic call \u2014 no seeds, no model, seconds: census every VALUE-ANNOTATED mark tag on the plan-role sheets, counted per schedule mark, committed as EA markers when asked. The identity rule is the annotated-device drafting pattern: a device is drawn as its mark tag with a value under it ("S1" over "200" \u2014 CFM on air devices, GPM on fixtures, a rating on equipment), so a tag WITH a paired value counts, a tag inside a schedule table's own region is a row label (excluded, tallied), and every other occurrence is WITHHELD with a reason and coordinates \u2014 a tag amid linework but unvalued may be a real device (view_sheet it), a bare tag is probably a note mention. Marks default to the set's schedule row keys (a compound row "R1 / E1" answers for R1 AND E1; each mark cites its row), or state them: {marks: ["S1","R1"]}. The complement to sweep_schedule_row: THAT tool is for marks drawn ON their marker with no value (finish tags in bubbles) and matches geometry; this one is for annotated devices and needs no fingerprint at all. Refusal-honest: scans refuse (no text layer), a set with no mark-shaped rows refuses unless marks are stated, non-plan sheets are skipped with the role that excused them. commit: true commits every counted occurrence under its mark's own tag \u2014 ONE undo step for the whole census, schedule citation on origin. Counts are scale-free (EA) \u2014 no set_scale needed. Then AUDIT: view_sheet {overlay: true} where the markers landed, and read every withheld entry \u2014 a withheld item you ignore is a hole in the bid. ${COORDS}
|
|
14603
|
+
description: `The COUNT TAKEOFF in one deterministic call \u2014 no seeds, no model, seconds: census every VALUE-ANNOTATED mark tag on the plan-role sheets, counted per schedule mark, committed as EA markers when asked. The identity rule is the annotated-device drafting pattern: a device is drawn as its mark tag with a value under it ("S1" over "200" \u2014 CFM on air devices, GPM on fixtures, a rating on equipment), so a tag WITH a paired value counts, a tag inside a schedule table's own region is a row label (excluded, tallied), and every other occurrence is WITHHELD with a reason and coordinates \u2014 a tag amid linework but unvalued may be a real device (view_sheet it), a bare tag is probably a note mention. Marks default to the set's schedule row keys (a compound row "R1 / E1" answers for R1 AND E1; each mark cites its row), or state them: {marks: ["S1","R1"]}. The complement to sweep_schedule_row: THAT tool is for marks drawn ON their marker with no value (finish tags in bubbles) and matches geometry; this one is for annotated devices and needs no fingerprint at all. Refusal-honest: scans refuse (no text layer), a set with no mark-shaped rows refuses unless marks are stated, non-plan sheets are skipped with the role that excused them. commit: true commits every counted occurrence under its mark's own tag \u2014 ONE undo step for the whole census, schedule citation on origin. Counts are scale-free (EA) \u2014 no set_scale needed. Then AUDIT: view_sheet {overlay: true} where the markers landed, and read every withheld entry \u2014 a withheld item you ignore is a hole in the bid. ${COORDS} EQUIPMENT marks (a row in an equipment/device schedule \u2014 fans, pumps, heaters, fixtures, panels) follow the leader-tag convention instead: a scheduled mark drawn amid linework with no value under it is counted BY LABEL (occurrence by: "label", counted_by_label on the mark); a bare mention in a note still withholds.`,
|
|
14271
14604
|
inputSchema: {
|
|
14272
14605
|
marks: z2.array(z2.string().min(1)).optional().describe(`The marks to census, e.g. ["S1", "R1"] \u2014 omit to take them from the schedule tables' row keys`),
|
|
14273
14606
|
commit: z2.boolean().default(false).describe("Commit every counted occurrence as one EA count marker under its mark (withheld/excluded never commit)")
|
|
@@ -14275,7 +14608,7 @@ function registerTools(realServer, session, opts = {}) {
|
|
|
14275
14608
|
outputSchema: countMarksOutput
|
|
14276
14609
|
}, run("count_marks", (a) => session.countMarks({ marks: a.marks, commit: a.commit })));
|
|
14277
14610
|
server.registerTool("derive_base", {
|
|
14278
|
-
description: `Mint the wall base from committed rooms (#148) \u2014 the estimator's most mechanical derivation: base LF = room perimeter \u2212 stated door openings. For every floor_area shape of source_condition, commits ONE linear shape under condition (e.g. 'RB-1') tracing that room's boundary, quantified NET of the openings you state per room. The openings are YOUR claim to make \u2014 look at the doors with view_sheet, state {shape_id, lf} per room (repeat a shape_id to stack openings); the tool never guesses, and your claim is recorded on origin.derived (from_shape_id, gross_lf, openings_lf). All-or-nothing: an unknown shape_id, a negative lf, or openings meeting a room's whole perimeter refuses the call before anything commits. The whole derivation is ONE undo step. Deriving onto the source condition is refused \u2014 base lands on its own tag.`,
|
|
14611
|
+
description: `Mint the wall base from committed rooms (#148) \u2014 the estimator's most mechanical derivation: base LF = room perimeter \u2212 stated door openings. For every floor_area shape of source_condition, commits ONE linear shape under condition (e.g. 'RB-1') tracing that room's boundary, quantified NET of the openings you state per room. The openings are YOUR claim to make \u2014 look at the doors with view_sheet, state {shape_id, lf} per room (repeat a shape_id to stack openings); the tool never guesses, and your claim is recorded on origin.derived (from_shape_id, gross_lf, openings_lf). The output geometry remains the whole perimeter: deducted openings are numerical, not visible gaps. For a drawing of the actual installed base, use measure_line on the physical runs after checking door jambs, alcoves and open finish splits. All-or-nothing: an unknown shape_id, a negative lf, or openings meeting a room's whole perimeter refuses the call before anything commits. The whole derivation is ONE undo step. Deriving onto the source condition is refused \u2014 base lands on its own tag.`,
|
|
14279
14612
|
inputSchema: {
|
|
14280
14613
|
source_condition: z2.string().describe("Finish tag whose floor_area rooms the base derives from, e.g. 'CPT-1'"),
|
|
14281
14614
|
condition: z2.string().describe("Finish tag the base commits under (minted on first use), e.g. 'RB-1'"),
|
|
@@ -14409,10 +14742,10 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
|
|
|
14409
14742
|
outputSchema: deleteShapeOutput
|
|
14410
14743
|
}, run("delete_shape", ({ shape_id }) => session.deleteShape(shape_id)));
|
|
14411
14744
|
server.registerTool("scope_duplicates", {
|
|
14412
|
-
description: `Two conditions claiming the same floor, as a list (#366). Every pair of committed floor_area shapes on one sheet whose EXACT polygon intersection exceeds min_fraction of the smaller shape \u2014 with the shared SF, which condition each belongs to, whether the estimator already affirmed either, and a look region to pass to view_sheet {overlay: true}. Pairs on DIFFERENT conditions are collisions: every total downstream counts that floor twice. Pairs on the SAME condition are a double trace (a different bug) and come back in duplicates. shared_floor_sf is the whole compared set's \u03A3 areas \u2212 union, counted once per cell no matter how many shapes pile on it \u2014 the number takeoff_summary carries and the one that has to read 0 before any total means anything. Deducts and runs are not claims. Read-only; a shape on an unscaled sheet or with a degenerate ring is listed in unmeasured, never counted as zero. Same rule as the room eval's shared-floor gate (iou \u2265 0.5 = the same space claimed twice). ${COORDS}`,
|
|
14745
|
+
description: `Two conditions claiming the same floor, as a list (#366). Every pair of committed floor_area shapes on one sheet whose EXACT polygon intersection exceeds min_fraction of the smaller shape \u2014 with the shared SF, which condition each belongs to, whether the estimator already affirmed either, and a look region to pass to view_sheet {overlay: true}. Pairs on DIFFERENT conditions are collisions: every total downstream counts that floor twice. Pairs on the SAME condition are a double trace (a different bug) and come back in duplicates. shared_floor_sf is the whole compared set's \u03A3 areas \u2212 union, counted once per cell no matter how many shapes pile on it \u2014 the number takeoff_summary carries and the one that has to read 0 before any total means anything. Machine-precision edge remnants are ignored; a real overlap below 0.01 SF stays listed with an explanatory note. Supporting materials belong in edit_materials coverage rows, not duplicate floor polygons. Deducts and runs are not claims. Read-only; a shape on an unscaled sheet or with a degenerate ring is listed in unmeasured, never counted as zero. Same rule as the room eval's shared-floor gate (iou \u2265 0.5 = the same space claimed twice). ${COORDS}`,
|
|
14413
14746
|
inputSchema: {
|
|
14414
14747
|
sheet: z2.string().optional().describe("Restrict to one sheet; default every sheet with floor shapes"),
|
|
14415
|
-
min_fraction: z2.number().min(0).max(1).optional().describe("List a pair only when shared \xF7 smaller \u2265 this (default 0.05 \u2014 rings that merely kiss along a wall are not claims; 0 lists
|
|
14748
|
+
min_fraction: z2.number().min(0).max(1).optional().describe("List a pair only when shared \xF7 smaller \u2265 this (default 0.05 \u2014 rings that merely kiss along a wall are not claims; 0 lists every positive overlap above machine-precision noise)")
|
|
14416
14749
|
},
|
|
14417
14750
|
outputSchema: scopeDuplicatesOutput
|
|
14418
14751
|
}, run("scope_duplicates", (a) => session.scopeDuplicates({ sheet: a.sheet, min_fraction: a.min_fraction })));
|
|
@@ -14571,7 +14904,7 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
|
|
|
14571
14904
|
}, run("resolve_tag", ({ tag }) => session.resolveRoomTag(tag)));
|
|
14572
14905
|
server.registerTool("find_schedule", {
|
|
14573
14906
|
description: `Locate a schedule table in the set (#87): pass a kind ("room finish", "material"/"finish") and get every matching table's sheet, title, headers, TOTAL row count, and REGION \u2014 sized for a view_sheet look or a read_sheet_text pull of exactly the table. A schedule continued across sheets is ONE match whose "parts" list every fragment (base first) with its own viewable region; tables read through rotated headers say so; a table answering for one building carries "building"; a table with delta/REV-marked rows says how many in "revised_rows". Errors with what WAS found when the asked-for kind isn't in the set. ${COORDS}`,
|
|
14574
|
-
inputSchema: { kind: z2.string().describe('"room finish" (rooms \u2192 surface finishes)
|
|
14907
|
+
inputSchema: { kind: z2.string().describe('"room finish" (rooms \u2192 surface finishes), "finish"/"material" (codes \u2192 products), or "equipment" (MEP device schedules \u2014 fans, pumps, heaters, AHUs, VAVs, diffusers/grilles/registers \u2014 keyed by mark, proven by a powered or air-device column)') },
|
|
14575
14908
|
outputSchema: findScheduleOutput
|
|
14576
14909
|
}, run("find_schedule", ({ kind }) => session.findSchedule(kind)));
|
|
14577
14910
|
server.registerTool("read_sheet_text", {
|
|
@@ -14646,6 +14979,14 @@ No review gate: the pencil-not-ink rule exists to stop an agent inventing geomet
|
|
|
14646
14979
|
},
|
|
14647
14980
|
outputSchema: listAnnotationsOutput
|
|
14648
14981
|
}, run("list_annotations", (a) => session.listAnnotations(a)));
|
|
14982
|
+
server.registerTool("edit_annotation", {
|
|
14983
|
+
description: "Shorten, replace or clear the text of an existing annotation. Get annotation_id from list_annotations (annotations, not verdicts). Changes only text: position, shape, dimension length, condition links, quantities and review records stay unchanged. Empty text clears the note; a dimension still prints its measured length. Refuses an RFI-linked note: review that question's context in the browser RFI register. One undo_last step restores the previous text. Does not create a verdict or human approval.",
|
|
14984
|
+
inputSchema: {
|
|
14985
|
+
annotation_id: z2.string().describe("An annotation id from list_annotations"),
|
|
14986
|
+
text: z2.string().describe("Replacement text; empty string clears it")
|
|
14987
|
+
},
|
|
14988
|
+
outputSchema: editAnnotationOutput
|
|
14989
|
+
}, run("edit_annotation", (a) => session.editAnnotation(a.annotation_id, a.text)));
|
|
14649
14990
|
server.registerTool("link_annotation", {
|
|
14650
14991
|
description: `Attach an existing annotation to a condition, or detach it by passing an empty condition \u2014 the canvas's Attach/Detach control, reachable by an agent. Use it to tie up notes left unattached (list_annotations reports how many), or to move one to the finish it actually concerns. Attaching mints the tag on first use.`,
|
|
14651
14992
|
inputSchema: {
|
|
@@ -14712,6 +15053,85 @@ No review gate: the pencil-not-ink rule exists to stop an agent inventing geomet
|
|
|
14712
15053
|
|
|
14713
15054
|
// src/resources.ts
|
|
14714
15055
|
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15056
|
+
|
|
15057
|
+
// src/wiki.generated.ts
|
|
15058
|
+
var WIKI_VERSION = "0.9.80";
|
|
15059
|
+
var WIKI_PAGES = [
|
|
15060
|
+
{
|
|
15061
|
+
"key": "index",
|
|
15062
|
+
"uri": "takeoff://wiki",
|
|
15063
|
+
"title": "OpenTakeoff knowledge index",
|
|
15064
|
+
"source": "docs/wiki/README.md",
|
|
15065
|
+
"source_sha256": "26f6c1b098cbec0743a01225cead29925ad281071e8ec217a80b2d037f4220a6",
|
|
15066
|
+
"text": "# OpenTakeoff knowledge index\n\nRead the page that answers the current question. Agents can read this index at\n`takeoff://wiki` and individual pages at `takeoff://wiki/{page}` without loading\na plan. These are documentation resources, not additional measurement tools.\n\n| Question | Page | MCP resource |\n|---|---|---|\n| What works, what is draft, and what is unsupported? | [Capability status](takeoff://wiki/status) | `takeoff://wiki/status` |\n| How do the browser, MCP and shared engine fit together? | [Architecture](takeoff://wiki/architecture) | `takeoff://wiki/architecture` |\n| What does a saved takeoff mean? | [Protocol](takeoff://wiki/protocol) | `takeoff://wiki/protocol` |\n| How do I measure, stitch, review and hand off work? | [Workflows](takeoff://wiki/workflows) | `takeoff://wiki/workflows` |\n| Which tool should I call, and in which coordinate frame? | [MCP routing](takeoff://wiki/mcp) | `takeoff://wiki/mcp` |\n| What do floor SF, wall SF, base LF and review mean? | [Domain knowledge](takeoff://wiki/domain) | `takeoff://wiki/domain` |\n| How do I change this repository and verify a PR? | [Repository guide](takeoff://wiki/repo-guide) | `takeoff://wiki/repo-guide` |\n| What tools and required inputs does the runtime expose? | [Generated tool index](takeoff://wiki/tool-index) | `takeoff://wiki/tool-index` |\n\nFor detailed canvas instructions, use the [human guide](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/USER_GUIDE.md).\nFor a full takeoff session, use the [agent guide](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/AGENT_GUIDE.md).\nThe wiki routes to their detailed procedures rather than copying every control\nand schema into every page.\n\nThe MCP build embeds these pages. CI checks the embedded copy, tool inventory,\nschema references and documentation links against their sources. Reading a\nresource does not mutate a session, set scale, review work or grant approval.\n"
|
|
15067
|
+
},
|
|
15068
|
+
{
|
|
15069
|
+
"key": "status",
|
|
15070
|
+
"uri": "takeoff://wiki/status",
|
|
15071
|
+
"title": "Capability status",
|
|
15072
|
+
"source": "docs/wiki/status.md",
|
|
15073
|
+
"source_sha256": "a3712c5f3e308c830a7b29c672d9a48479eba0b3e5cc5394e2639435451f5e35",
|
|
15074
|
+
"text": "# Capability status\n\nThis page describes implementation support, not a promise that every plan can\nbe measured automatically. Follow its source links before changing behavior.\n\n| Capability | Current boundary | Evidence |\n|---|---|---|\n| Human and agent measurements | Shared geometry/quantity modules; source interpretation still requires inspection | [Geometry workflow](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/GEOMETRY_WORKFLOW.md) |\n| Original machine geometry after human correction | Outer vertices preserved, including manual agent traces; missing historical originals cannot be recovered | [Compatibility](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/COMPATIBILITY.md) |\n| Human stitching | Browser gallery and alignment; editable browser archive preserves composites | [Workflow](takeoff://wiki/workflows) |\n| MCP stitched documents | No stitch tool; current import/export omits stitch records | [Field inventory](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/INVENTORY.md) |\n| One-Click | Temporarily gated in the default browser/MCP builds | [Gate](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/design/ONE_CLICK_GATE.md) |\n| TakeoffDocument v1 | Draft schemas and compatibility checks; legacy writer format remains | [Protocol](takeoff://wiki/protocol) |\n| Annotation cleanup | Text-only MCP edit with undo; RFI-linked notes refuse | [MCP routing](takeoff://wiki/mcp) |\n| Agent knowledge | Packaged wiki resources and source-generated tool/schema references | [Index](takeoff://wiki) |\n| Academy interoperability | Incompatibilities inventoried; no adapter or certification bridge claimed | [Academy report](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/ACADEMY_COMPATIBILITY.md) |\n| Independent geometry benchmark | Reference-assisted and synthetic checks are not independently reviewed ground truth | [Geometry evidence](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/GEOMETRY_WORKFLOW.md#reusable-evidence-from-a-project-test) |\n\nNext protocol work expands the semantic compatibility corpus before opt-in\nadapters. Package extraction, verified identity/signatures, Academy credentials\nand optional anchoring come later. Rendering optimization is separate from\nthis protocol and knowledge work.\n"
|
|
15075
|
+
},
|
|
15076
|
+
{
|
|
15077
|
+
"key": "architecture",
|
|
15078
|
+
"uri": "takeoff://wiki/architecture",
|
|
15079
|
+
"title": "Architecture",
|
|
15080
|
+
"source": "docs/wiki/architecture.md",
|
|
15081
|
+
"source_sha256": "c74a31394709bc1fe59b0a222d9ce6c824a4eb36fc7f272508f7878a2cad95e6",
|
|
15082
|
+
"text": "# Architecture\n\nThe core app runs in the browser. PDFs, calibrated sheets, conditions, geometry\nand review state persist locally. The stdio MCP server imports shared geometry,\ncalibration and quantity modules and keeps its own working session. It does not\ndrive the browser UI or share the browser's live undo history.\n\n| Boundary | Responsibility | Source |\n|---|---|---|\n| Browser canvas | Human interaction, overlays, local persistence and project archives | [TakeoffCanvas](https://github.com/Kentucky-ai/opentakeoff/blob/main/web/src/pages/TakeoffCanvas.jsx), [store](https://github.com/Kentucky-ai/opentakeoff/blob/main/web/src/lib/store.js) |\n| Shared engine | Geometry, calibration, quantities, cutouts and collision review | [Geometry](https://github.com/Kentucky-ai/opentakeoff/blob/main/web/src/lib/geometry.js), [totals](https://github.com/Kentucky-ai/opentakeoff/blob/main/web/src/lib/totals.js), [cutouts](https://github.com/Kentucky-ai/opentakeoff/blob/main/web/src/lib/cutout.js) |\n| MCP | Validated tool calls, session state, resources and explicit file exports | [Server](https://github.com/Kentucky-ai/opentakeoff/blob/main/mcp/server.ts), [Session](https://github.com/Kentucky-ai/opentakeoff/blob/main/mcp/src/session.ts), [tools](https://github.com/Kentucky-ai/opentakeoff/blob/main/mcp/src/tools.ts) |\n| Protocol checks | Draft schemas, record inventory and compatibility evidence; no runtime format switch | [Protocol package](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/README.md) |\n| Optional AI service | Separate suggestion adapter; not the stdio MCP server | [Service guide](https://github.com/Kentucky-ai/opentakeoff/blob/main/server/README.md) |\n\nBrowser geometry is a normalized SVG overlay on a rendered plan. Tool inputs\nuse full-sheet image pixels; storage uses per-sheet normalized vertices. Scale\nconverts pixels to stored SF/LF; changing display units does not rewrite shapes.\nSee [MCP coordinates](takeoff://wiki/mcp).\n\nShared modules do not imply every record or detector path is identical. Browser\nstitches are not preserved by the MCP import/export path. Browser and MCP\norigins also have different actors and review authority. Consult the\n[compatibility matrix](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/COMPATIBILITY.md) before promising a\nlossless interchange.\n\nPackage extraction, signatures, Academy credentials and optional chain anchoring\nare later work. They are not required to open a plan or measure it. The current\nprotocol first makes existing records explicit; it does not replace the engine.\n"
|
|
15083
|
+
},
|
|
15084
|
+
{
|
|
15085
|
+
"key": "protocol",
|
|
15086
|
+
"uri": "takeoff://wiki/protocol",
|
|
15087
|
+
"title": "Takeoff Protocol",
|
|
15088
|
+
"source": "docs/wiki/protocol.md",
|
|
15089
|
+
"source_sha256": "53a5059d6929cb2241e763ffe423edca569b285e7b6f1adf790943b3b3d314c7",
|
|
15090
|
+
"text": "# Takeoff Protocol\n\nThe writer format remains `opentakeoff.takeoff_canvas.v1`. The proposed\n`opentakeoff.takeoff-document.v1` is a draft contract with offline validation,\nnot a new autosave format. Never rename the schema identifier and call that a\ncompleted migration: transport support, semantic checks and loss handling still\nneed evidence.\n\n| Contract concern | Canonical source |\n|---|---|\n| Measurement, Calibration, Provenance, Evidence and Review | [Generated schema reference](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/README.md#generated-schema-reference) |\n| Existing fields, writers, readers and projections | [Field inventory](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/INVENTORY.md) |\n| Conforms versus unsupported, including stitches | [Compatibility status](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/COMPATIBILITY.md) |\n| Differences from Academy's records and trust model | [Academy compatibility report](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/ACADEMY_COMPATIBILITY.md) |\n| Minimal event vocabulary without a new event store | [Draft events](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/EVENTS.md) |\n\nActor and drawing method are separate. An agent can draw with a manual tool;\nhuman correction still preserves its original outer ring. Existing missing\nhistory cannot be reconstructed, and outer-ring preservation does not imply a\ncomplete historical snapshot of holes or curves.\n\nHuman review, a human approval seal, an agent verdict, confidence and Academy\ncertification are different claims. Validation checks record structure; it does\nnot prove who authored or approved it. MCP cannot create a human approval.\n\nFollow the implementation order: schema, compatibility tests, then explicit\nopt-in adapters. Preserve inputs, IDs, quantities, originals, extensions and\nmissing-value meaning; refuse unsupported loss. No default writer migration,\nAcademy schema change, identity/signature system or storage infrastructure is\npart of the draft. Run `npm run check --prefix protocol` when changing it.\n"
|
|
15091
|
+
},
|
|
15092
|
+
{
|
|
15093
|
+
"key": "workflows",
|
|
15094
|
+
"uri": "takeoff://wiki/workflows",
|
|
15095
|
+
"title": "Workflows",
|
|
15096
|
+
"source": "docs/wiki/workflows.md",
|
|
15097
|
+
"source_sha256": "defe8732d8dee9e38f040c97c315195f63dd891f2a66c8281c770b621b9ccad2",
|
|
15098
|
+
"text": "# Workflows\n\n## Human: open, stitch and measure\n\nOpen the plans and check their revision. In the sheet gallery, 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 the joint and scale\nbefore tracing. Once shapes exist on a stitch, alignment is locked.\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).\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"
|
|
15099
|
+
},
|
|
15100
|
+
{
|
|
15101
|
+
"key": "mcp",
|
|
15102
|
+
"uri": "takeoff://wiki/mcp",
|
|
15103
|
+
"title": "MCP routing and coordinate contract",
|
|
15104
|
+
"source": "docs/wiki/mcp.md",
|
|
15105
|
+
"source_sha256": "ba2cea9b3fc0ce89d6abc3d5a7a784cd7d782bddc5bca4ca202bf3f92c964a36",
|
|
15106
|
+
"text": "# MCP routing and coordinate contract\n\nStart at `takeoff://wiki`; read the relevant page, then use the discovered\nschemas for precise arguments. The [generated tool index](takeoff://wiki/tool-index)\ncomes from the runtime, including stage ownership and required inputs.\n\n| Current job | First tool or resource | Next check |\n|---|---|---|\n| See supported workflows without a plan | `takeoff://wiki/status` | Read only the needed workflow page |\n| Open and orient | `load_plan`, `sheet_info` | Source revision, dimensions and the intended sheet |\n| Establish scale | `set_scale` | Correct detail scale; agent-set calibration stays unconfirmed |\n| Find finish evidence | `find_schedule`, `resolve_tag`, `read_sheet_text` | Verify the cited sheet/region; do not infer missing rows |\n| Locate boundaries | `get_sheet_vectors` or `sheet_context` | `view_sheet` for visual confirmation |\n| Group a pass | `propose_takeoff` | A batch heading exists; no geometry has been committed |\n| Floor, base/trim, wall face | `measure_polygon`, `measure_line`, `measure_surface` respectively | Overlay inspection and per-role quantities |\n| Device/count census | `count_marks` | Inspect withheld entries; use more specific sweeps where needed |\n| Located opening | `cut_out` | Surviving geometry; numeric derived-base allowances cannot be clipped |\n| Pending shape or crowded note | `edit_shape` or `edit_annotation` | Reinspect; `undo_last` restores the edit |\n| Check and hand off | `takeoff_summary`, `export_report`, `export_takeoff`, `export_marked_pdf` | Reopen and inspect; leave human review pending |\n\nBy default tools are flat. With `OPENTAKEOFF_MCP_STAGED_TOOLS=1`, setup starts\nopen; `open_tool_stage` opens measure, revise or handoff when needed. Opening is\nidempotent and never closes another stage. Wiki resources remain readable in\nboth modes; adding knowledge does not add a measurement tool.\n\n## Coordinates: carry the frame with every point\n\n- Tool coordinates are **full-sheet image pixels at PDF render scale 2.0**:\n PDF points \xD7 2, top-left origin, y down.\n- A rendered preview can be resized or cropped. Convert a displayed point back\n using the returned view dimensions and region offset before calling a tool.\n Do not pass raw screenshot pixels as full-sheet coordinates.\n- Persisted `verts_norm` use the sheet's normalized frame. Tool calls accept\n pixels, so convert normalized coordinates with the original sheet dimensions.\n- Scale is feet per image pixel; areas square that factor. Metric display is a\n conversion of stored quantities, not a change to geometry.\n\nThe [full MCP guide](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/MCP.md) and [tool reference](https://github.com/Kentucky-ai/opentakeoff/blob/main/mcp/README.md)\ncontain optional arguments and examples. Runtime descriptions remain the\nargument authority. A refusal should lead to its stated next step rather than\nretrying the same call with guessed coordinates or authority fields.\n"
|
|
15107
|
+
},
|
|
15108
|
+
{
|
|
15109
|
+
"key": "domain",
|
|
15110
|
+
"uri": "takeoff://wiki/domain",
|
|
15111
|
+
"title": "Takeoff domain knowledge",
|
|
15112
|
+
"source": "docs/wiki/domain.md",
|
|
15113
|
+
"source_sha256": "6a055b911e85cf9416c9036ab53a63a13ea260c91b2184b5386643f0db7a7b98",
|
|
15114
|
+
"text": "# Takeoff domain knowledge\n\n| Quantity or record | Meaning | Common mistake |\n|---|---|---|\n| Floor SF | Area assigned to a floor finish, net of supported deductions | Adding wall SF to call the result building floor area |\n| Wall SF | Measured run LF \xD7 that shape's height | Applying one elevation width to every wall or treating floor deducts as wall openings |\n| Base/transition LF | Installed run length or a disclosed derived allowance | Treating a numeric opening allowance as a located gap |\n| Count | Located instances with count semantics | Counting a note's bare mention as a drawn device |\n| Order quantity | Net quantity with the condition's stated multiplier/waste rules | Increasing the traced geometry to carry waste |\n| Material coverage | A material row derived from measured finish quantities | Drawing duplicate finish polygons for membrane/protection coverage |\n| Confidence | A signal for prioritizing inspection | Treating it as human approval or an accuracy guarantee |\n| Agent verdict | The agent's own recorded check | Creating or claiming an estimator's approval seal |\n\nUse `measure_surface` bands for stepped faces. `cut_out` removes the full height\nof the particular wall run it clips; a partial-height opening needs bands so\nonly affected heights are clipped. Existing records have no elevation-plane\ncoordinate or vertical band offset. Preserve that limitation rather than\ninventing a new meaning for `deduct`.\n\n`derive_base` retains a whole perimeter and may subtract stated LF numerically.\nExplicit `measure_line` runs and located cuts show installation gaps. Inspect\nopen finish splits, jamb returns and columns; apparent openings need source\nevidence. `derive_transitions` can withhold a wall-separated boundary: a returned\ncandidate is still something to inspect.\n\nMCP work stays pending; correction does not approve it. A human's correction\nfreezes original machine outer vertices, including manual agent traces, but it\ncannot restore previously discarded history. Schema validation, confidence,\nreview, approval, identity and Academy certification remain separate.\n\nSources: [quantity math](https://github.com/Kentucky-ai/opentakeoff/blob/main/web/src/lib/totals.js),\n[Session measurement and derivation](https://github.com/Kentucky-ai/opentakeoff/blob/main/mcp/src/session.ts),\n[provenance](https://github.com/Kentucky-ai/opentakeoff/blob/main/web/src/lib/provenance.js),\n[review semantics](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/COMPATIBILITY.md),\n[human glossary](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/USER_GUIDE.md#18-glossary--what-the-words-mean-here).\n"
|
|
15115
|
+
},
|
|
15116
|
+
{
|
|
15117
|
+
"key": "repo-guide",
|
|
15118
|
+
"uri": "takeoff://wiki/repo-guide",
|
|
15119
|
+
"title": "Working on the repository",
|
|
15120
|
+
"source": "docs/wiki/repo-guide.md",
|
|
15121
|
+
"source_sha256": "f57a56894aed4d50564a49ef3e97a289de46b28bf9e1c365577df8527a4248e4",
|
|
15122
|
+
"text": "# Working on the repository\n\nOpenTakeoff is a **client-only React app**: a PDF construction-takeoff canvas for flooring (useful for any trade). No backend, no database, no auth\u2014everything runs and persists in the browser. Apache-2.0. (For the one-page project pitch and vision, see [`AGENT_BRIEF.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/AGENT_BRIEF.md); for capability \u2192 code mapping, see [`FEATURES.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/FEATURES.md).)\n\n## Run / build / check\n\n```bash\ncd web\nnvm use # Node pinned by web/.nvmrc (CI reads the same file)\nnpm install\nnpm run dev # http://localhost:5173 \u2014 hot reload\nnpm test # node:test over the pure geometry + totals math (test/*.test.ts)\nnpm run build # \u2192 web/dist/ (static output; this is what Netlify deploys)\nnpm run check # typecheck + lint + test + build \u2014 exactly what CI runs; green here \u21D2 green CI\n```\n\n## Shipping \u2014 the required steps, every change\n\n`main` is protected on GitHub by a ruleset (PR-only, one approving review,\ngreen `web` check, branch up to date\u2014the repo owner has a standing bypass\nas the solo maintainer). **Merging to `main` deploys to production**\n(<https://opentakeoff.kentucky-ai.com>)\u2014Netlify's own git integration builds\nthe merge commit and publishes it. `netlify.toml` holds the whole recipe\n(`base = \"web\"`, `command = npm run build`, `publish = \"dist\"`), so the deploy\nruns a fresh build from the merged source; nothing is uploaded from CI.\n\n`.github/workflows/deploy.yml` used to do this by publishing `web/dist` with\n`--no-build`, and it was **deleted on 2026-07-13 in `e701f1a`** (\"deploys here\nare manual CLI; it fails on every push without the fork's secrets\"). Current checks and release automation live in\n[the workflow directory](https://github.com/Kentucky-ai/opentakeoff/blob/main/.github/workflows); `publish-mcp.yml` fires on\nan `mcp-v*` tag. The old line here said \"Netlify never builds anything\nitself\", which is now exactly backwards\u2014Netlify is the only thing that\nbuilds production. **Merge = deploy either way: that part has never changed.**\n\n> **This is the canonical `Kentucky-ai/opentakeoff` repo\u2014production is\n> <https://opentakeoff.kentucky-ai.com>, nothing else.** A downstream fork\n> (`knmurphy/opentakeoff`) tracks this repo as its own upstream and deploys\n> separately to `takeoff.345flooring.com`\u2014that URL belongs to *that* fork,\n> not this repo. If you see `takeoff.345flooring.com` referenced elsewhere in\n> this repo's docs, it's either an example value in the optional cloud-mode\n> guides or leftover content from that fork's docs that rode along in a\n> wholesale history merge (2026-07-13)\u2014treat it as describing the\n> *downstream* fork's deployment, not this one's.\n\nSo:\n\n1. **Branch first**\u2014never commit on `main`: `git checkout -b <topic>`.\n2. **`npm run check` before pushing** (in `web/`). It is exactly what CI runs,\n on the same Node (`web/.nvmrc`)\u2014green here means green CI.\n3. **Include review evidence in every PR**: screenshots or a short video for\n visible changes; measured expected-versus-observed stats and reproducible\n commands for engine/tool changes. Link the actual checks or evidence. This\n repository is also the maintainer's public portfolio: keep claims verifiable\n and private project data out of public artifacts.\n4. **Open a PR** and wait for the `web` check to pass. Don't merge red or\n pending.\n5. **Squash-merge with branch delete**\n (`gh pr merge <n> --squash --delete-branch`), then\n `git checkout main && git pull --ff-only` and delete the local branch\n (`git branch -D <topic>`\u2014squash merges need `-D`).\n6. **Remember a merge is a deploy.** Don't merge work you haven't verified in\n the running app.\n\nThe tests cover the pure math (`web/test/geometry.test.ts`, `web/test/totals.test.ts`); the canvas itself is verified by hand\u2014**Vite does not flag undefined identifiers in JSX**, so grep for your new identifiers after editing and load the app once before you call it done. The bundled sample plan (`web/public/demo/`, wired to the \"Load sample plan\" button) is the fastest end-to-end check: load it, press `A`, trace a room, open Report.\n\n## Where things live\n\n| Concern | Path |\n|---|---|\n| **The canvas\u201490% of the app** | `web/src/pages/TakeoffCanvas.jsx` (one large, deliberately monolithic component) |\n| Geometry: vector extraction, One-Click flood fill, vertex snap | `web/src/lib/oneclick.ts` |\n| Sheet/page helpers, scale detection | `web/src/lib/sheets.ts` |\n| Totals and materials math (waste, SY, coverage \u2192 order qty) | `web/src/lib/totals.js` |\n| Persistence (IndexedDB + localStorage) | `web/src/lib/store.js` |\n| PDF/image/zip ingest | `web/src/lib/ingest.js` |\n| Icon set | `web/src/brand/icons.jsx` |\n| Design tokens (colors, spacing\u2014the source of truth) | `web/src/styles/tokens.css` |\n| Sheet gallery / report UI | `web/src/components/` |\n| Pure-math tests (node:test) | `web/test/` |\n| **Optional AI backend** (pluggable adapter: scale/room/finish suggestions) | `server/`\u2014`app.py` + `adapters/base.py` (interface) + `adapters/heuristic.py` (default, no model) |\n\n## How the canvas works (the mental model)\n\n- Each open sheet renders into a `<canvas>` bitmap; **all takeoff geometry is an SVG overlay** on top; pan/zoom is a single CSS transform on the stage div, written imperatively (`tfRef` \u2192 `style.transform`) to avoid React re-renders per frame.\n- Coordinates: pointer events (client px) \u2192 `toImage()` \u2192 **stage px**; committed shapes store **normalized [0..1] vertices per sheet** (`verts_norm`), so quantities survive re-renders and zoom.\n- Cursor-following UI (crosshair hairlines, readout chip, rubber band) updates through **direct DOM writes in `moveCrosshair`**\u2014never React state per mousemove. Keep it that way.\n- Angle snapping: `angleSnap()` locks in-progress segments to the 45\xB0 family; endpoint snap (`nearestSnap` over a spatial hash of PDF vector endpoints) takes priority. The committed click reuses the same locked point (`angleRef`).\n- Past ~1.15\xD7 zoom, a **detail-view canvas** re-renders the visible region from PDF vectors at the current zoom (crispness); the base bitmap stays as first paint.\n- pdf.js rendering schedules work on `requestAnimationFrame`\u2014a fully hidden/occluded window will pause mid-render by design; it resumes when visible.\n\n## Conventions\n\n- **SVG presentation attributes take literal colors** (CSS vars don't resolve there): cobalt `#1f3fc7`, danger `#b03a26`, positive `#1f6b4a`\u2014centralized in `web/src/lib/ui.js` (`SVG`, with HUD-dark counterparts through `svgAccent(isDark)`). DOM/HTML chrome may use `var(--\u2026)` from `tokens.css`.\n- Condition palettes (`PALETTE` in `web/src/components/hatches.jsx`, the seeded condition colors in `FLOORING_DEFAULTS` in `web/src/lib/canvasConstants.js`, and the mirrored copies in `mcp/src/session.ts`) are **user data**\u2014don't re-theme them.\n- Waste applies only in the report (order quantities), never to live measured numbers.\n- Keyboard shortcuts are single letters registered on `window` (see `docs/USER_GUIDE.md` \xA715); toolbar menus pause them through `menuDepthRef`.\n- Brand voice: **precision instrument** (2026-08 overhaul). Light theme = \"ice\": bright white surfaces on a cool field, cool-slate neutrals, cobalt the one saturated thing. Dark theme = \"HUD\": true-black cockpit, electric blue `#3f8cff`, phosphor `--glow` on exactly five elements (active tool face, status verb, hero quantity, primary CTA, calibration dot). Square corners; the single sanctioned radius is `--r-1` on floating chrome. Mono tabular numerals on every readout. Drafting-table language stays. No vendor mimicry.\n- Layout/spacing/type come from the token scales in `tokens.css` (`--sp-*`, `--fs-*`, `--ctl-*`); zIndex comes from the `Z` ladder in `web/src/lib/ui.js`. No new magic numbers.\n\n## Docs to keep in sync when you change behavior\n\nThe draft Takeoff Protocol lives in [`protocol/`](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/README.md). When\nchanging persisted fields, consult its [inventory](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/INVENTORY.md) and\n[compatibility report](https://github.com/Kentucky-ai/opentakeoff/blob/main/protocol/ACADEMY_COMPATIBILITY.md). Run\n`npm run check --prefix protocol` after installing web, MCP, and protocol\ndependencies. Refresh schema references with\n`node protocol/scripts/check-docs.mjs --write`. The draft does not authorize\nchanging writer formats or adopting a migration.\n\n1. `README.md` (Features + \"What's in the box\")\n2. `docs/USER_GUIDE.md` (shortcuts + the relevant section)\n3. `CHANGELOG.md`\n\nTouching the **MCP server** adds four more, and they drift independently\u2014the\ntool count alone lives in five places, so grep the old number before you assume\nyou got them all:\n\n4. `mcp/src/tools.ts`\u2014the tool's own `description` **is** its integration.\n An MCP client reads it at runtime; nothing else you write reaches the model.\n5. `mcp/server.ts`\u2014the `instructions` block sent at `initialize`. This is the\n decision tree every client receives before its first call. A new *verb* does\n not belong here; a new *step in the standard finish* does.\n6. `mcp/README.md` (the tool table) and `docs/MCP.md` (the reach-for-it ordering,\n the example session, and the tool count in its opening line). A new tool also\n needs a row in `mcp/src/staging.ts`'s `TOOL_STAGES`\u2014the four lists must\n partition the tool set exactly, and a test fails CI if one doesn't.\n `npm run check:tool-count --prefix mcp` checks default, gated and setup counts across the current entry points,\n reference-table coverage, and [`docs/MCP_TOOL_INDEX.md`](takeoff://wiki/tool-index)\n against runtime schemas. Add `-- --write` to regenerate counts and the index. If the\n change alters *doctrine* rather than adding a verb\u2014what withholds, what\n refuses, what has no agent verb\u2014it belongs in `docs/AGENT_GUIDE.md` too,\n and the tool count appears there and in `docs/USER_GUIDE.md` \xA714.\n7. **Version, on three surfaces that must agree**: `mcp/package.json`,\n `mcp/server.json`, `web/public/.well-known/mcp.json`. They have drifted\n before (#171, and again at 0.9.28). Check `git show HEAD:mcp/package.json`\n before bumping\u2014a concurrent branch may already have claimed the number.\n\nArchitecture rather than behavior\u2014what MCP is versus what the `/ai` sandbox\nis, and why the server imports the web engine in-process\u2014lives at the end of\n[`docs/MCP.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/MCP.md) (\"Where this sits\") and in\n[`server/README.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/server/README.md).\n\n## The doc set, and who each one is for\n\nFour documents carry the product, and they're deliberately split by audience\u2014don't\nanswer an estimator's question in the agent manual or vice versa:\n\n| Document | Audience | What belongs in it |\n|---|---|---|\n| [`README.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/README.md) | everyone, ~60 seconds | what this is, the three doors, what's in the box |\n| [`docs/USER_GUIDE.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/USER_GUIDE.md) | the estimator at the canvas | every shipped UI behavior, the working order on a real bid, the glossary |\n| [`docs/AGENT_GUIDE.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/AGENT_GUIDE.md) | an agent driving the engine | the operating model, the standard finish, withheld doctrine, staging, refusal\u2192next-move |\n| [`mcp/README.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/mcp/README.md) | an agent's integrator | tool-by-tool reference, resources, coordinate contract, limits |\n\nAll of them follow one house style\u2014the Apple Style Guide, with the rules that\nactually come up written out in [`CONTRIBUTING.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/CONTRIBUTING.md#docs-house-style).\nInterface text quoted in a doc is copied from the code verbatim, so changing a\nmessage means changing it in both places.\n\n[`AGENT_BRIEF.md`](https://github.com/Kentucky-ai/opentakeoff/blob/main/AGENT_BRIEF.md) is the one-page orientation that routes to\nall four. A behavior change usually touches two of them; a new MCP tool touches\nthree plus this file's sync list above.\n"
|
|
15123
|
+
},
|
|
15124
|
+
{
|
|
15125
|
+
"key": "tool-index",
|
|
15126
|
+
"uri": "takeoff://wiki/tool-index",
|
|
15127
|
+
"title": "MCP tool index",
|
|
15128
|
+
"source": "docs/MCP_TOOL_INDEX.md",
|
|
15129
|
+
"source_sha256": "9c478f0db051e68c357951067a0915dab6e2ae0e70b9ff194f64f0fba13bb991",
|
|
15130
|
+
"text": "# MCP tool index\n\nGenerated from runtime `tools/list` schemas and the source stage table. Do not edit by hand.\nRefresh with `npm run check:tool-count --prefix mcp -- --write`; CI fails on stale output.\n\nFor the operating workflow, read [the agent guide](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/AGENT_GUIDE.md) and [geometry workflow](https://github.com/Kentucky-ai/opentakeoff/blob/main/docs/GEOMETRY_WORKFLOW.md).\nFor behavior and optional arguments, use the [tool reference](https://github.com/Kentucky-ai/opentakeoff/blob/main/mcp/README.md#tools) and the discovered input schema.\n\nDefault build: **53 tools**. Coordinates, where present, are full-sheet image pixels at PDF render scale 2.0, top-left origin, y down.\n\n| Tool | Stage | Availability | Required arguments |\n|---|---|---|---|\n| `annotate` | revise | Default | `sheet`, `type` |\n| `apply_rules` | handoff | Default | None |\n| `count_marks` | measure | Default | None |\n| `create_rfi` | revise | Default | `title`, `question`, `sheet` |\n| `cut_out` | measure | Default | `parent_shape_id`, `verts` |\n| `delete_rfi` | revise | Default | `rfi_id` |\n| `delete_shape` | revise | Default | `shape_id` |\n| `delete_verdict` | revise | Default | `verdict_id` |\n| `derive_base` | measure | Default | `source_condition`, `condition` |\n| `derive_transitions` | measure | Default | `condition_a`, `condition_b`, `condition` |\n| `detect_rooms` | measure | One-Click gate lifted | `sheet` |\n| `duplicate_condition` | revise | Default | `condition`, `label` |\n| `edit_annotation` | revise | Default | `annotation_id`, `text` |\n| `edit_condition` | revise | Default | `condition` |\n| `edit_materials` | revise | Default | `condition` |\n| `edit_shape` | revise | Default | `shape_id` |\n| `export_dxf` | handoff | Default | `path` |\n| `export_marked_pdf` | handoff | Default | None |\n| `export_report` | handoff | Default | None |\n| `export_takeoff` | handoff | Default | None |\n| `find_schedule` | setup | Default | `kind` |\n| `find_text` | setup | Default | `sheet`, `q` |\n| `get_sheet_vectors` | setup | Default | `sheet` |\n| `import_takeoff` | handoff | Default | `path` |\n| `link_annotation` | revise | Default | `annotation_id`, `condition` |\n| `list_annotations` | revise | Default | None |\n| `list_rfis` | revise | Default | None |\n| `list_shapes` | revise | Default | None |\n| `load_plan` | setup | Default | `path` |\n| `mark_verdict` | revise | Default | None |\n| `measure_line` | measure | Default | `sheet`, `pts` |\n| `measure_polygon` | measure | Default | `sheet`, `verts` |\n| `measure_surface` | measure | Default | `sheet`, `pts`, `condition` |\n| `one_click` | measure | One-Click gate lifted | `sheet`, `x`, `y` |\n| `place_count` | measure | Default | `sheet`, `points`, `condition` |\n| `propose_condition_edit` | revise | Default | `condition`, `rationale` |\n| `propose_takeoff` | measure | Default | `label`, `rationale` |\n| `read_sheet_text` | setup | Default | `sheet` |\n| `resolve_rfi` | revise | Default | `rfi_id`, `answer` |\n| `resolve_tag` | setup | Default | `tag` |\n| `revise_proposal` | revise | Default | `proposal_id`, `shapes` |\n| `scope_duplicates` | revise | Default | None |\n| `scope_merge` | revise | Default | `shape_a`, `shape_b` |\n| `set_scale` | setup | Default | `sheet` |\n| `sheet_context` | setup | Default | `sheet` |\n| `sheet_graph` | setup | Default | None |\n| `sheet_info` | setup | Default | `sheet` |\n| `split_condition` | revise | Default | `condition` |\n| `sweep_schedule_row` | measure | Default | `tag` |\n| `symbol_sweep` | measure | Default | `sheet`, `seed_rect` |\n| `takeoff_summary` | handoff | Default | None |\n| `undo_last` | revise | Default | None |\n| `view_sheet` | setup | Default | `sheet` |\n| `withdraw_condition_edit` | revise | Default | `proposal_id` |\n| `withdraw_proposal` | revise | Default | `proposal_id` |\n\n`open_tool_stage {stage}` exists only when staged exposure is enabled. Setup starts open; measure, revise and handoff open on demand. This opener is not part of the default flat count.\n\nSchema-required fields are only the first validation layer: conditional requirements (such as annotation coordinates by type or exactly one calibration method) are explained by each tool and validated by its handler.\n\nSources: [tool registrations](https://github.com/Kentucky-ai/opentakeoff/blob/main/mcp/src/tools.ts), [stage table](https://github.com/Kentucky-ai/opentakeoff/blob/main/mcp/src/staging.ts), [output schemas](https://github.com/Kentucky-ai/opentakeoff/blob/main/mcp/src/outputs.ts).\n"
|
|
15131
|
+
}
|
|
15132
|
+
];
|
|
15133
|
+
|
|
15134
|
+
// src/resources.ts
|
|
14715
15135
|
function toBase64(bytes) {
|
|
14716
15136
|
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
|
|
14717
15137
|
}
|
|
@@ -14721,6 +15141,19 @@ function parsePage(session, raw) {
|
|
|
14721
15141
|
return session.sheetForPage(Number(s));
|
|
14722
15142
|
}
|
|
14723
15143
|
function registerResources(server, session) {
|
|
15144
|
+
for (const page of WIKI_PAGES) {
|
|
15145
|
+
server.registerResource(`wiki-${page.key}`, page.uri, {
|
|
15146
|
+
title: page.title,
|
|
15147
|
+
description: `OpenTakeoff ${WIKI_VERSION} knowledge: ${page.title}. Read only when relevant; no session mutation.`,
|
|
15148
|
+
mimeType: "text/markdown"
|
|
15149
|
+
}, async (uri) => ({ contents: [{
|
|
15150
|
+
uri: uri.href,
|
|
15151
|
+
mimeType: "text/markdown",
|
|
15152
|
+
text: `Packaged with MCP ${WIKI_VERSION}. Source: ${page.source} (SHA-256 of LF-normalized text: ${page.source_sha256}). Repository source links browse main and may be newer.
|
|
15153
|
+
|
|
15154
|
+
${page.text}`
|
|
15155
|
+
}] }));
|
|
15156
|
+
}
|
|
14724
15157
|
const sheetEntries = (suffix, mimeType, what) => () => ({
|
|
14725
15158
|
resources: session.sheetList().map((s) => ({
|
|
14726
15159
|
uri: `takeoff://sheet/${s.ord}${suffix}`,
|
|
@@ -14835,6 +15268,7 @@ var TOOL_STAGES = {
|
|
|
14835
15268
|
"undo_last",
|
|
14836
15269
|
"annotate",
|
|
14837
15270
|
"list_annotations",
|
|
15271
|
+
"edit_annotation",
|
|
14838
15272
|
"link_annotation",
|
|
14839
15273
|
"mark_verdict",
|
|
14840
15274
|
"delete_verdict",
|
|
@@ -14953,7 +15387,7 @@ function nameTheStageInRefusals(server) {
|
|
|
14953
15387
|
// package.json
|
|
14954
15388
|
var package_default = {
|
|
14955
15389
|
name: "opentakeoff-mcp",
|
|
14956
|
-
version: "0.9.
|
|
15390
|
+
version: "0.9.80",
|
|
14957
15391
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
14958
15392
|
type: "module",
|
|
14959
15393
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -14964,13 +15398,14 @@ var package_default = {
|
|
|
14964
15398
|
scripts: {
|
|
14965
15399
|
dev: "node --import tsx server.ts",
|
|
14966
15400
|
start: "node dist/server.js",
|
|
14967
|
-
build: "esbuild server.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/server-core.js && node scripts/finish-build.mjs",
|
|
15401
|
+
build: "npm run check:wiki && esbuild server.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/server-core.js && node scripts/finish-build.mjs",
|
|
14968
15402
|
"smoke:dist": "node scripts/smoke-dist.mjs",
|
|
14969
15403
|
mcpb: "npm run build && node scripts/build-mcpb.mjs",
|
|
14970
15404
|
prepublishOnly: "npm run typecheck && npm test && npm run build",
|
|
14971
15405
|
typecheck: "tsc --noEmit",
|
|
14972
|
-
"check:
|
|
14973
|
-
|
|
15406
|
+
"check:wiki": "node scripts/check-wiki.mjs",
|
|
15407
|
+
"check:tool-count": "node --import tsx scripts/check-tool-count.mjs",
|
|
15408
|
+
test: "node --import tsx --test test/conformance.test.ts test/context.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"
|
|
14974
15409
|
},
|
|
14975
15410
|
dependencies: {
|
|
14976
15411
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
@@ -15036,14 +15471,16 @@ function buildServer(session = new Session(), opts = {}) {
|
|
|
15036
15471
|
// takeoffs land as reviewable work instead of a bare numbers report.
|
|
15037
15472
|
instructions: [
|
|
15038
15473
|
"OpenTakeoff: quantity takeoff on construction plan PDFs.",
|
|
15474
|
+
"Knowledge: read takeoff://wiki for the task router, then only the relevant takeoff://wiki/{page}. Pages cover capability limits, architecture, protocol, workflows, MCP routing and domain knowledge; takeoff://wiki/tool-index lists tool stages and required inputs. These resources work before loading a plan and do not change the session.",
|
|
15039
15475
|
"A takeoff's deliverable is the marked-up planset, not a numbers report. Standard finish for ANY takeoff:",
|
|
15040
15476
|
"1. load_plan, then set_scale on each sheet you measure (quantities are px-only until the scale is set).",
|
|
15041
15477
|
oneClick ? "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). A COUNT takeoff of value-annotated device marks (GRDs, fixtures, equipment \u2014 the tag-over-value pattern) starts with count_marks {commit: true}: the whole census in one deterministic call, then audit its withheld entries \u2014 reach for the agent-driven per-mark tools only where it refuses or withholds." : "2. Commit shapes under finish-tag conditions (measure_polygon / measure_line with `condition`; a room's polygon is its wall faces \u2014 read them with get_sheet_vectors, confirm on view_sheet, and take the finish tag from the room's own schedule row via resolve_tag). A COUNT takeoff of value-annotated device marks (GRDs, fixtures, equipment \u2014 the tag-over-value pattern) starts with count_marks {commit: true}: the whole census in one deterministic call, then audit its withheld entries \u2014 reach for the agent-driven per-mark tools only where it refuses or withholds.",
|
|
15042
|
-
"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
|
|
15478
|
+
"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. derive_base draws the whole perimeter even where LF is deducted: when the handoff needs actual installed runs, use measure_line on the physical base segments and cut_out for located gaps. Do not clip a derived base that already carries numeric openings. Inspect finish splits, alcoves, jambs and withheld transitions in step 4.",
|
|
15043
15479
|
"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).",
|
|
15044
15480
|
"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.",
|
|
15045
15481
|
"A floor split across sheets at a MATCH LINE: there is no stitch verb, deliberately \u2014 joining and aligning a match line is human judgment in the canvas (a sloppy join silently skews every seam-crossing quantity). Measure each member sheet as its own surface and tell the user a seam-crossing room needs their stitch in the app; never approximate one by combining sheets yourself.",
|
|
15046
15482
|
`WITHHELD IS NOT A FAILURE \u2014 IT IS THE ANSWER. ${oneClick ? "detect_rooms, " : ""}symbol_sweep, sweep_schedule_row, count_marks 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.`,
|
|
15483
|
+
"For annotation cleanup, list_annotations gives ids and edit_annotation changes only text with undo. RFI-linked notes require review in the browser register; annotation edits cannot create approval.",
|
|
15047
15484
|
"When the DRAWINGS are the problem \u2014 a schedule row the plan never draws, a room the schedule has no row for, a finish called out two ways \u2014 raise it with create_rfi (annotate a cloud at the conflict first and link it) instead of describing it in prose: the question lands numbered in the estimator's RFI register and prints in the marked set, pending their acceptance before it goes to the architect.",
|
|
15048
15485
|
...oneClick ? [] : [GATE_NOTE],
|
|
15049
15486
|
...staged ? [STAGED_INSTRUCTIONS] : []
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opentakeoff-mcp",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.80",
|
|
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.",
|
|
@@ -11,13 +11,14 @@
|
|
|
11
11
|
"scripts": {
|
|
12
12
|
"dev": "node --import tsx server.ts",
|
|
13
13
|
"start": "node dist/server.js",
|
|
14
|
-
"build": "esbuild server.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/server-core.js && node scripts/finish-build.mjs",
|
|
14
|
+
"build": "npm run check:wiki && esbuild server.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/server-core.js && node scripts/finish-build.mjs",
|
|
15
15
|
"smoke:dist": "node scripts/smoke-dist.mjs",
|
|
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
|
-
"check:
|
|
20
|
-
"
|
|
19
|
+
"check:wiki": "node scripts/check-wiki.mjs",
|
|
20
|
+
"check:tool-count": "node --import tsx scripts/check-tool-count.mjs",
|
|
21
|
+
"test": "node --import tsx --test test/conformance.test.ts test/context.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"
|
|
21
22
|
},
|
|
22
23
|
"dependencies": {
|
|
23
24
|
"@modelcontextprotocol/sdk": "^1.12.0",
|