@floless/app 0.61.0 → 0.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,441 @@
1
+ /*
2
+ * grid-core.js — PURE, framework-free structural-grid core (no DOM, no Three.js). Unit-tested in
3
+ * node (server/grid-core.test.ts) AND loaded by the steel editor's 2D plan mode + 3D view.
4
+ *
5
+ * The grid is Tekla-style but friendlier: X/Y spacings are RELATIVE coordinate strings
6
+ * ("0 3*25' 20'-6\"" — each entry is the distance from the previous line, n*d repeats a bay),
7
+ * Z levels are ABSOLUTE elevations. Tokens accept ft-in (25'-6"), bare inches (6"), metres (7.5m)
8
+ * and bare numbers as mm — mixed freely. Everything parses to canonical mm.
9
+ *
10
+ * Contract shape (PER-PLAN, plan.grid — origin/spacings live in that sheet's display space, like
11
+ * member wp / frame / dims, so it rides the per-plan undo stack safely):
12
+ * { on, origin:[x,y] px, x:"0 3*25'", y:"0 4*20'", z:"0", labels_x:"", labels_y:"", ext: mm }
13
+ * X entries position lines ALONG +X (the lines run vertically on the plan), labelled 1,2,3… ;
14
+ * Y entries march UP the sheet (display -Y == scene +Y), lines horizontal, labelled A,B,C… .
15
+ */
16
+
17
+ export const IN_MM = 25.4;
18
+ export const FT_MM = 304.8;
19
+
20
+ /** mm per display px for a plan scale (matches steel-3d-core kOf). */
21
+ export const mmPerPx = (ptPerFt) => FT_MM / (ptPerFt > 0 ? ptPerFt : 1);
22
+
23
+ /** One length token → mm, or NaN. Accepts 25' | 25'-6" | 25'6" | 6" | 7.5m | 6096 (mm) | 6096mm; leading '-' negates. */
24
+ export function parseLen(tok) {
25
+ let s = String(tok == null ? '' : tok).trim();
26
+ if (!s) return NaN;
27
+ let sign = 1;
28
+ if (s[0] === '-') { sign = -1; s = s.slice(1); }
29
+ else if (s[0] === '+') s = s.slice(1);
30
+ let m;
31
+ if ((m = /^(\d+(?:\.\d+)?)'(?:-?(\d+(?:\.\d+)?)")?$/.exec(s))) return sign * (+m[1] * FT_MM + (m[2] ? +m[2] * IN_MM : 0));
32
+ if ((m = /^(\d+(?:\.\d+)?)"$/.exec(s))) return sign * +m[1] * IN_MM;
33
+ if ((m = /^(\d+(?:\.\d+)?)m$/.exec(s))) return sign * +m[1] * 1000;
34
+ if ((m = /^(\d+(?:\.\d+)?)(?:mm)?$/.exec(s))) return sign * +m[1];
35
+ return NaN;
36
+ }
37
+
38
+ /** mm → a clean display token: ft-in when it lands on a 1/16", else integer mm. Round-trips through parseLen. */
39
+ export function fmtLen(mm) {
40
+ if (Math.abs(mm) < 0.05) return '0';
41
+ const neg = mm < 0, a = Math.abs(mm);
42
+ const sixteenths = Math.round(a / (IN_MM / 16));
43
+ if (sixteenths > 0 && Math.abs(a - sixteenths * (IN_MM / 16)) < 0.05) { // lands on a 1/16" → ft-in reads native for US drawings
44
+ const inches = sixteenths / 16;
45
+ const ft = Math.floor(inches / 12);
46
+ const rest = inches - ft * 12; // may be fractional — decimal inches, since tokens can't contain spaces
47
+ const restStr = String(Math.round(rest * 16) / 16) + '"';
48
+ const body = ft > 0 ? (ft + "'" + (rest > 0 ? '-' + restStr : '')) : restStr;
49
+ return (neg ? '-' : '') + body;
50
+ }
51
+ return (neg ? '-' : '') + Math.round(a);
52
+ }
53
+
54
+ /**
55
+ * A relative spacing string → cumulative offsets (mm, from the grid origin).
56
+ * "0 3*25' 20'" → [0, 7620, 15240, 22860, 28956]. Separators: whitespace / commas; repeat: n*d or nxd.
57
+ * The first entry is the first line's offset (any sign); later entries must be > 0.
58
+ * Returns { offsets, err } — err is a human-readable reason (offsets = [] then).
59
+ */
60
+ export function parseSpacings(str) {
61
+ const toks = String(str == null ? '' : str).trim().split(/[\s,]+/).filter(Boolean);
62
+ if (!toks.length) return { offsets: [], err: null }; // empty = a valid axis-less grid (X-only / Y-only)
63
+ const offsets = [];
64
+ let at = 0;
65
+ for (let i = 0; i < toks.length; i++) {
66
+ const rep = /^(\d+)[*xX](.+)$/.exec(toks[i]);
67
+ const n = rep ? +rep[1] : 1;
68
+ const d = parseLen(rep ? rep[2] : toks[i]);
69
+ if (!isFinite(d) || (rep && n < 1)) return { offsets: [], err: 'can’t read “' + toks[i] + '”' };
70
+ const isFirstOffset = (i === 0 && !rep); // only the first plain entry may be ≤ 0 (the origin offset)
71
+ if (!isFirstOffset && d <= 0) return { offsets: [], err: 'spacing “' + toks[i] + '” must be > 0' };
72
+ for (let r = 0; r < n; r++) { at += d; offsets.push(at); }
73
+ }
74
+ return { offsets, err: null };
75
+ }
76
+
77
+ /** Cumulative offsets (mm) → a compact relative string ("0 3*25' 20'"): equal runs compress to n*d. */
78
+ export function formatSpacings(offsets) {
79
+ if (!offsets || !offsets.length) return '';
80
+ const diffs = offsets.map((v, i) => v - (i ? offsets[i - 1] : 0));
81
+ const out = [];
82
+ for (let i = 0; i < diffs.length;) {
83
+ let j = i + 1;
84
+ while (j < diffs.length && Math.abs(diffs[j] - diffs[i]) < 0.5) j++;
85
+ const n = j - i, tok = fmtLen(diffs[i]);
86
+ out.push(n > 1 ? n + '*' + tok : tok);
87
+ i = j;
88
+ }
89
+ return out.join(' ');
90
+ }
91
+
92
+ /** Auto labels: style 'num' → 1,2,3… ; 'alpha' → A…Z, AA, AB… */
93
+ export function autoLabels(style, n) {
94
+ const out = [];
95
+ for (let i = 0; i < n; i++) {
96
+ if (style === 'num') { out.push(String(i + 1)); continue; }
97
+ let s = '', k = i;
98
+ do { s = String.fromCharCode(65 + (k % 26)) + s; k = Math.floor(k / 26) - 1; } while (k >= 0);
99
+ out.push(s);
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /** n labels for an axis: whitespace-separated overrides first, auto-continue past them. */
105
+ export function labelsFor(override, style, n) {
106
+ const own = String(override || '').trim().split(/\s+/).filter(Boolean);
107
+ const auto = autoLabels(style, n);
108
+ return auto.map((a, i) => own[i] != null ? own[i] : a);
109
+ }
110
+
111
+ /** A fresh default grid (display-space origin px). 4×4 30' bays, ground level only. */
112
+ export function defaultGrid(origin) {
113
+ return { on: true, origin: (origin || [0, 0]).slice(0, 2), x: "0 3*30'", y: "0 3*30'", z: '0', labels_x: '', labels_y: '', ext: 5 * FT_MM };
114
+ }
115
+
116
+ /** True when g is structurally usable (parseable origin — the spacing strings report their own errors). */
117
+ export function sane(g) {
118
+ return !!(g && Array.isArray(g.origin) && g.origin.length >= 2 && isFinite(g.origin[0]) && isFinite(g.origin[1]));
119
+ }
120
+
121
+ /**
122
+ * The grid resolved to display-space plan geometry.
123
+ * v = vertical lines (spaced along +X, labels 1,2,3…): { x, label }
124
+ * h = horizontal lines (spaced UP the sheet = -Y, labels A,B,C…): { y, label }
125
+ * zs = levels: { mm, label } (sorted, deduped). ext/extents are display px.
126
+ * errX/errY/errZ carry per-axis parse errors; lines of a failed axis are [].
127
+ */
128
+ export function gridGeometry(g, ptPerFt) {
129
+ const k = mmPerPx(ptPerFt);
130
+ const px = (mm) => mm / k;
131
+ const X = parseSpacings(g && g.x), Y = parseSpacings(g && g.y);
132
+ const ok = sane(g);
133
+ const ox = ok ? g.origin[0] : 0, oy = ok ? g.origin[1] : 0;
134
+ const lx = labelsFor(g && g.labels_x, 'num', X.offsets.length);
135
+ const ly = labelsFor(g && g.labels_y, 'alpha', Y.offsets.length);
136
+ const v = X.offsets.map((off, i) => ({ x: ox + px(off), label: lx[i] }));
137
+ const h = Y.offsets.map((off, i) => ({ y: oy - px(off), label: ly[i] })); // up the sheet = -Y (scene +Y)
138
+ // Z levels: absolute tokens, sorted + deduped, labelled by elevation (or overridden order-preserving)
139
+ const ztoks = String((g && g.z) == null ? '' : g.z).trim().split(/[\s,]+/).filter(Boolean);
140
+ let errZ = null;
141
+ const zmm = [];
142
+ for (const t of ztoks) {
143
+ const val = parseLen(t);
144
+ if (!isFinite(val)) { errZ = 'can’t read “' + t + '”'; break; }
145
+ if (!zmm.some((z) => Math.abs(z - val) < 0.5)) zmm.push(val);
146
+ }
147
+ zmm.sort((a, b) => a - b);
148
+ const zs = errZ ? [] : zmm.map((mm) => ({ mm, label: fmtLen(mm) }));
149
+ const ext = px((g && isFinite(g.ext)) ? g.ext : 5 * FT_MM);
150
+ const xs = v.map((l) => l.x), ys = h.map((l) => l.y);
151
+ const geo = {
152
+ v, h, zs,
153
+ errX: X.err, errY: Y.err, errZ,
154
+ ext,
155
+ x0: (xs.length ? Math.min(...xs) : ox) - ext, x1: (xs.length ? Math.max(...xs) : ox) + ext,
156
+ y0: (ys.length ? Math.min(...ys) : oy) - ext, y1: (ys.length ? Math.max(...ys) : oy) + ext,
157
+ };
158
+ // per-line extent overrides (g.ends_x / g.ends_y: { "<index>": [lo|null, hi|null] } display px along
159
+ // the line — a real sheet has partial lines like a 5.1 covering one wing). null / absent = the auto
160
+ // full span. Every consumer (2D render, snapping, 3D) reads l.lo / l.hi.
161
+ for (const [i, l] of v.entries()) {
162
+ const o = (g && g.ends_x && g.ends_x[i]) || [];
163
+ l.lo = o[0] != null && isFinite(o[0]) ? +o[0] : geo.y0;
164
+ l.hi = o[1] != null && isFinite(o[1]) ? +o[1] : geo.y1;
165
+ }
166
+ for (const [i, l] of h.entries()) {
167
+ const o = (g && g.ends_y && g.ends_y[i]) || [];
168
+ l.lo = o[0] != null && isFinite(o[0]) ? +o[0] : geo.x0;
169
+ l.hi = o[1] != null && isFinite(o[1]) ? +o[1] : geo.x1;
170
+ }
171
+ return geo;
172
+ }
173
+
174
+ /**
175
+ * Drag one END of a grid line along its axis (extend/shorten — the bubbles are the grips).
176
+ * end 0 = the lo end (top of a v-line / left of an h-line), 1 = hi. Clamps to a 12" minimum line and
177
+ * snaps BACK TO AUTO when released within ~6px of the full-span extent (the override is deleted, so
178
+ * the end keeps following the grid when lines are added later). Mutates g; returns the applied
179
+ * position, or null when there's no such line.
180
+ * ponytail: overrides are keyed by line INDEX — editing the spacing string re-maps them positionally.
181
+ */
182
+ export function moveGridLineEnd(g, ptPerFt, axis, i, end, np) {
183
+ const geo = gridGeometry(g, ptPerFt);
184
+ const line = (axis === 'v' ? geo.v : geo.h)[i];
185
+ if (!line) return null;
186
+ const auto = axis === 'v' ? [geo.y0, geo.y1] : [geo.x0, geo.x1];
187
+ const k = mmPerPx(ptPerFt);
188
+ const MINLEN = 12 * IN_MM / k; // 12" minimum line length in px
189
+ const other = end === 0 ? line.hi : line.lo;
190
+ np = end === 0 ? Math.min(np, other - MINLEN) : Math.max(np, other + MINLEN);
191
+ const key = axis === 'v' ? 'ends_x' : 'ends_y';
192
+ if (!g[key]) g[key] = {};
193
+ const cur = g[key][i] || [null, null];
194
+ const backToAuto = Math.abs(np - auto[end]) < 6; // magnetic default: near the full span → drop the override
195
+ cur[end] = backToAuto ? null : np;
196
+ if (cur[0] == null && cur[1] == null) { delete g[key][i]; if (!Object.keys(g[key]).length) delete g[key]; }
197
+ else g[key][i] = cur;
198
+ return backToAuto ? auto[end] : np;
199
+ }
200
+
201
+ /** 2D snap fodder from resolved geometry: every grid line as a segment (its OWN span) + every
202
+ * real intersection (both lines actually reach the crossing) as a point. */
203
+ export function gridSnapGeom(geo) {
204
+ const segs = [], pts = [];
205
+ for (const l of geo.v) segs.push([[l.x, l.lo], [l.x, l.hi]]);
206
+ for (const l of geo.h) segs.push([[l.lo, l.y], [l.hi, l.y]]);
207
+ for (const a of geo.v) for (const b of geo.h) {
208
+ if (b.y >= a.lo && b.y <= a.hi && a.x >= b.lo && a.x <= b.hi) pts.push([a.x, b.y]);
209
+ }
210
+ return { segs, pts };
211
+ }
212
+
213
+ /**
214
+ * 3D snap candidates (scene mm, Z-up — same map as steel-3d-core dispToMm: mmX = x·k, mmY = -y·k).
215
+ * Emitted PLAN-ONLY on purpose: 'grid-line' / 'grid-int' snap X/Y and keep the dragged Z, so a grid
216
+ * can never yank a member off its elevation (candidatePoint in steel-3d-core honors that).
217
+ */
218
+ export function gridCandidates3d(g, ptPerFt) {
219
+ if (!g || !g.on) return [];
220
+ const geo = gridGeometry(g, ptPerFt);
221
+ const k = mmPerPx(ptPerFt);
222
+ const out = [];
223
+ for (const l of geo.v) out.push({ type: 'grid-line', a: [l.x * k, -l.hi * k, 0], b: [l.x * k, -l.lo * k, 0], fromId: 'grid-' + l.label });
224
+ for (const l of geo.h) out.push({ type: 'grid-line', a: [l.lo * k, -l.y * k, 0], b: [l.hi * k, -l.y * k, 0], fromId: 'grid-' + l.label });
225
+ for (const a of geo.v) for (const b of geo.h) {
226
+ if (b.y >= a.lo && b.y <= a.hi && a.x >= b.lo && a.x <= b.hi) out.push({ type: 'grid-int', p: [a.x * k, -b.y * k, 0], fromId: 'grid-' + a.label + '/' + b.label });
227
+ }
228
+ return out;
229
+ }
230
+
231
+ /**
232
+ * Slide one grid line to a new display position (the editor's direct-manipulation drag).
233
+ * axis 'v' = a vertical line (position = display x), 'h' = horizontal (display y). Mutates g:
234
+ * clamps between neighbours (1" minimum bay — a drag can never reorder lines or scramble labels),
235
+ * rebases the origin when the FIRST line moves (the other lines keep their world positions), and
236
+ * rewrites the spacing string with offsets snapped to 1/16". Returns the clamped position (px),
237
+ * or null when the grid/axis has no such line.
238
+ * `base` (optional) = the axis's line positions captured at DRAG START — pass it on every move of
239
+ * one continuous drag so the string is quantized ONCE from the originals; re-deriving from the
240
+ * just-written string each mousemove lets 1/16" rounding creep onto lines that never moved.
241
+ */
242
+ export function moveGridLine(g, ptPerFt, axis, i, np, base) {
243
+ const geo = gridGeometry(g, ptPerFt);
244
+ const k = mmPerPx(ptPerFt);
245
+ const snap16 = (mm) => Math.round(mm / (IN_MM / 16)) * (IN_MM / 16);
246
+ const MIN = IN_MM / k; // 1" in display px
247
+ if (axis === 'v') {
248
+ if (!geo.v[i] && !(base && base[i] != null)) return null;
249
+ const xs = base ? base.slice() : geo.v.map((l) => l.x); // ascending
250
+ np = Math.max(i > 0 ? xs[i - 1] + MIN : -Infinity, Math.min(i < xs.length - 1 ? xs[i + 1] - MIN : Infinity, np));
251
+ xs[i] = np;
252
+ g.origin[0] = xs[0];
253
+ g.x = formatSpacings(xs.map((x) => snap16((x - xs[0]) * k)));
254
+ } else {
255
+ if (!geo.h[i] && !(base && base[i] != null)) return null;
256
+ const ys = base ? base.slice() : geo.h.map((l) => l.y); // bottom→up: DESCENDING display y (h[0] = the origin line)
257
+ np = Math.max(i < ys.length - 1 ? ys[i + 1] + MIN : -Infinity, Math.min(i > 0 ? ys[i - 1] - MIN : Infinity, np));
258
+ ys[i] = np;
259
+ g.origin[1] = ys[0];
260
+ g.y = formatSpacings(ys.map((y) => snap16((ys[0] - y) * k)));
261
+ }
262
+ return np;
263
+ }
264
+
265
+ // ---- grid detection from the DRAWING itself (the steel filter's per-sheet elements) ----
266
+ // CAD PDFs draw each dash of a dash-dot grid line as its own tiny segment, and the bubble labels are
267
+ // usually outlined text (not machine-readable). So detection is structural: bucket collinear
268
+ // axis-aligned segments into long "line fields", then ANCHOR each candidate to a grid bubble — a
269
+ // circle at the line's end (kind 'circle', emitted by the reader's PDF pass) or a short label-like
270
+ // text span there. Anchoring is what separates grid lines from borders/leaders/hatch. When a text
271
+ // layer exists the bubble labels come through verbatim (incl. 5.1 / K.1 style inserts).
272
+
273
+ const _seg = (e) => {
274
+ const s = e.x1 != null
275
+ ? [+e.x1, +e.y1, +e.x2, +e.y2]
276
+ : (() => { const m = /M\s*(-?[\d.]+)[\s,]+(-?[\d.]+)\s*L\s*(-?[\d.]+)[\s,]+(-?[\d.]+)/.exec(e.d || ''); return m ? [+m[1], +m[2], +m[3], +m[4]] : null; })();
277
+ return s && s.every(isFinite) ? s : null; // a half-formed element must not poison the bbox with NaN
278
+ };
279
+
280
+ /** Looks like a grid label: "1", "12", "5.1", "A", "AB", "K.1", "F.2". */
281
+ export function gridLabelLike(t) {
282
+ return /^([A-Z]{1,2}|\d{1,3})(\.\d)?$/.test(String(t == null ? '' : t).trim());
283
+ }
284
+
285
+ /**
286
+ * Detect the structural grid in a sheet's drawing elements (display space).
287
+ * elements: filter-style [{kind:'line', d|x1..y2}, {kind:'circle', cx,cy,r}, {kind:'text', text,bbox}].
288
+ * Returns { grid, info:{ v, h, anchored:'circles'|'text'|'geometry', labeled } } or null.
289
+ */
290
+ export function gridFromDrawing(elements, ptPerFt) {
291
+ const k = mmPerPx(ptPerFt);
292
+ const circles = [], texts = [];
293
+ const vb = new Map(), hb = new Map(); // rounded pos -> { lo, hi, ink, n, sum }
294
+ let bx0 = Infinity, by0 = Infinity, bx1 = -Infinity, by1 = -Infinity;
295
+ const bucket = (map, pos, lo, hi) => {
296
+ const key = Math.round(pos / 2) * 2; // 2px cells; near-misses merge below
297
+ let b = map.get(key);
298
+ if (!b) map.set(key, b = { lo, hi, ink: 0, n: 0, sum: 0 });
299
+ b.lo = Math.min(b.lo, lo); b.hi = Math.max(b.hi, hi);
300
+ b.ink += hi - lo; b.n++; b.sum += pos;
301
+ };
302
+ for (const e of elements || []) {
303
+ if (e.kind === 'circle' && e.r > 3) { circles.push(e); continue; }
304
+ if (e.kind === 'text' && gridLabelLike(e.text) && Array.isArray(e.bbox)) { texts.push(e); continue; }
305
+ if (e.kind !== 'line') continue;
306
+ const s = _seg(e);
307
+ if (!s) continue;
308
+ const [x1, y1, x2, y2] = s;
309
+ bx0 = Math.min(bx0, x1, x2); bx1 = Math.max(bx1, x1, x2);
310
+ by0 = Math.min(by0, y1, y2); by1 = Math.max(by1, y1, y2);
311
+ const dx = Math.abs(x2 - x1), dy = Math.abs(y2 - y1);
312
+ if (dx <= 1.2 && dy > 0.4) bucket(vb, (x1 + x2) / 2, Math.min(y1, y2), Math.max(y1, y2));
313
+ else if (dy <= 1.2 && dx > 0.4) bucket(hb, (y1 + y2) / 2, Math.min(x1, x2), Math.max(x1, x2));
314
+ }
315
+ if (!isFinite(bx0)) return null;
316
+ const W = bx1 - bx0, H = by1 - by0;
317
+ // Grid bubbles line up: the labels of one direction share a row (v-lines) or a column (h-lines)
318
+ // along the sheet edge. Scattered same-size circles (section/detail callouts) don't. So anchors
319
+ // are only circles/texts belonging to a COLLINEAR cluster of ≥3 — that's the discriminator that
320
+ // makes the detector precise on real sheets.
321
+ const clusterBy = (items, coord) => { // collinear runs of ≥3, kept as separate clusters
322
+ const sorted = items.map((it) => ({ it, c: coord(it) })).sort((a, b) => a.c - b.c);
323
+ const out = [];
324
+ let run = [];
325
+ const flush = () => { if (run.length >= 3) out.push(run.map((r) => r.it)); run = []; };
326
+ for (const s of sorted) {
327
+ if (run.length && s.c - run[run.length - 1].c > 8) flush();
328
+ run.push(s);
329
+ }
330
+ flush();
331
+ return out;
332
+ };
333
+ const textC = (t) => ({ cx: (t.bbox[0] + t.bbox[2]) / 2, cy: (t.bbox[1] + t.bbox[3]) / 2, r: Math.max(t.bbox[2] - t.bbox[0], t.bbox[3] - t.bbox[1]) * 0.9, text: t.text.trim() });
334
+ const vClusters = [...clusterBy(circles, (c) => c.cy), ...clusterBy(texts.map(textC), (c) => c.cy)]; // bubble ROW → anchors vertical lines
335
+ const hClusters = [...clusterBy(circles, (c) => c.cx), ...clusterBy(texts.map(textC), (c) => c.cx)]; // bubble COLUMN → anchors horizontal lines
336
+ const labelIn = (c) => {
337
+ if (c.text) return c.text;
338
+ for (const t of texts) {
339
+ const tc = textC(t);
340
+ if (Math.hypot(tc.cx - c.cx, tc.cy - c.cy) < c.r) return tc.text;
341
+ }
342
+ return '';
343
+ };
344
+ // Binding rule (measured on a real sheet): a grid line AIMS at its bubble's centre (across-offset
345
+ // ≈ 0, impostor parallels sit ≥4px off) and STOPS at the bubble's edge (end-gap ≈ r; impostors
346
+ // 1.3r–2r). One line per bubble, best aim wins. A cluster of circles only counts as a bubble
347
+ // row/column when most of its members bind — scattered callout circles that happen to share a
348
+ // coordinate bind almost nothing and drop out wholesale.
349
+ const harvest = (map, vertical) => {
350
+ const clusters = vertical ? vClusters : hClusters;
351
+ if (!clusters.length) return [];
352
+ // permissive floor: partial grid lines (an insert like 5.1 covering one wing) are real; the
353
+ // edge-contact binding below is strict enough that short leaders don't survive it
354
+ const minSpan = (vertical ? H : W) * 0.15;
355
+ const fields = [];
356
+ for (const [, b] of map) {
357
+ if (b.hi - b.lo >= minSpan) fields.push({ pos: b.sum / b.n, lo: b.lo, hi: b.hi });
358
+ }
359
+ const cands = [];
360
+ for (const cluster of clusters) {
361
+ const bound = [];
362
+ for (const c of cluster) {
363
+ const across = vertical ? c.cx : c.cy, along = vertical ? c.cy : c.cx;
364
+ let best = null, bestD = c.r * 0.35;
365
+ for (const f of fields) {
366
+ const gap = Math.min(Math.abs(along - f.lo), Math.abs(along - f.hi));
367
+ if (gap < c.r * 0.4 || gap > c.r * 1.6) continue; // the line stops AT the bubble edge (~r), not inside/far away
368
+ const d = Math.abs(f.pos - across);
369
+ if (d < bestD) { bestD = d; best = f; }
370
+ }
371
+ if (best) bound.push({ pos: best.pos, lo: best.lo, hi: best.hi, label: labelIn(c) });
372
+ }
373
+ if (bound.length >= 3 && bound.length >= cluster.length * 0.5) cands.push(...bound);
374
+ }
375
+ cands.sort((p, q) => p.pos - q.pos);
376
+ // the same physical line anchored from both ends lands in the same 2px bucket → merge only
377
+ // near-identical positions (3px). A wider window would swallow genuinely close insert lines
378
+ // (a 5.1/5.2-style pair can sit a few px apart on a small-scale sheet).
379
+ const merged = [];
380
+ for (const c of cands) {
381
+ const last = merged[merged.length - 1];
382
+ if (last && c.pos - last.pos < 3) { if (!last.label && c.label) last.label = c.label; }
383
+ else merged.push({ ...c });
384
+ }
385
+ return merged;
386
+ };
387
+ const v = harvest(vb, true), h = harvest(hb, false);
388
+ if ((v.length < 2 && h.length < 2) || v.length > 40 || h.length > 40) return null; // nothing grid-like, or garbage-in
389
+ const snap16 = (mm) => Math.round(mm / (IN_MM / 16)) * (IN_MM / 16);
390
+ const origin = [v.length ? v[0].pos : bx0, h.length ? h[h.length - 1].pos : by1]; // left-most × bottom-most
391
+ const xOff = v.map((l) => snap16((l.pos - origin[0]) * k));
392
+ const yOff = h.map((l) => snap16((origin[1] - l.pos) * k)).reverse(); // up the sheet, ascending
393
+ const labeled = [...v, ...h].length > 0 && [...v, ...h].every((l) => l.label);
394
+ const grid = {
395
+ on: true, origin,
396
+ x: formatSpacings(xOff), y: formatSpacings(yOff), z: '0',
397
+ labels_x: labeled ? v.map((l) => l.label).join(' ') : '',
398
+ labels_y: labeled ? h.map((l) => l.label).reverse().join(' ') : '',
399
+ ext: 5 * FT_MM,
400
+ };
401
+ // extension: how far lines overhang the perpendicular family (to reach their bubbles)
402
+ const overhang = [];
403
+ if (v.length && h.length) {
404
+ const hTop = Math.min(...h.map((l) => l.pos)), hBot = Math.max(...h.map((l) => l.pos));
405
+ for (const l of v) { overhang.push(Math.max(0, hTop - l.lo)); overhang.push(Math.max(0, l.hi - hBot)); }
406
+ }
407
+ const ov = overhang.filter((o) => o > 0).sort((a, b) => a - b);
408
+ if (ov.length) grid.ext = Math.min(Math.max(snap16(ov[Math.floor(ov.length / 2)] * k), 2 * FT_MM), 20 * FT_MM);
409
+ return { grid, info: { v: v.length, h: h.length, anchored: circles.length ? 'circles' : (texts.length ? 'text' : 'geometry'), labeled } };
410
+ }
411
+
412
+ /**
413
+ * Propose a grid from the columns on a plan (display space): cluster distinct column Xs and Ys
414
+ * (150 mm tolerance), origin = the bottom-left intersection, spacings snapped to the nearest 1/2"
415
+ * so the strings read clean. Falls back to null when there are no columns (caller uses defaultGrid).
416
+ */
417
+ export function inferGrid(members, ptPerFt) {
418
+ const k = mmPerPx(ptPerFt), tolPx = 150 / k;
419
+ const xs = [], ys = [];
420
+ for (const m of members || []) {
421
+ if (!m || m.role !== 'column' || !Array.isArray(m.wp) || !m.wp[0]) continue;
422
+ xs.push(m.wp[0][0]); ys.push(m.wp[0][1]);
423
+ }
424
+ if (!xs.length) return null;
425
+ const cluster = (vals) => {
426
+ const sorted = [...vals].sort((a, b) => a - b), out = [];
427
+ let bucket = [sorted[0]];
428
+ for (let i = 1; i < sorted.length; i++) {
429
+ if (sorted[i] - bucket[bucket.length - 1] <= tolPx) bucket.push(sorted[i]);
430
+ else { out.push(bucket.reduce((s, v) => s + v, 0) / bucket.length); bucket = [sorted[i]]; }
431
+ }
432
+ out.push(bucket.reduce((s, v) => s + v, 0) / bucket.length);
433
+ return out;
434
+ };
435
+ const snapHalfIn = (mm) => Math.round(mm / (IN_MM / 2)) * (IN_MM / 2);
436
+ const cx = cluster(xs), cy = cluster(ys);
437
+ const origin = [cx[0], cy[cy.length - 1]]; // left-most line × bottom-most (display y-down) line
438
+ const xOff = cx.map((x) => snapHalfIn((x - origin[0]) * k));
439
+ const yOff = cy.map((y) => snapHalfIn((origin[1] - y) * k)).reverse(); // up the sheet, ascending
440
+ return { on: true, origin, x: formatSpacings(xOff), y: formatSpacings(yOff), z: '0', labels_x: '', labels_y: '', ext: 5 * FT_MM };
441
+ }
@@ -98,8 +98,9 @@ export function elevationLevels(members, ptPerFt, defaultTosMm, excludeId) {
98
98
  return [...set].sort((a, b) => a - b);
99
99
  }
100
100
 
101
- // Lower wins when two candidates are both within tolerance.
102
- const PRECEDENCE = { vertex: 0, intersection: 1, midpoint: 2, centerline: 3, 'vertical-axis': 4, 'grid-line': 5, level: 1 };
101
+ // Lower wins when two candidates are both within tolerance. Grid intersections rank with member
102
+ // intersections (columns land on them); grid lines below member centerlines.
103
+ const PRECEDENCE = { vertex: 0, intersection: 1, 'grid-int': 1, midpoint: 2, centerline: 3, 'vertical-axis': 4, 'grid-line': 5, level: 1 };
103
104
 
104
105
  function closestOnSeg(p, a, b) {
105
106
  const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
@@ -113,7 +114,9 @@ function closestOnSeg(p, a, b) {
113
114
  function candidatePoint(c, dragged) {
114
115
  if (c.type === 'vertex' || c.type === 'intersection' || c.type === 'midpoint' || c.type === 'level') return c.p; // fixed points
115
116
  if (c.type === 'vertical-axis') return [c.p[0], c.p[1], dragged[2]]; // lock plan X/Y to the axis, keep dragged Z
116
- return closestOnSeg(dragged, c.a, c.b); // centerline / grid-line
117
+ if (c.type === 'grid-int') return [c.p[0], c.p[1], dragged[2]]; // grid steers PLAN only — never yanks the elevation
118
+ if (c.type === 'grid-line') { const q = closestOnSeg(dragged, c.a, c.b); return [q[0], q[1], dragged[2]]; }
119
+ return closestOnSeg(dragged, c.a, c.b); // centerline
117
120
  }
118
121
 
119
122
  /**
@@ -136,6 +139,49 @@ export function snapPoint(dragged, candidates, toScreen, tolPx) {
136
139
  return best ? { snapped: bestPt, candidate: best } : { snapped: dragged, candidate: null };
137
140
  }
138
141
 
142
+ // ---- working-plane math (CAD copy/move epic §6) — pure vector helpers, scene-mm space ----
143
+ const _dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
144
+ const _cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
145
+ const _norm = (a) => { const L = Math.hypot(a[0], a[1], a[2]); return L > 1e-12 ? [a[0] / L, a[1] / L, a[2] / L] : null; };
146
+
147
+ /**
148
+ * Orthonormal in-plane basis {u,v} for a plane normal. xHint (default world-X) projects onto the
149
+ * plane as U; when the hint is ~parallel to the normal it falls back to world-Y so U never
150
+ * degenerates. v = n × u keeps the frame right-handed (u × v = n).
151
+ */
152
+ export function planeBasis(normal, xHint) {
153
+ const n = _norm(normal) || [0, 0, 1];
154
+ let u = null;
155
+ for (const hint of [xHint, [1, 0, 0], [0, 1, 0]]) {
156
+ if (!hint) continue;
157
+ const d = _dot(hint, n);
158
+ u = _norm([hint[0] - d * n[0], hint[1] - d * n[1], hint[2] - d * n[2]]);
159
+ if (u) break;
160
+ }
161
+ return { u, v: _cross(n, u) };
162
+ }
163
+
164
+ /** Project p onto the plane (origin,normal): p − ((p−o)·n̂)n̂ — the nearest on-plane point. */
165
+ export function projectToPlane(p, origin, normal) {
166
+ const n = _norm(normal) || [0, 0, 1];
167
+ const d = (p[0] - origin[0]) * n[0] + (p[1] - origin[1]) * n[1] + (p[2] - origin[2]) * n[2];
168
+ return [p[0] - d * n[0], p[1] - d * n[1], p[2] - d * n[2]];
169
+ }
170
+
171
+ /** Plane from 3 picked points (Tekla flow): origin=a, xAxis toward b, normal = x̂ × (c−a). null when degenerate. */
172
+ export function planeFrom3Points(a, b, c) {
173
+ const xAxis = _norm([b[0] - a[0], b[1] - a[1], b[2] - a[2]]);
174
+ if (!xAxis) return null;
175
+ const normal = _norm(_cross(xAxis, [c[0] - a[0], c[1] - a[1], c[2] - a[2]]));
176
+ if (!normal) return null;
177
+ return { origin: [a[0], a[1], a[2]], xAxis, normal };
178
+ }
179
+
180
+ /** World vector → components along the plane basis: {du,dv,dn}. Reconstruct via du·u+dv·v+dn·n. */
181
+ export function vecToPlane(vec, u, v, n) {
182
+ return { du: _dot(vec, u), dv: _dot(vec, v), dn: _dot(vec, n) };
183
+ }
184
+
139
185
  /**
140
186
  * 3D dimension geometry (pure). a,b are scene mm [x,y,z]. axis ∈ free|x|y|z.
141
187
  * free → the straight segment a→b (valueMm = full 3D distance); x|y|z →