@floless/app 0.62.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 +2 -2
- 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 +376 -7
- package/dist/web/steel-editor.html +414 -32
- package/package.json +1 -1
package/dist/floless-server.cjs
CHANGED
|
@@ -53022,7 +53022,7 @@ function appVersion() {
|
|
|
53022
53022
|
return resolveVersion({
|
|
53023
53023
|
isSea: isSea2(),
|
|
53024
53024
|
sqVersionXml: readSqVersionXml(),
|
|
53025
|
-
define: true ? "0.
|
|
53025
|
+
define: true ? "0.63.0" : void 0,
|
|
53026
53026
|
pkgVersion: readPkgVersion()
|
|
53027
53027
|
});
|
|
53028
53028
|
}
|
|
@@ -53032,7 +53032,7 @@ function resolveChannel(s) {
|
|
|
53032
53032
|
return "dev";
|
|
53033
53033
|
}
|
|
53034
53034
|
function appChannel() {
|
|
53035
|
-
return resolveChannel({ isSea: isSea2(), define: true ? "0.
|
|
53035
|
+
return resolveChannel({ isSea: isSea2(), define: true ? "0.63.0" : void 0 });
|
|
53036
53036
|
}
|
|
53037
53037
|
|
|
53038
53038
|
// workflow-update.ts
|
|
@@ -43,6 +43,24 @@
|
|
|
43
43
|
"dims3d": { "type": "array", "items": { "$ref": "#/$defs/dim3" }, "description": "Draft-only 3D dimensions (editor annotations, model-global). World-scene mm. NOT baked into the lock / 3D scene / IFC / BOM." }
|
|
44
44
|
},
|
|
45
45
|
"$defs": {
|
|
46
|
+
"grid": {
|
|
47
|
+
"type": ["object", "null"],
|
|
48
|
+
"required": ["origin"],
|
|
49
|
+
"additionalProperties": true,
|
|
50
|
+
"description": "Structural grid: X/Y are RELATIVE spacing strings (Tekla syntax — \"0 3*25' 20'-6\\\"\", n*d repeats a bay; tokens accept ft-in / inches / m / bare mm), Z is ABSOLUTE elevations. X entries space lines along +X (lines vertical on the plan, auto-labels 1,2,3…); Y entries march UP the sheet (lines horizontal, auto-labels A,B,C…). web/grid-core.js is the parser/geometry source of truth.",
|
|
51
|
+
"properties": {
|
|
52
|
+
"on": { "type": "boolean", "description": "Grid visible (2D + 3D). Default true." },
|
|
53
|
+
"origin": { "$ref": "#/$defs/point2", "description": "The [1,A] corner, display space (same space as member wp)." },
|
|
54
|
+
"x": { "type": "string", "description": "Relative spacings along +X, e.g. \"0 3*25'\"." },
|
|
55
|
+
"y": { "type": "string", "description": "Relative spacings up the sheet, e.g. \"0 4*30'\"." },
|
|
56
|
+
"z": { "type": "string", "description": "Absolute level elevations, e.g. \"0 16'-6\\\"\"." },
|
|
57
|
+
"labels_x": { "type": "string", "description": "Whitespace-separated label overrides for X lines; blank = auto 1,2,3…" },
|
|
58
|
+
"labels_y": { "type": "string", "description": "Whitespace-separated label overrides for Y lines; blank = auto A,B,C…" },
|
|
59
|
+
"ext": { "type": "number", "description": "How far lines run past the outermost line, in mm. Default 5'." },
|
|
60
|
+
"ends_x": { "type": "object", "description": "Per-line extent overrides for X lines: { \"<line index>\": [lo, hi] } display px along the line (a partial line like a 5.1 insert covering one wing). null / absent element = the auto full span. Set by dragging a bubble along its line." },
|
|
61
|
+
"ends_y": { "type": "object", "description": "Per-line extent overrides for Y lines — same shape as ends_x." }
|
|
62
|
+
}
|
|
63
|
+
},
|
|
46
64
|
"filter": {
|
|
47
65
|
"type": "object",
|
|
48
66
|
"additionalProperties": true,
|
|
@@ -111,18 +129,21 @@
|
|
|
111
129
|
},
|
|
112
130
|
"elements": {
|
|
113
131
|
"type": "array",
|
|
114
|
-
"description": "Every drawing element: lines (with an SVG path `d`)
|
|
132
|
+
"description": "Every drawing element: lines (with an SVG path `d`), text spans (with `text`+`bbox`) and circles (`cx`/`cy`/`r` — grid bubbles, callout balloons), each tagged layer/thickness/colour.",
|
|
115
133
|
"items": {
|
|
116
134
|
"type": "object",
|
|
117
135
|
"additionalProperties": true,
|
|
118
136
|
"properties": {
|
|
119
|
-
"kind": { "enum": ["line", "text"] },
|
|
137
|
+
"kind": { "enum": ["line", "text", "circle"] },
|
|
120
138
|
"layer": { "type": "string" },
|
|
121
139
|
"w": { "type": "number" },
|
|
122
140
|
"color": { "type": "string" },
|
|
123
141
|
"d": { "type": "string", "description": "SVG path (line elements)." },
|
|
124
142
|
"text": { "type": "string", "description": "Label text (text elements)." },
|
|
125
143
|
"bbox": { "type": "array", "items": { "type": "number" }, "description": "[x0,y0,x1,y1] (text elements)." },
|
|
144
|
+
"cx": { "type": "number", "description": "Circle centre X (circle elements)." },
|
|
145
|
+
"cy": { "type": "number", "description": "Circle centre Y (circle elements)." },
|
|
146
|
+
"r": { "type": "number", "description": "Circle radius (circle elements)." },
|
|
126
147
|
"dashed": { "type": "boolean" }
|
|
127
148
|
}
|
|
128
149
|
}
|
|
@@ -150,18 +171,21 @@
|
|
|
150
171
|
"steelBbox": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "[x0,y0,x1,y1] of this sheet's steel linework, for Zoom-to-steel." },
|
|
151
172
|
"elements": {
|
|
152
173
|
"type": "array",
|
|
153
|
-
"description": "This sheet's drawing elements: lines (SVG path `d`)
|
|
174
|
+
"description": "This sheet's drawing elements: lines (SVG path `d`), text spans (`text`+`bbox`) and circles (`cx`/`cy`/`r` — grid bubbles feed the editor's grid-from-drawing detector), each tagged layer/thickness/colour.",
|
|
154
175
|
"items": {
|
|
155
176
|
"type": "object",
|
|
156
177
|
"additionalProperties": true,
|
|
157
178
|
"properties": {
|
|
158
|
-
"kind": { "enum": ["line", "text"] },
|
|
179
|
+
"kind": { "enum": ["line", "text", "circle"] },
|
|
159
180
|
"layer": { "type": "string" },
|
|
160
181
|
"w": { "type": "number" },
|
|
161
182
|
"color": { "type": "string" },
|
|
162
183
|
"d": { "type": "string" },
|
|
163
184
|
"text": { "type": "string" },
|
|
164
185
|
"bbox": { "type": "array", "items": { "type": "number" } },
|
|
186
|
+
"cx": { "type": "number" },
|
|
187
|
+
"cy": { "type": "number" },
|
|
188
|
+
"r": { "type": "number" },
|
|
165
189
|
"dashed": { "type": "boolean" }
|
|
166
190
|
}
|
|
167
191
|
}
|
|
@@ -236,7 +260,8 @@
|
|
|
236
260
|
"tos_callouts": { "type": "array", "items": { "$ref": "#/$defs/tos_callout" } },
|
|
237
261
|
"members": { "type": "array", "items": { "$ref": "#/$defs/member" } },
|
|
238
262
|
"dims": { "type": "array", "items": { "$ref": "#/$defs/dim" }, "description": "Draft-only on-drawing dimensions (editor annotations). NOT baked into the lock / 3D / IFC / BOM." },
|
|
239
|
-
"frame": { "$ref": "#/$defs/frame" }
|
|
263
|
+
"frame": { "$ref": "#/$defs/frame" },
|
|
264
|
+
"grid": { "$ref": "#/$defs/grid", "description": "Structural grid lines (Tekla-style). PER-PLAN — its origin/spacings live in this sheet's display space (like frame/dims). A plan REFERENCE the editor renders in 2D + 3D and snaps to — it never constrains members and is NOT baked into the lock / IFC / BOM." }
|
|
240
265
|
}
|
|
241
266
|
},
|
|
242
267
|
"segment": {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: floless-app-steel-takeoff
|
|
3
3
|
description: Read a structural steel drawing into an editable steel.takeoff/v1 contract in floless.app — the "Steel Takeoff" reader. Triggers on a pending floless `rebake` request for the `steel-model` app (queued from its "Re-read & re-bake ▸" button), or asks like "read this framing plan into a takeoff", "turn this structural drawing into a takeoff", "bake my drawing into steel-model", "extract members from this PDF". Teaches the host AI to READ a steel drawing (PDF/image) with its own vision at COMPOSE time, build a steel.takeoff/v1 contract (members + profiles + elevations + weights from AISC lookup), write it to the contract store, and hand control to the editor — no model in the run path, no API key.
|
|
4
4
|
metadata:
|
|
5
|
-
version: 0.
|
|
5
|
+
version: 0.4.0
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Steel Takeoff — read a drawing into a steel.takeoff/v1 contract
|
|
@@ -80,6 +80,11 @@ native/display coords):
|
|
|
80
80
|
pt>, color:<#rrggbb stroke>, d:<SVG path>, dashed:<bool> }`.
|
|
81
81
|
2. **Text** — `page.get_text("dict")`: per span keep `{ kind:"text", layer:<OCG>, w:0, color:<#hex>,
|
|
82
82
|
text:<string>, bbox:[x0,y0,x1,y1] }`.
|
|
83
|
+
2b. **Circles** — a closed bezier path whose bbox is round (width ≈ height, ≥ ~8 pt across) becomes
|
|
84
|
+
`{ kind:"circle", cx:<centre x>, cy:<centre y>, r:<radius> }` in display space. These are the grid
|
|
85
|
+
bubbles and callout balloons; the editor's **grid-from-drawing** detector (`web/grid-core.js`
|
|
86
|
+
`gridFromDrawing`) anchors grid lines to them, so skipping circles silently disables the Grid
|
|
87
|
+
panel's "From drawing" button.
|
|
83
88
|
3. **Per-sheet entry** — push `{ sheet:<number>, title:<name?>, page:{ w, h, bg_b64, rect }, steelBbox,
|
|
84
89
|
elements:[…] }` onto `filter.sheets`. `bg_b64` is a dimmed raster of THIS sheet's framing area
|
|
85
90
|
(**title block excluded** — CONFIDENTIAL, machine-local, never committed). **`page.rect` = the exact
|
|
@@ -96,6 +101,29 @@ native/display coords):
|
|
|
96
101
|
5. Set `filter.mode: "layer"`, `filter.active: 0`. **PUT** the contract
|
|
97
102
|
(`PUT /api/contract/steel-model`). On a re-read, preserve any existing per-facet `on` flags.
|
|
98
103
|
|
|
104
|
+
### 1c. Grid lines — read the printed grid (quick, per sheet)
|
|
105
|
+
|
|
106
|
+
With the filter elements in hand, populate each plan's **`plan.grid`** (Tekla-style grid lines the
|
|
107
|
+
editor renders in 2D + 3D and snaps to). The geometry is deterministic — the editor's Grid panel has
|
|
108
|
+
a "From drawing" button running the same detector (`web/grid-core.js` `gridFromDrawing`: grid lines
|
|
109
|
+
are the long dash-dot line fields that AIM at a bubble circle's centre and STOP at its edge; bubbles
|
|
110
|
+
line up in a row along one sheet edge and columns along the sides). What the detector usually can't
|
|
111
|
+
get is the **labels** — CAD sets outline their text, so bubble marks like `5.1` / `K.1` aren't in the
|
|
112
|
+
text layer. You CAN read them: you already have vision on the sheet. So:
|
|
113
|
+
|
|
114
|
+
1. Derive the line positions (or let the user press "From drawing" later — same result).
|
|
115
|
+
2. **Vision-read each bubble's mark** from the sheet render, in line order.
|
|
116
|
+
3. Write `plan.grid = { on:true, origin:[x,y], x:"0 3*25' …", y:"…", z:"0", labels_x:"K J H …",
|
|
117
|
+
labels_y:"0.5 1 1.1 …", ext:<mm> }` — origin = the left-most × bottom-most line crossing,
|
|
118
|
+
spacings are RELATIVE ft-in tokens (`n*d` repeats a bay), labels are whitespace-separated
|
|
119
|
+
overrides in line order (X: left→right; Y: bottom→up). Sanity-check a bay or two against the
|
|
120
|
+
printed dimension strings between grid lines — a mismatch means the sheet scale (`pt_per_ft`) is
|
|
121
|
+
wrong, which corrupts the whole takeoff, not just the grid. **Then PUT the contract again** —
|
|
122
|
+
the 1b PUT already happened, and the user's next Filter-node Save writes back the contract *it
|
|
123
|
+
loaded*; an un-PUT grid (and its vision-read labels) would be silently dropped.
|
|
124
|
+
|
|
125
|
+
Skip silently only when the sheet genuinely has no grid bubbles (rare for framing plans); say so.
|
|
126
|
+
|
|
99
127
|
**Then STOP and hand off — checkpoint before the member read.** Tell the user to open the **Filter**
|
|
100
128
|
node, flip through the sheets, narrow to the steel, and **Save**. The saved `on` flags + `mode` are
|
|
101
129
|
what step 2 reads — **per sheet** (each sheet's elements passing `mode` + the global `on` flags). Only
|
|
@@ -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
|
+
}
|