@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.
- package/dist/floless-server.cjs +227 -5
- package/dist/schemas/drawing.vector.v1.schema.json +92 -4
- package/dist/schemas/steel.takeoff.v1.schema.json +30 -5
- package/dist/skills/floless-app-steel-takeoff/SKILL.md +29 -1
- package/dist/web/grid-core.js +441 -0
- package/dist/web/steel-3d-core.js +49 -3
- package/dist/web/steel-3d-view.js +424 -10
- package/dist/web/steel-editor.html +734 -20
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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 →
|