opentakeoff-mcp 0.9.84 → 0.9.86
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/dist/server-core.js +203 -120
- package/package.json +2 -2
package/dist/server-core.js
CHANGED
|
@@ -118,8 +118,65 @@ function memberEmbed(vpTransform, member, pageH, renderScale) {
|
|
|
118
118
|
};
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
// ../web/src/lib/
|
|
121
|
+
// ../web/src/lib/takeoffConstants.ts
|
|
122
|
+
var TAKEOFF_SCHEMA = "opentakeoff.takeoff_canvas.v1";
|
|
123
|
+
var REPORT_SCHEMA = "opentakeoff.report.v1";
|
|
122
124
|
var RENDER_SCALE = 2;
|
|
125
|
+
var PALETTE = Object.freeze([
|
|
126
|
+
"#c96442",
|
|
127
|
+
"#2f7d54",
|
|
128
|
+
"#2563eb",
|
|
129
|
+
"#9333ea",
|
|
130
|
+
"#b8860b",
|
|
131
|
+
"#0d9488",
|
|
132
|
+
"#be185d",
|
|
133
|
+
"#1f2937",
|
|
134
|
+
"#dc2626",
|
|
135
|
+
"#0891b2"
|
|
136
|
+
]);
|
|
137
|
+
var HATCH_IDS = Object.freeze([
|
|
138
|
+
"solid",
|
|
139
|
+
"diag",
|
|
140
|
+
"diag2",
|
|
141
|
+
"cross",
|
|
142
|
+
"diagdense",
|
|
143
|
+
"horiz",
|
|
144
|
+
"vert",
|
|
145
|
+
"grid",
|
|
146
|
+
"brick",
|
|
147
|
+
"plank",
|
|
148
|
+
"herring",
|
|
149
|
+
"basket",
|
|
150
|
+
"checker",
|
|
151
|
+
"wave",
|
|
152
|
+
"dots",
|
|
153
|
+
"speckle",
|
|
154
|
+
"iso",
|
|
155
|
+
"honeycomb",
|
|
156
|
+
"scan",
|
|
157
|
+
"plus",
|
|
158
|
+
"circuit",
|
|
159
|
+
"topo",
|
|
160
|
+
"woodgrain",
|
|
161
|
+
"chevron",
|
|
162
|
+
"pinwheel",
|
|
163
|
+
"harlequin",
|
|
164
|
+
"hexagon",
|
|
165
|
+
"penny",
|
|
166
|
+
"octagondot",
|
|
167
|
+
"fleur",
|
|
168
|
+
"concrete"
|
|
169
|
+
]);
|
|
170
|
+
var SNAP_CELL = 24;
|
|
171
|
+
var SNAP_TOL = 7;
|
|
172
|
+
function nextHatchId(conditionCount) {
|
|
173
|
+
return HATCH_IDS[1 + conditionCount % (HATCH_IDS.length - 1)];
|
|
174
|
+
}
|
|
175
|
+
function nextPaletteColor(conditionCount) {
|
|
176
|
+
return PALETTE[conditionCount % PALETTE.length];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ../web/src/lib/sheets.ts
|
|
123
180
|
function sheetBaseLabelFromKey(key) {
|
|
124
181
|
if (typeof key !== "string" || !key || isStitchKey(key)) return "";
|
|
125
182
|
const t = parseSheetKey(key);
|
|
@@ -1404,8 +1461,8 @@ function sweepHatchRuns(segs, meta, ws) {
|
|
|
1404
1461
|
clusters.pop();
|
|
1405
1462
|
}
|
|
1406
1463
|
}
|
|
1407
|
-
const median2 = (
|
|
1408
|
-
const a =
|
|
1464
|
+
const median2 = (arr3) => {
|
|
1465
|
+
const a = arr3.slice().sort((x, y) => x - y);
|
|
1409
1466
|
return a[a.length >> 1];
|
|
1410
1467
|
};
|
|
1411
1468
|
for (const members of clusters) {
|
|
@@ -1533,8 +1590,8 @@ function classifyOffsetAnnotationSegs(segs, meta, ws, maxOffsetPx, minOffsetPx,
|
|
|
1533
1590
|
t1: Math.max(c.x1 * dxu + c.y1 * dyu, c.x2 * dxu + c.y2 * dyu),
|
|
1534
1591
|
w: c.w
|
|
1535
1592
|
};
|
|
1536
|
-
const
|
|
1537
|
-
if (
|
|
1593
|
+
const arr3 = bins.get(b);
|
|
1594
|
+
if (arr3) arr3.push(r);
|
|
1538
1595
|
else bins.set(b, [r]);
|
|
1539
1596
|
}
|
|
1540
1597
|
for (const runs of bins.values()) {
|
|
@@ -1711,8 +1768,8 @@ function sweepDimensionStrings(segs, meta, ws, ftPx, rejects, dimTexts) {
|
|
|
1711
1768
|
t0: Math.min(c.x1 * dx + c.y1 * dy, c.x2 * dx + c.y2 * dy),
|
|
1712
1769
|
t1: Math.max(c.x1 * dx + c.y1 * dy, c.x2 * dx + c.y2 * dy)
|
|
1713
1770
|
};
|
|
1714
|
-
const
|
|
1715
|
-
if (
|
|
1771
|
+
const arr3 = bins.get(b);
|
|
1772
|
+
if (arr3) arr3.push(r);
|
|
1716
1773
|
else bins.set(b, [r]);
|
|
1717
1774
|
}
|
|
1718
1775
|
for (const [b, rows] of bins) {
|
|
@@ -6601,12 +6658,12 @@ function applyRuleToProject(rule, shapes, sheetData) {
|
|
|
6601
6658
|
const d = sheetData.get(s.sheet_id);
|
|
6602
6659
|
if (!d) continue;
|
|
6603
6660
|
const ring = s.verts_norm.map(([nx, ny]) => [nx * d.imgW, ny * d.imgH]);
|
|
6604
|
-
let
|
|
6605
|
-
if (!
|
|
6606
|
-
|
|
6607
|
-
deductsBySheet.set(s.sheet_id,
|
|
6661
|
+
let arr3 = deductsBySheet.get(s.sheet_id);
|
|
6662
|
+
if (!arr3) {
|
|
6663
|
+
arr3 = [];
|
|
6664
|
+
deductsBySheet.set(s.sheet_id, arr3);
|
|
6608
6665
|
}
|
|
6609
|
-
|
|
6666
|
+
arr3.push(ring);
|
|
6610
6667
|
}
|
|
6611
6668
|
const out = [];
|
|
6612
6669
|
const acceptedBySheet = /* @__PURE__ */ new Map();
|
|
@@ -6631,12 +6688,12 @@ function applyRuleToProject(rule, shapes, sheetData) {
|
|
|
6631
6688
|
verts_norm: ring.map(([x, y]) => [x / d.imgW, y / d.imgH]),
|
|
6632
6689
|
area_sf: +areaSf.toFixed(2)
|
|
6633
6690
|
});
|
|
6634
|
-
let
|
|
6635
|
-
if (!
|
|
6636
|
-
|
|
6637
|
-
acceptedBySheet.set(room.sheet_id,
|
|
6691
|
+
let arr3 = acceptedBySheet.get(room.sheet_id);
|
|
6692
|
+
if (!arr3) {
|
|
6693
|
+
arr3 = [];
|
|
6694
|
+
acceptedBySheet.set(room.sheet_id, arr3);
|
|
6638
6695
|
}
|
|
6639
|
-
|
|
6696
|
+
arr3.push(ring);
|
|
6640
6697
|
}
|
|
6641
6698
|
}
|
|
6642
6699
|
return out;
|
|
@@ -6937,7 +6994,7 @@ function reportJson({ projectName = "", rows = [], bySheet = [], scaleInfo = [],
|
|
|
6937
6994
|
const colDefs = (Array.isArray(conditionColumns) ? conditionColumns : []).filter((cc) => cc && typeof cc === "object" && typeof cc.id === "string");
|
|
6938
6995
|
const attrs = attrsByCond instanceof Map ? attrsByCond : /* @__PURE__ */ new Map();
|
|
6939
6996
|
return {
|
|
6940
|
-
schema:
|
|
6997
|
+
schema: REPORT_SCHEMA,
|
|
6941
6998
|
project_name: projectName || null,
|
|
6942
6999
|
generated_with: "OpenTakeoff",
|
|
6943
7000
|
// scale_confirmed (scale gate): false = an agent set this sheet's scale and
|
|
@@ -7655,15 +7712,82 @@ function drawMarks(ctx, toCanvas, marks, longEdge) {
|
|
|
7655
7712
|
return drawn;
|
|
7656
7713
|
}
|
|
7657
7714
|
|
|
7715
|
+
// ../web/src/lib/takeoffDocument.js
|
|
7716
|
+
var arr = (v) => Array.isArray(v) ? v : [];
|
|
7717
|
+
var obj = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
7718
|
+
var TAKEOFF_DOCUMENT_KEYS = Object.freeze([
|
|
7719
|
+
"schema",
|
|
7720
|
+
"project_name",
|
|
7721
|
+
"units",
|
|
7722
|
+
"client_info",
|
|
7723
|
+
"sheets",
|
|
7724
|
+
"conditions",
|
|
7725
|
+
"condition_columns",
|
|
7726
|
+
"shape_labels",
|
|
7727
|
+
"palette",
|
|
7728
|
+
"shapes",
|
|
7729
|
+
"markups",
|
|
7730
|
+
"rfis",
|
|
7731
|
+
"approvals",
|
|
7732
|
+
"proposals",
|
|
7733
|
+
"condition_edit_proposals",
|
|
7734
|
+
"rules",
|
|
7735
|
+
"sheet_group",
|
|
7736
|
+
"last_group",
|
|
7737
|
+
"sheet_tabs",
|
|
7738
|
+
"stitches",
|
|
7739
|
+
"sheet_levels",
|
|
7740
|
+
"layer_overrides",
|
|
7741
|
+
"provenance_counters"
|
|
7742
|
+
]);
|
|
7743
|
+
function sheetEntry({ sheet_id, units_per_px, scale_source = void 0, scale_confirmed = void 0 }) {
|
|
7744
|
+
return {
|
|
7745
|
+
sheet_id,
|
|
7746
|
+
units_per_px,
|
|
7747
|
+
...scale_source ? { scale_source } : {},
|
|
7748
|
+
...scale_confirmed === false ? { scale_confirmed: false } : {}
|
|
7749
|
+
};
|
|
7750
|
+
}
|
|
7751
|
+
function buildTakeoffDocument(f = {}) {
|
|
7752
|
+
const conditions = arr(f.conditions);
|
|
7753
|
+
const pinned = arr(f.palette).filter((id) => conditions.some((c) => c && c.id === id));
|
|
7754
|
+
const clientInfo = obj(f.client_info);
|
|
7755
|
+
const hasClient = Object.values(clientInfo).some((v) => v && String(v).trim());
|
|
7756
|
+
const sheetLevels = obj(f.sheet_levels);
|
|
7757
|
+
const layerOverrides = obj(f.layer_overrides);
|
|
7758
|
+
const prov = f.provenance_counters;
|
|
7759
|
+
const hasProv = !!(prov && prov.shapes_deleted && Object.keys(prov.shapes_deleted).length);
|
|
7760
|
+
return {
|
|
7761
|
+
schema: TAKEOFF_SCHEMA,
|
|
7762
|
+
project_name: typeof f.project_name === "string" ? f.project_name : "",
|
|
7763
|
+
...f.units === "metric" ? { units: "metric" } : {},
|
|
7764
|
+
...hasClient ? { client_info: clientInfo } : {},
|
|
7765
|
+
sheets: arr(f.sheets),
|
|
7766
|
+
conditions,
|
|
7767
|
+
...arr(f.condition_columns).length ? { condition_columns: f.condition_columns } : {},
|
|
7768
|
+
...arr(f.shape_labels).length ? { shape_labels: f.shape_labels } : {},
|
|
7769
|
+
...pinned.length ? { palette: pinned } : {},
|
|
7770
|
+
shapes: arr(f.shapes),
|
|
7771
|
+
markups: arr(f.markups),
|
|
7772
|
+
rfis: arr(f.rfis),
|
|
7773
|
+
...arr(f.approvals).length ? { approvals: f.approvals } : {},
|
|
7774
|
+
...arr(f.proposals).length ? { proposals: f.proposals } : {},
|
|
7775
|
+
...arr(f.condition_edit_proposals).length ? { condition_edit_proposals: f.condition_edit_proposals } : {},
|
|
7776
|
+
...arr(f.rules).length ? { rules: f.rules } : {},
|
|
7777
|
+
sheet_group: arr(f.sheet_group),
|
|
7778
|
+
last_group: arr(f.last_group),
|
|
7779
|
+
sheet_tabs: arr(f.sheet_tabs),
|
|
7780
|
+
...arr(f.stitches).length ? { stitches: f.stitches } : {},
|
|
7781
|
+
...Object.keys(sheetLevels).length ? { sheet_levels: sheetLevels } : {},
|
|
7782
|
+
...Object.keys(layerOverrides).length ? { layer_overrides: layerOverrides } : {},
|
|
7783
|
+
...hasProv ? { provenance_counters: prov } : {}
|
|
7784
|
+
};
|
|
7785
|
+
}
|
|
7786
|
+
|
|
7658
7787
|
// src/session.ts
|
|
7659
|
-
var SNAP_CELL = 24;
|
|
7660
|
-
var SNAP_TOL = 7;
|
|
7661
|
-
var PALETTE = ["#c96442", "#2f7d54", "#2563eb", "#9333ea", "#b8860b", "#0d9488", "#be185d", "#1f2937", "#dc2626", "#0891b2"];
|
|
7662
|
-
var HATCH_IDS = ["solid", "diag", "diag2", "cross", "diagdense", "horiz", "vert", "grid", "brick", "plank", "herring", "basket", "checker", "wave", "dots", "speckle", "iso", "honeycomb", "scan", "plus", "circuit", "topo", "woodgrain", "chevron", "pinwheel", "harlequin", "hexagon", "penny", "octagondot", "fleur", "concrete"];
|
|
7663
7788
|
var mintUuid2 = () => globalThis.crypto && typeof globalThis.crypto.randomUUID === "function" ? globalThis.crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
7664
7789
|
var uid = (p) => `${p}-${mintUuid2()}`;
|
|
7665
7790
|
var nowIso2 = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
7666
|
-
var ANN_SCHEMA = "opentakeoff.takeoff_canvas.v1";
|
|
7667
7791
|
var sanitizeApprovals2 = sanitizeApprovals;
|
|
7668
7792
|
var applyApprovalCommand2 = applyApprovalCommand;
|
|
7669
7793
|
var CONTEXT_MIN_LEN_PX = 2;
|
|
@@ -8374,13 +8498,14 @@ var Session = class _Session {
|
|
|
8374
8498
|
conditionFor(tag) {
|
|
8375
8499
|
let c = this.conditions.find((x) => x.finish_tag === tag);
|
|
8376
8500
|
if (!c) {
|
|
8377
|
-
const lc =
|
|
8501
|
+
const lc = nextPaletteColor(this.conditions.length);
|
|
8378
8502
|
c = {
|
|
8379
8503
|
id: uid("cnd"),
|
|
8504
|
+
created_at: nowIso2(),
|
|
8380
8505
|
finish_tag: tag,
|
|
8381
8506
|
color: lc,
|
|
8382
8507
|
fill: lc,
|
|
8383
|
-
hatch:
|
|
8508
|
+
hatch: nextHatchId(this.conditions.length),
|
|
8384
8509
|
multiplier: 1,
|
|
8385
8510
|
waste_pct: 0,
|
|
8386
8511
|
materials: []
|
|
@@ -9584,9 +9709,9 @@ var Session = class _Session {
|
|
|
9584
9709
|
}
|
|
9585
9710
|
const tableRegions = /* @__PURE__ */ new Map();
|
|
9586
9711
|
for (const tb of graph.tables) {
|
|
9587
|
-
const
|
|
9588
|
-
|
|
9589
|
-
tableRegions.set(tb.sheet,
|
|
9712
|
+
const arr3 = tableRegions.get(tb.sheet) ?? [];
|
|
9713
|
+
arr3.push(tb.region);
|
|
9714
|
+
tableRegions.set(tb.sheet, arr3);
|
|
9590
9715
|
}
|
|
9591
9716
|
const VAL_RE = /^[0-9][0-9,]{0,6}$/;
|
|
9592
9717
|
const perMark = /* @__PURE__ */ new Map();
|
|
@@ -10793,7 +10918,7 @@ var Session = class _Session {
|
|
|
10793
10918
|
tag: newTag,
|
|
10794
10919
|
mintId: (p) => uid(p),
|
|
10795
10920
|
nowIso: nowIso2,
|
|
10796
|
-
nextHatch:
|
|
10921
|
+
nextHatch: nextHatchId(this.conditions.length + 1)
|
|
10797
10922
|
});
|
|
10798
10923
|
if (parentPatch) Object.assign(src, parentPatch);
|
|
10799
10924
|
this.conditions.push(twin);
|
|
@@ -11452,41 +11577,18 @@ var Session = class _Session {
|
|
|
11452
11577
|
}
|
|
11453
11578
|
exportPayload() {
|
|
11454
11579
|
if (!this.docs.size) throw new UserError("No plan loaded \u2014 call load_plan first.");
|
|
11455
|
-
return {
|
|
11456
|
-
schema: ANN_SCHEMA,
|
|
11580
|
+
return buildTakeoffDocument({
|
|
11457
11581
|
project_name: "",
|
|
11458
11582
|
units: "imperial",
|
|
11459
|
-
sheets: [...this.sheets.values()].filter((s) => s.upp != null).map((s) => ({
|
|
11460
|
-
sheet_id: s.key,
|
|
11461
|
-
units_per_px: s.upp,
|
|
11462
|
-
// provenance rides the payload (it used to be dropped here): the canvas
|
|
11463
|
-
// hydrates scale_source for its report and scale_confirmed for the
|
|
11464
|
-
// scale gate's confirm affordance — absent = confirmed (pre-flag docs)
|
|
11465
|
-
...s.scaleSource ? { scale_source: s.scaleSource } : {},
|
|
11466
|
-
...s.scaleConfirmed === false ? { scale_confirmed: false } : {}
|
|
11467
|
-
})),
|
|
11583
|
+
sheets: [...this.sheets.values()].filter((s) => s.upp != null).map((s) => sheetEntry({ sheet_id: s.key, units_per_px: s.upp, scale_source: s.scaleSource, scale_confirmed: s.scaleConfirmed })),
|
|
11468
11584
|
conditions: this.conditions,
|
|
11469
11585
|
shapes: this.shapes,
|
|
11470
11586
|
markups: this.markups,
|
|
11471
|
-
|
|
11472
|
-
|
|
11473
|
-
|
|
11474
|
-
|
|
11475
|
-
|
|
11476
|
-
// has no tombstone notion (its delete is a removal), and a withdrawn
|
|
11477
|
-
// question must not resurface there as a stray Void row. Same
|
|
11478
|
-
// present-only-when-any convention as approvals.
|
|
11479
|
-
...this.liveRfis().length ? { rfis: this.liveRfis() } : {},
|
|
11480
|
-
// proposals (#365): the batches and the pending condition diffs ride
|
|
11481
|
-
// the payload as transport — present only when any exist, so a
|
|
11482
|
-
// proposal-free export stays byte-identical to a pre-#365 one.
|
|
11483
|
-
...this.proposals.length ? { proposals: this.proposals } : {},
|
|
11484
|
-
...this.conditionEditProposals.length ? { condition_edit_proposals: this.conditionEditProposals } : {},
|
|
11485
|
-
sheet_group: [],
|
|
11486
|
-
last_group: [],
|
|
11487
|
-
sheet_tabs: [],
|
|
11488
|
-
sheet_levels: {}
|
|
11489
|
-
};
|
|
11587
|
+
rfis: this.liveRfis(),
|
|
11588
|
+
approvals: this.approvals,
|
|
11589
|
+
proposals: this.proposals,
|
|
11590
|
+
condition_edit_proposals: this.conditionEditProposals
|
|
11591
|
+
});
|
|
11490
11592
|
}
|
|
11491
11593
|
/** The computed Report document — "opentakeoff.report.v1", the SAME schema
|
|
11492
11594
|
* and math as the canvas Report's JSON export (web reportJson, totals.js):
|
|
@@ -12116,7 +12218,7 @@ var exportDxfOutput = {
|
|
|
12116
12218
|
var exportTakeoffOutput = {
|
|
12117
12219
|
schema: z.string(),
|
|
12118
12220
|
project_name: z.string(),
|
|
12119
|
-
units: z.string(),
|
|
12221
|
+
units: z.string().optional().describe("Present only for a metric project; absent means imperial \u2014 the app's own diff-only convention"),
|
|
12120
12222
|
sheets: z.array(z.object({
|
|
12121
12223
|
sheet_id: z.string(),
|
|
12122
12224
|
units_per_px: z.number(),
|
|
@@ -12148,7 +12250,7 @@ var exportTakeoffOutput = {
|
|
|
12148
12250
|
sheet_group: z.array(z.unknown()),
|
|
12149
12251
|
last_group: z.array(z.unknown()),
|
|
12150
12252
|
sheet_tabs: z.array(z.unknown()),
|
|
12151
|
-
sheet_levels: z.object({}).passthrough(),
|
|
12253
|
+
sheet_levels: z.object({}).passthrough().optional().describe("Present only when a sheet carries a level label (the app omits it when empty)"),
|
|
12152
12254
|
proposals: z.array(z.object({ id: z.string(), label: z.string(), rationale: z.string(), created_at: z.string(), withdrawn_at: z.string().optional() }).passthrough()).optional().describe("Proposal batches (#365) \u2014 present only when any exist. Shapes reference them by origin.proposal_id; the canvas shows one Accept per batch"),
|
|
12153
12255
|
condition_edit_proposals: z.array(z.object({ id: z.string(), condition_id: z.string(), proposed: z.object({}).passthrough(), rationale: z.string(), proposed_at: z.string() }).passthrough()).optional().describe("Pending condition-edit diffs (#365) \u2014 present only when any exist. Nothing on the condition changes until the estimator accepts in the canvas")
|
|
12154
12256
|
};
|
|
@@ -12358,7 +12460,7 @@ var reportMaterialLine = z.object({
|
|
|
12358
12460
|
qty: z.number().describe("Computed order quantity")
|
|
12359
12461
|
}).passthrough();
|
|
12360
12462
|
var exportReportOutput = {
|
|
12361
|
-
schema: z.literal(
|
|
12463
|
+
schema: z.literal(REPORT_SCHEMA),
|
|
12362
12464
|
project_name: z.string().nullable(),
|
|
12363
12465
|
generated_with: z.string(),
|
|
12364
12466
|
sheets: z.array(z.object({ sheet_id: z.string(), sheet: z.string(), scale_source: z.string() }).passthrough()).describe("Scale provenance per sheet \u2014 how each scale was set"),
|
|
@@ -13467,9 +13569,9 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
|
|
|
13467
13569
|
const { PDFDocument, StandardFonts, rgb, degrees, LineCapStyle } = await import("pdf-lib");
|
|
13468
13570
|
const condById = Object.fromEntries(conditions.map((c) => [c.id, c]));
|
|
13469
13571
|
const rfiNum = new Map((rfis || []).map((r) => [r.id, r.number]));
|
|
13470
|
-
const byKey = (
|
|
13572
|
+
const byKey = (arr3) => {
|
|
13471
13573
|
const m = /* @__PURE__ */ new Map();
|
|
13472
|
-
for (const s of
|
|
13574
|
+
for (const s of arr3) {
|
|
13473
13575
|
const a = m.get(s.sheet_id) || [];
|
|
13474
13576
|
a.push(s);
|
|
13475
13577
|
m.set(s.sheet_id, a);
|
|
@@ -14182,25 +14284,6 @@ async function exportMarkedPdf(session, opts) {
|
|
|
14182
14284
|
import path4 from "node:path";
|
|
14183
14285
|
import { readFile as readFile3 } from "node:fs/promises";
|
|
14184
14286
|
|
|
14185
|
-
// ../web/src/lib/stamps.js
|
|
14186
|
-
var DEFAULT_STAMPS = [
|
|
14187
|
-
{ id: "stmp-direction", name: "Plank / tile direction", elements: [
|
|
14188
|
-
{ type: "arrow", from: [-0.05, 0], to: [0.05, 0], color: "#1f3fc7", weight: 1.5 }
|
|
14189
|
-
] },
|
|
14190
|
-
{ id: "stmp-seam", name: "Seam direction", elements: [
|
|
14191
|
-
{ type: "arrow", from: [-0.05, 0], to: [0.05, 0], color: "#b03a26", line_style: "dashed" }
|
|
14192
|
-
] },
|
|
14193
|
-
{ id: "stmp-origin", name: "Pattern origin", elements: [
|
|
14194
|
-
{ type: "bubble", at: [0, 0], r: 0.018, text: "PO", color: "#0d9488" }
|
|
14195
|
-
] }
|
|
14196
|
-
];
|
|
14197
|
-
var DEFAULT_STAMP_SETS = [
|
|
14198
|
-
{ id: "set-flooring", name: "Flooring shop drawings", stampIds: DEFAULT_STAMPS.map((s) => s.id) }
|
|
14199
|
-
];
|
|
14200
|
-
|
|
14201
|
-
// ../web/src/lib/store.js
|
|
14202
|
-
var ANN_SCHEMA2 = "opentakeoff.takeoff_canvas.v1";
|
|
14203
|
-
|
|
14204
14287
|
// ../web/src/lib/reviewState.js
|
|
14205
14288
|
function normalizeAgentReview(shape) {
|
|
14206
14289
|
return shape?.origin?.actor === "agent" && shape.origin.reviewed == null ? { ...shape, origin: { ...shape.origin, reviewed: false } } : shape;
|
|
@@ -14214,12 +14297,12 @@ function parseTakeoffImport(text) {
|
|
|
14214
14297
|
} catch {
|
|
14215
14298
|
throw new Error("Couldn't import takeoff: that file is not valid JSON.");
|
|
14216
14299
|
}
|
|
14217
|
-
if (!doc || typeof doc !== "object" || Array.isArray(doc) || doc.schema !==
|
|
14218
|
-
throw new Error(`Couldn't import takeoff: not a takeoff export (expected schema "${
|
|
14300
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc) || doc.schema !== TAKEOFF_SCHEMA) {
|
|
14301
|
+
throw new Error(`Couldn't import takeoff: not a takeoff export (expected schema "${TAKEOFF_SCHEMA}" \u2014 the file export_takeoff or the app writes).`);
|
|
14219
14302
|
}
|
|
14220
14303
|
return doc;
|
|
14221
14304
|
}
|
|
14222
|
-
var
|
|
14305
|
+
var arr2 = (v) => Array.isArray(v) ? v : [];
|
|
14223
14306
|
var tagKey = (t) => String(t || "").trim().toUpperCase();
|
|
14224
14307
|
var freeId = (id, taken) => {
|
|
14225
14308
|
let n = 2, next = id;
|
|
@@ -14228,11 +14311,11 @@ var freeId = (id, taken) => {
|
|
|
14228
14311
|
};
|
|
14229
14312
|
function mergeTakeoffImport(current, imported, knownFiles = null) {
|
|
14230
14313
|
const cur = current && typeof current === "object" ? current : {};
|
|
14231
|
-
const impShapes =
|
|
14232
|
-
const impConds =
|
|
14233
|
-
const localScales = new Map(
|
|
14234
|
-
const incomingScales = new Map(
|
|
14235
|
-
const existingIds = new Set(
|
|
14314
|
+
const impShapes = arr2(imported.shapes).filter((s) => s && typeof s === "object" && typeof s.sheet_id === "string" && typeof s.id === "string").map(normalizeAgentReview);
|
|
14315
|
+
const impConds = arr2(imported.conditions).filter((c) => c && typeof c === "object" && typeof c.id === "string");
|
|
14316
|
+
const localScales = new Map(arr2(cur.sheets).filter((s) => typeof s?.sheet_id === "string").map((s) => [s.sheet_id, s.units_per_px]));
|
|
14317
|
+
const incomingScales = new Map(arr2(imported.sheets).filter((s) => typeof s?.sheet_id === "string").map((s) => [s.sheet_id, s.units_per_px]));
|
|
14318
|
+
const existingIds = new Set(arr2(cur.shapes).map((s) => s.id));
|
|
14236
14319
|
for (const s of impShapes) {
|
|
14237
14320
|
if (existingIds.has(s.id) || s.measure_role === "count") continue;
|
|
14238
14321
|
const local = localScales.get(s.sheet_id), incoming = incomingScales.get(s.sheet_id);
|
|
@@ -14246,20 +14329,20 @@ function mergeTakeoffImport(current, imported, knownFiles = null) {
|
|
|
14246
14329
|
return [...new Set(added.map((s) => String(s.sheet_id).split("#")[0]).filter((f) => !known.has(f)))];
|
|
14247
14330
|
};
|
|
14248
14331
|
const pendingCount = (shapes) => shapes.filter((s) => s.origin?.reviewed === false).length;
|
|
14249
|
-
if (!
|
|
14332
|
+
if (!arr2(cur.shapes).length && !arr2(cur.markups).length && !arr2(cur.approvals).length && !arr2(cur.sheets).some((s) => s?.units_per_px > 0)) {
|
|
14250
14333
|
const payload2 = {
|
|
14251
14334
|
...imported,
|
|
14252
14335
|
shapes: impShapes,
|
|
14253
|
-
...
|
|
14254
|
-
...
|
|
14255
|
-
...
|
|
14336
|
+
...arr2(imported.sheet_tabs).length ? {} : { sheet_tabs: arr2(cur.sheet_tabs) },
|
|
14337
|
+
...arr2(imported.sheet_group).length ? {} : { sheet_group: arr2(cur.sheet_group) },
|
|
14338
|
+
...arr2(imported.last_group).length ? {} : { last_group: arr2(cur.last_group) }
|
|
14256
14339
|
};
|
|
14257
14340
|
return {
|
|
14258
14341
|
payload: payload2,
|
|
14259
|
-
note: { replaced: true, shapes_added: impShapes.length, shapes_pending: pendingCount(impShapes), conditions_merged: 0, conditions_added: impConds.length, scales_adopted:
|
|
14342
|
+
note: { replaced: true, shapes_added: impShapes.length, shapes_pending: pendingCount(impShapes), conditions_merged: 0, conditions_added: impConds.length, scales_adopted: arr2(imported.sheets).length, unknown_files: unknownFiles(impShapes) }
|
|
14260
14343
|
};
|
|
14261
14344
|
}
|
|
14262
|
-
const conditions = [...
|
|
14345
|
+
const conditions = [...arr2(cur.conditions)];
|
|
14263
14346
|
const byTag = new Map(conditions.map((c) => [tagKey(c.finish_tag), c.id]));
|
|
14264
14347
|
const condIds = new Set(conditions.map((c) => c.id));
|
|
14265
14348
|
const condMap = /* @__PURE__ */ new Map();
|
|
@@ -14295,23 +14378,23 @@ function mergeTakeoffImport(current, imported, knownFiles = null) {
|
|
|
14295
14378
|
conditions[i] = next;
|
|
14296
14379
|
}
|
|
14297
14380
|
}
|
|
14298
|
-
const shapeIds = new Set(
|
|
14381
|
+
const shapeIds = new Set(arr2(cur.shapes).map((s) => s.id));
|
|
14299
14382
|
const addedShapes = impShapes.filter((s) => !shapeIds.has(s.id)).map((s) => condMap.has(s.condition_id) && condMap.get(s.condition_id) !== s.condition_id ? { ...s, condition_id: condMap.get(s.condition_id) } : s);
|
|
14300
|
-
const markupIds = new Set(
|
|
14301
|
-
const addedMarkups =
|
|
14302
|
-
const rfiIds = new Set(
|
|
14303
|
-
const addedRfis =
|
|
14304
|
-
const proposalIds = new Set(
|
|
14305
|
-
const addedProposals =
|
|
14306
|
-
const editIds = new Set(
|
|
14383
|
+
const markupIds = new Set(arr2(cur.markups).map((m) => m?.id).filter(Boolean));
|
|
14384
|
+
const addedMarkups = arr2(imported.markups).filter((m) => m && typeof m === "object" && (!m.id || !markupIds.has(m.id)));
|
|
14385
|
+
const rfiIds = new Set(arr2(cur.rfis).map((r) => r?.id).filter(Boolean));
|
|
14386
|
+
const addedRfis = arr2(imported.rfis).filter((r) => r && typeof r === "object" && r.id && !rfiIds.has(r.id));
|
|
14387
|
+
const proposalIds = new Set(arr2(cur.proposals).map((p) => p?.id).filter(Boolean));
|
|
14388
|
+
const addedProposals = arr2(imported.proposals).filter((p) => p && typeof p === "object" && typeof p.id === "string" && !proposalIds.has(p.id));
|
|
14389
|
+
const editIds = new Set(arr2(cur.condition_edit_proposals).map((p) => p?.id).filter(Boolean));
|
|
14307
14390
|
const takenTags = new Set(conditions.map((c) => tagKey(c.finish_tag)));
|
|
14308
|
-
const addedEdits =
|
|
14309
|
-
const approvalIds = new Set(
|
|
14391
|
+
const addedEdits = arr2(imported.condition_edit_proposals).filter((p) => p && typeof p === "object" && typeof p.id === "string" && !editIds.has(p.id) && p.proposed && typeof p.proposed === "object").map((p) => condMap.has(p.condition_id) ? { ...p, condition_id: condMap.get(p.condition_id) } : p).filter((p) => condIds.has(p.condition_id)).filter((p) => p.proposed.finish_tag === void 0 || !takenTags.has(tagKey(p.proposed.finish_tag)) || tagKey(p.proposed.finish_tag) === tagKey(conditions.find((c) => c.id === p.condition_id)?.finish_tag)).filter((p) => !arr2(cur.condition_edit_proposals).some((q) => q?.condition_id === p.condition_id));
|
|
14392
|
+
const approvalIds = new Set(arr2(cur.approvals).map((a) => a?.id).filter(Boolean));
|
|
14310
14393
|
const addedApprovals = sanitizeApprovals(imported.approvals).filter((a) => !approvalIds.has(a.id));
|
|
14311
|
-
const sheets = [...
|
|
14394
|
+
const sheets = [...arr2(cur.sheets)];
|
|
14312
14395
|
const scaled = new Set(sheets.map((s) => s?.sheet_id));
|
|
14313
14396
|
let scalesAdopted = 0;
|
|
14314
|
-
for (const s of
|
|
14397
|
+
for (const s of arr2(imported.sheets)) {
|
|
14315
14398
|
if (s && typeof s === "object" && s.sheet_id && s.units_per_px && !scaled.has(s.sheet_id)) {
|
|
14316
14399
|
sheets.push(s);
|
|
14317
14400
|
scaled.add(s.sheet_id);
|
|
@@ -14321,12 +14404,12 @@ function mergeTakeoffImport(current, imported, knownFiles = null) {
|
|
|
14321
14404
|
const payload = {
|
|
14322
14405
|
...cur,
|
|
14323
14406
|
conditions,
|
|
14324
|
-
shapes: [...
|
|
14325
|
-
markups: [...
|
|
14326
|
-
...addedRfis.length ? { rfis: [...
|
|
14327
|
-
...addedProposals.length ? { proposals: [...
|
|
14328
|
-
...addedEdits.length ? { condition_edit_proposals: [...
|
|
14329
|
-
...addedApprovals.length ? { approvals: [...
|
|
14407
|
+
shapes: [...arr2(cur.shapes), ...addedShapes],
|
|
14408
|
+
markups: [...arr2(cur.markups), ...addedMarkups],
|
|
14409
|
+
...addedRfis.length ? { rfis: [...arr2(cur.rfis), ...addedRfis] } : {},
|
|
14410
|
+
...addedProposals.length ? { proposals: [...arr2(cur.proposals), ...addedProposals] } : {},
|
|
14411
|
+
...addedEdits.length ? { condition_edit_proposals: [...arr2(cur.condition_edit_proposals), ...addedEdits] } : {},
|
|
14412
|
+
...addedApprovals.length ? { approvals: [...arr2(cur.approvals), ...addedApprovals] } : {},
|
|
14330
14413
|
sheets
|
|
14331
14414
|
};
|
|
14332
14415
|
return {
|
|
@@ -15055,7 +15138,7 @@ No review gate: the pencil-not-ink rule exists to stop an agent inventing geomet
|
|
|
15055
15138
|
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15056
15139
|
|
|
15057
15140
|
// src/wiki.generated.ts
|
|
15058
|
-
var WIKI_VERSION = "0.9.
|
|
15141
|
+
var WIKI_VERSION = "0.9.86";
|
|
15059
15142
|
var WIKI_PAGES = [
|
|
15060
15143
|
{
|
|
15061
15144
|
"key": "index",
|
|
@@ -15118,8 +15201,8 @@ var WIKI_PAGES = [
|
|
|
15118
15201
|
"uri": "takeoff://wiki/repo-guide",
|
|
15119
15202
|
"title": "Working on the repository",
|
|
15120
15203
|
"source": "docs/wiki/repo-guide.md",
|
|
15121
|
-
"source_sha256": "
|
|
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 # web/.nvmrc matches the root .nvmrc used by CI\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 # web typecheck + lint + test + benchmark + build\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\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 covers the independent web\n job on Node 24; a green web check does not replace the MCP, protocol, docs,\n capture or optional-server jobs.\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** also requires checking its runtime guidance and\ngenerated references:\n\n4. `mcp/src/tools.ts`\u2014the tool's own `description` is part of its integration.\n An MCP client reads it at runtime. Initialize instructions and packaged wiki\n resources are also agent-facing surfaces, so keep them consistent with the\n runtime behavior.\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. Packaged wiki\n resources are another deliberate agent-facing surface; update their source\n pages and run the wiki/resource checks when their guidance changes.\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 fields must agree.** The MCP source version is\n in `mcp/package.json`; its lockfile has two matching fields, while\n `mcp/server.json` and `web/public/.well-known/mcp.json` each have two. The\n version checker checks all seven MCP fields. Separately, it checks the web\n package version against the two root-version fields in its lockfile.\n Check current `origin/main` and existing tags before\n reserving a number. The web package has its own version; it is not the MCP\n release number.\n8. `npm run check:tool-count --prefix mcp` runs version agreement before checking\n counts and the schema-backed inventory. With `-- --write`, it refuses version\n mismatches before changing generated documents; update release metadata\n explicitly, then regenerate. Run `npm run check:versions --prefix mcp` for the\n version check alone. Wiki content has its own generator:\n `npm run check:wiki --prefix mcp -- --write`. Review generated diffs and run\n both checks without `--write` afterward.\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"
|
|
15204
|
+
"source_sha256": "9ef949a3dca1f992a005764a825ca86b5f4da1fd631e9eed1011b8d6fe967ab3",
|
|
15205
|
+
"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 # web/.nvmrc matches the root .nvmrc used by CI\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 # web typecheck + lint + test + benchmark + build\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\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 covers the independent web\n job on Node 24; a green web check does not replace the MCP, protocol, docs,\n capture or optional-server jobs.\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` and `HATCH_IDS` in `web/src/lib/takeoffConstants.ts`, which both the canvas and `mcp/src/session.ts` import, and the seeded condition colors in `FLOORING_DEFAULTS` in `web/src/lib/canvasConstants.js`) are **user data**\u2014don't re-theme them. The same module owns the takeoff and report schema ids, `RENDER_SCALE` and the snap constants; parity tests in `web/test`, `mcp/test` and `protocol/test` fail if a copy reappears.\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** also requires checking its runtime guidance and\ngenerated references:\n\n4. `mcp/src/tools.ts`\u2014the tool's own `description` is part of its integration.\n An MCP client reads it at runtime. Initialize instructions and packaged wiki\n resources are also agent-facing surfaces, so keep them consistent with the\n runtime behavior.\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. Packaged wiki\n resources are another deliberate agent-facing surface; update their source\n pages and run the wiki/resource checks when their guidance changes.\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 fields must agree.** The MCP source version is\n in `mcp/package.json`; its lockfile has two matching fields, while\n `mcp/server.json` and `web/public/.well-known/mcp.json` each have two. The\n version checker checks all seven MCP fields. Separately, it checks the web\n package version against the two root-version fields in its lockfile.\n Check current `origin/main` and existing tags before\n reserving a number. The web package has its own version; it is not the MCP\n release number.\n8. `npm run check:tool-count --prefix mcp` runs version agreement before checking\n counts and the schema-backed inventory. With `-- --write`, it refuses version\n mismatches before changing generated documents; update release metadata\n explicitly, then regenerate. Run `npm run check:versions --prefix mcp` for the\n version check alone. Wiki content has its own generator:\n `npm run check:wiki --prefix mcp -- --write`. Review generated diffs and run\n both checks without `--write` afterward.\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
15206
|
},
|
|
15124
15207
|
{
|
|
15125
15208
|
"key": "tool-index",
|
|
@@ -15493,7 +15576,7 @@ function nameTheStageInRefusals(server) {
|
|
|
15493
15576
|
// package.json
|
|
15494
15577
|
var package_default = {
|
|
15495
15578
|
name: "opentakeoff-mcp",
|
|
15496
|
-
version: "0.9.
|
|
15579
|
+
version: "0.9.86",
|
|
15497
15580
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
15498
15581
|
type: "module",
|
|
15499
15582
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -15513,7 +15596,7 @@ var package_default = {
|
|
|
15513
15596
|
"check:wiki": "node scripts/check-wiki.mjs",
|
|
15514
15597
|
"check:versions": "node ../scripts/check-version-consistency.mjs",
|
|
15515
15598
|
"check:tool-count": "node --import tsx scripts/check-tool-count.mjs",
|
|
15516
|
-
test: "node --import tsx --test test/checkers.test.ts 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"
|
|
15599
|
+
test: "node --import tsx --test test/checkers.test.ts test/conformance.test.ts test/constants.test.ts test/context.test.ts test/document.test.ts test/dxf.test.ts test/e2e.test.ts test/gate.test.ts test/graphEvalRowsym.test.ts test/labels.test.ts test/mep.test.ts test/overlap.test.ts test/parity.test.ts test/proposals.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/scope.test.ts test/session.test.ts test/staging.test.ts test/sweepguard.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts test/wiki.test.ts"
|
|
15517
15600
|
},
|
|
15518
15601
|
dependencies: {
|
|
15519
15602
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opentakeoff-mcp",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.86",
|
|
4
4
|
"mcpName": "io.github.Kentucky-ai/opentakeoff",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "OpenTakeoff MCP server — drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"check:wiki": "node scripts/check-wiki.mjs",
|
|
21
21
|
"check:versions": "node ../scripts/check-version-consistency.mjs",
|
|
22
22
|
"check:tool-count": "node --import tsx scripts/check-tool-count.mjs",
|
|
23
|
-
"test": "node --import tsx --test test/checkers.test.ts test/conformance.test.ts test/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"
|
|
23
|
+
"test": "node --import tsx --test test/checkers.test.ts test/conformance.test.ts test/constants.test.ts test/context.test.ts test/document.test.ts test/dxf.test.ts test/e2e.test.ts test/gate.test.ts test/graphEvalRowsym.test.ts test/labels.test.ts test/mep.test.ts test/overlap.test.ts test/parity.test.ts test/proposals.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/scope.test.ts test/session.test.ts test/staging.test.ts test/sweepguard.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts test/wiki.test.ts"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@modelcontextprotocol/sdk": "^1.12.0",
|