las-laz-js 0.4.0 → 0.6.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,68 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.1 (2026-10-22)
4
+
5
+ - Demo: a read bigger than the draw cap (20 M) is drawn as an even spread over the whole cloud,
6
+ not its first 20 M points; selections and edits still act on every point read, and the
7
+ selection line says how many selected points are on screen. `?drawCap=N` lowers the cap.
8
+
9
+ ## 0.6.0 (2026-10-22)
10
+
11
+ Voxel neighbour index and the sparse-point (dust) filter.
12
+
13
+ - `selectSparse(cols, { radius, minNeighbours = 1, xy })` selects the points with fewer than
14
+ `minNeighbours` other points within `radius` (inclusive), for `removePoints`. `minNeighbours: 1`
15
+ keeps thin sparse features such as scanned blastholes. Checked point for point against PDAL's
16
+ `filters.outlier` (`method=radius`; PDAL's `min_k` is `minNeighbours`, and PDAL does not count a
17
+ point exactly `radius` away).
18
+ - `buildIndex(cols, { cellSize })`: a sorted voxel grid on flat typed arrays (6 to 8 bytes a point),
19
+ over x / y / z or a k = 3 `positions` column, with exact queries: `nearestPoint` (3D, or `xy` in
20
+ plan; `maxDistance`), `pointsInRadius` (a selection) and `pointsNearRay` (a cylinder or a cone,
21
+ with each point's `t` along the ray and `d` from its axis, for a caller's snap). Rebuild it after
22
+ any edit of the columns.
23
+ - `dedupeSelection(cols, tolerance, { z })` returns `dedupe`'s kept points as a selection and a
24
+ `map` from every input point to the kept point it merged into (its nearest within the tolerance).
25
+ - Speed (Node): a 34-million-point LAS (Float32 after a local origin) indexes in about 1.1 s at
26
+ about 8 bytes a point, and `selectSparse` at 2 m takes about 2.1 s.
27
+ - Demo: a Sparse points selection on the Edit tab.
28
+
29
+ ## 0.5.0 (2026-10-22)
30
+
31
+ Point operations.
32
+
33
+ - Operations on point columns (the object `readPoints` returns, or any `{ count, <name>: TypedArray }`
34
+ whose arrays are `count × k` long), with no dependencies. A selection is a `Uint32Array` of
35
+ strictly ascending point indices, checked on use (`INVALID_ARGUMENT` names the bad position).
36
+ - Select: `selectBox`, `selectPolygon` (even-odd; `boundary: "touching"` keeps points on the edge,
37
+ `"inside"` drops them; `tolerance`; `minZ` / `maxZ`), `selectClasses`, `selectReturns` (`returns`,
38
+ `first`, `last`, `single`, any one matches), `thinSelection` (the reader's `maxPoints` rule,
39
+ exported), and `unionSelections`, `intersectSelections`, `subtractSelections`.
40
+ - Apply: `keepPoints`, `removePoints`, `splitPoints`, `mergePoints`. Copies by default; `inPlace: true`
41
+ on keep and remove compacts each column inside its own buffer and returns views of it (columns that
42
+ share memory are refused). `mergePoints` takes the union of the columns, lists zero-filled ones
43
+ in `filled`, widens types (Float64 over Float32 over integers; Int32 or Uint32 with Float32 gives
44
+ Float64), and refuses a `k` mismatch, a column named `count` or `filled`, and signed with unsigned
45
+ integers unless the signed type is strictly wider.
46
+ - Transform: `makeTransform({ shift, rotateZ, rotate, scale, about })` builds a row-major `{ m, t }`;
47
+ `transformPoints(cols, T, { inPlace })` applies it in Float64 and returns a new `{ count, columns }`
48
+ object (a copy has new x, y, z and shares the rest; `inPlace` returns the input's arrays, overwritten).
49
+ - `interleavePositions(cols, { layout, into, at, origin, float32 })` packs x y (z) into a caller's
50
+ Float32 / Float64 buffer, shifting by `origin` in Float64 first.
51
+ - Reads: `index` field (each point's record number; Uint32, Float64 above 4,294,967,295 points);
52
+ `polygon` / `boundary` / `tolerance` crop while reading, so only the region is decoded and held;
53
+ `transform` applies in the fixed order record → world → T → minus `localOrigin` → Float32.
54
+ Filters apply in file coordinates; the result's bounds and `dedupe` are in output coordinates,
55
+ and the worker's elevation colour range follows the transform.
56
+ - Changed: an omitted (or `null`) `bounds` limit is now open. Before, a missing limit kept nothing.
57
+ `polygon: null` means no polygon. `selectBox` and the `minZ` / `maxZ` of `selectPolygon` read a
58
+ `null` bound as open too.
59
+ - `dedupe` moves every column (k values per point, such as an interleaved `positions` or `rgb`) with
60
+ the kept points; other properties pass through. `transformPoints` with `inPlace` refuses x, y or z
61
+ that share memory. `mergePoints` ignores a zero-point input when widening types.
62
+ - Demo: an Edit tab (polygon, box and class selections; keep, delete, split, transform, crop on
63
+ read) and LAZ export.
64
+ - Docs: README "Point operations".
65
+
3
66
  ## 0.4.0 (2026-10-22)
4
67
 
5
68
  Writes LAZ.
package/README.md CHANGED
@@ -7,8 +7,12 @@ runs in the browser and in Node — and built for files far larger than memory.
7
7
  **Status: early (0.x).** It works and is tested, but the API may still change before 1.0.
8
8
 
9
9
  - Working: `openLas` reads the header, VLRs / EVLRs and coordinate system of LAS 1.0–1.4;
10
- `readPoints` and `chunks` decode every point format (0–10) a window at a time, with filters
11
- and thinning; `colourize` colours points; `writeLas` and `createLasWriter` write LAS 1.0–1.4.
10
+ `readPoints` and `chunks` decode every point format (0–10) a window at a time, with filters,
11
+ thinning, a polygon crop, a coordinate transform and the original point index; `colourize`
12
+ colours points; `writeLas` and `createLasWriter` write LAS 1.0–1.4.
13
+ - Point operations on columns: select by box, polygon, class or return; keep, remove, split and
14
+ merge; transform coordinates; interleave positions for a renderer; find sparse points (dust);
15
+ a voxel neighbour index with nearest, radius and near-ray queries. See Point operations.
12
16
  - LAZ: point formats 0–3 and 6–8, read exactly like LAS — the same calls, the same options,
13
17
  decoded chunk by chunk in plain JavaScript (no WebAssembly) — and written with
14
18
  `compress: true`, byte for byte what PDAL (LAZperf) writes (bar one lossless exception, see
@@ -35,8 +39,9 @@ Drop a LAS or LAZ file onto the page. It is read locally (never uploaded), decod
35
39
  library's Worker (LAZ on every core) behind a progress bar, then shown once in a Three.js viewer;
36
40
  colour changes recolour the points already read, without reading the file again. The side panel shows the header, VLRs, coordinate
37
41
  system and LAZ compression; reads with class, return, bounds, thinning and dedupe filters;
38
- colours by RGB, intensity, elevation or class; lists point statistics; and saves the points back
39
- out as LAS. To run it locally: `npm run demo`, then open http://localhost:8080/.
42
+ colours by RGB, intensity, elevation or class; lists point statistics; selects points by polygon,
43
+ box or class and keeps, deletes, splits, transforms or crops them (the Edit tab); and saves the
44
+ points back out as LAS or LAZ. To run it locally: `npm run demo`, then open http://localhost:8080/.
40
45
 
41
46
  ## Reading
42
47
 
@@ -56,7 +61,11 @@ var pts = await las.readPoints({
56
61
  fields: ["x", "y", "z", "classification", "red", "green", "blue"],
57
62
  classes: [2], // keep only these classification codes
58
63
  returns: [1], // keep only these return numbers
59
- bounds: { minX, maxX, minY, maxY, minZ, maxZ },
64
+ bounds: { minX, maxX, minY, maxY, minZ, maxZ }, // any limit may be left out: an omitted limit is open
65
+ polygon: [x0, y0, x1, y1, x2, y2], // crop: keep points inside it (file coordinates, XY, even-odd); null is none
66
+ boundary: "touching", // polygon edge: "touching" keeps points on it (default), "inside" drops them
67
+ tolerance: 0, // file units: within this of a polygon edge counts as on it
68
+ transform: T, // { m, t } from makeTransform, applied to world coordinates (see Point operations)
60
69
  maxPoints: 5000000, // even thinning; 0 keeps every point
61
70
  dedupe: 0.01, // drop points within 1 cm (XY; dedupeZ: true for 3D)
62
71
  localOrigin: { x: 478000, y: 6772000 }, float32: true, // small Float32 coordinates for the GPU
@@ -64,7 +73,7 @@ var pts = await las.readPoints({
64
73
  signal: abortController.signal
65
74
  });
66
75
  pts.count; pts.x; pts.y; pts.z; // one typed array per field
67
- pts.bounds; pts.stats; // world bounds, per-class counts
76
+ pts.bounds; pts.stats; // world bounds (after any transform), per-class counts
68
77
  ```
69
78
 
70
79
  Fields: `x y z intensity returnNumber numberOfReturns scanDirection edgeOfFlightLine
@@ -72,10 +81,192 @@ classification classificationFlags scannerChannel scanAngle userData pointSource
72
81
  red green blue nir`. Fields a point format does not hold are left out. `scanAngle` is in
73
82
  degrees for every format.
74
83
 
84
+ `index` is one more field, for every format: the column is present for the points the filters and
85
+ `maxPoints` keep, and each value is that record's position in the file (0-based; it is not
86
+ renumbered after filtering). It is a `Uint32Array`, or a `Float64Array` for a file of more than
87
+ 4,294,967,295 points. It is an ordinary column, so keep, remove, split and merge carry it, and it
88
+ tells you which records of the file are left.
89
+
75
90
  Filters run on the raw record before it is decoded, so a filtered-out point costs a byte read.
91
+ `polygon` is a crop on read: only the points inside it are decoded and held, so a site region of
92
+ a large file never costs the whole file in memory. It needs at least 3 vertices (a flat list of
93
+ x, y pairs, implicitly closed) and combines with the other filters and `maxPoints`. A LAZ crop
94
+ saves memory and field decoding but still decompresses every chunk (there is no spatial index), so
95
+ its time is close to a full read.
76
96
  `maxPoints` keeps exactly that many of the points that pass the filters, spread evenly over
77
97
  the whole file; with filters set it first counts the passing points, which reads the file twice.
78
98
 
99
+ In `bounds`, a limit that is left out (or `null`) is open; before 0.5.0 a missing limit kept
100
+ nothing. `polygon: null` means no polygon. `boundary` and `tolerance` belong to `polygon`: a point
101
+ within `tolerance` of an edge or vertex is on the boundary, kept by `"touching"` and dropped by
102
+ `"inside"`. They are not `dedupe`'s tolerance, which is the value of the `dedupe` option.
103
+
104
+ `transform` moves the points while they are decoded, in a fixed order: record → world (scale and
105
+ offset) → `transform` (Float64) → minus `localOrigin` → Float32 (when `float32` and `localOrigin`
106
+ are set). Raw scanner coordinates never pass through Float32 at full magnitude. The filters
107
+ (`classes`, `returns`, `bounds`, `polygon`) apply in **file** coordinates, before the transform;
108
+ the result's `bounds`, its stats and `dedupe` are in the output coordinates. To crop a scan in
109
+ site coordinates, read it transformed and use `selectPolygon`. The Worker's elevation colour
110
+ range follows the transform.
111
+
112
+ ## Point operations
113
+
114
+ Zero-dependency operations on **point columns**: the object `readPoints` returns, or any object
115
+ of the same shape, `{ count, <name>: TypedArray, ... }`. A property is a column when it is a typed
116
+ array whose length is `count × k` for a whole number `k` of 1 or more (`k` is 1 for `x` or
117
+ `classification`, 3 for an interleaved `positions` or `rgb`). Operations carry every column, `k`
118
+ values per point, and nothing else: `bounds`, `stats` and the like are not copied, so rebuild them
119
+ from the result. A **selection** is a `Uint32Array` of point indices, strictly ascending, each
120
+ below `count`. Everything that takes one checks it in one pass and throws `INVALID_ARGUMENT`
121
+ naming the first bad position; everything that makes one returns a valid one.
122
+
123
+ ### Select
124
+
125
+ ```js
126
+ import { selectBox, selectPolygon, selectClasses, selectReturns, thinSelection,
127
+ unionSelections, intersectSelections, subtractSelections } from "las-laz-js";
128
+
129
+ var inBox = selectBox(pts, { minX: 477010, maxX: 477050, minZ: 295 }); // inclusive; a limit left out is open
130
+ var inSite = selectPolygon(pts, poly, { boundary: "inside", tolerance: 0.001 }); // poly: [x0, y0, x1, y1, ...]
131
+ var ground = selectClasses(pts, [2]);
132
+ var last = selectReturns(pts, { last: true, single: true }); // returns: [..], first, last, single; any one matches
133
+ var thin = thinSelection(pts.count, 1000); // exactly min(count, 1000) indices, spread evenly
134
+ var wanted = intersectSelections(inSite, ground); // also unionSelections, subtractSelections
135
+ ```
136
+
137
+ `selectPolygon` takes a flat `[x0, y0, x1, y1, ...]` list of at least 3 vertices, closed for you,
138
+ in the columns' own coordinates; the interior is the even-odd rule, so a concave polygon works.
139
+ Points on the boundary are kept by `boundary: "touching"` (the default) and dropped by `"inside"`,
140
+ so the two differ by exactly the boundary points, and `splitPoints` by one polygon loses and
141
+ duplicates nothing. Coordinates are not exact in binary, so a point mathematically on an edge may not
142
+ count as on it at `tolerance: 0`; use a small tolerance (for example 1e-6 of the units) to catch
143
+ boundary points. `minZ` / `maxZ` add a height limit. `thinSelection` is the reader's
144
+ `maxPoints` rule: `keepPoints(all, thinSelection(n, t))` gives the points
145
+ `readPoints({ maxPoints: t })` does.
146
+
147
+ ### Keep, remove, split, merge
148
+
149
+ ```js
150
+ import { keepPoints, removePoints, splitPoints, mergePoints } from "las-laz-js";
151
+
152
+ var kept = keepPoints(pts, wanted); // new columns of the same types
153
+ var without = removePoints(pts, wanted);
154
+ var [inside, outside] = splitPoints(pts, inSite); // always copies; every point lands in one result
155
+ var both = mergePoints([inside, outside]); // concatenated in order; both.filled is []
156
+
157
+ var crop = keepPoints(big, selectPolygon(big, poly), { inPlace: true }); // no second copy
158
+ ```
159
+
160
+ `inPlace: true` (for `keepPoints` and `removePoints`) slides the kept values down inside each
161
+ column's own buffer and returns views of that memory: the input is overwritten, and a big cloud is
162
+ cropped without a second copy. Columns that share memory (the same array twice, or overlapping
163
+ views) are refused, since compacting one would corrupt the other.
164
+
165
+ `mergePoints` takes the union of the column names; a column missing from an input is zero for
166
+ that input's points, and its name is listed in `result.filled`. A column named `count` or
167
+ `filled` is refused. A column's `k` must agree across inputs. The result's type is the widest:
168
+ Float64 over Float32 over the integer types; an Int32 or Uint32 with a Float32 gives Float64
169
+ (Float32 holds integers exactly only to 2^24). Signed with unsigned integers merge only when the
170
+ signed type is strictly wider (Int16 with Uint8 is Int16; Int8 with Uint8, or Int32 with Uint32,
171
+ is `INVALID_ARGUMENT`). An input with no points is ignored when the type is chosen, so an empty
172
+ Int8 column does not block a Uint16 one; a column only an empty input has keeps its type.
173
+ Coordinates must already share one frame.
174
+
175
+ ### Transform
176
+
177
+ ```js
178
+ import { makeTransform, transformPoints } from "las-laz-js";
179
+
180
+ // p' = scale · R · (p − about) + about + shift; rotateZ is degrees anticlockwise about +Z
181
+ var T = makeTransform({ rotateZ: 90, about: [477050, 6770015, 0], shift: [10, 0, 0] });
182
+ // T = { m: [0, -1, 0, 1, 0, 0, 0, 0, 1], t: [7247075, 6292965, 0] } row-major: p' = m·p + t
183
+
184
+ var moved = transformPoints(pts, T); // { count, columns }: new x, y, z; the rest shared
185
+ var same = transformPoints(pts, T, { inPlace: true }); // the same x, y, z arrays, overwritten
186
+
187
+ var local = await las.readPoints({ // or while reading
188
+ fields: ["x", "y", "z"], transform: T,
189
+ localOrigin: { x: 477050, y: 6770015 }, float32: true
190
+ });
191
+ ```
192
+
193
+ A transform is a plain object, `{ m, t }`, with `m` nine numbers (a row-major 3×3) and `t` three.
194
+ Any affine map fits, so a fitted registration can be passed the same way; it is not Three.js's
195
+ column-major `Matrix4`. `makeTransform` takes `shift`, `about`, `scale` and either `rotateZ` or
196
+ `rotate` (a row-major 3×3; not both). `transformPoints` needs `x`, `y` and `z`, computes in
197
+ Float64 and stores in the columns' own types. It returns a new `{ count, columns }` object either
198
+ way: with `inPlace` the arrays in it are the input's, overwritten (and `x`, `y` and `z` must not
199
+ share memory, or it throws `INVALID_ARGUMENT`). On read, the order is the one
200
+ above.
201
+
202
+ ### Interleave for a renderer
203
+
204
+ ```js
205
+ import { interleavePositions } from "las-laz-js";
206
+
207
+ var positions = interleavePositions(pts, { origin: { x: 477000, y: 6770000 } }); // Float32Array, x y z per point
208
+ geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
209
+
210
+ // or into a buffer you own, starting at point 'at'; layout "xy" writes two values per point
211
+ interleavePositions(pts, { into: buffer, at: 50000, layout: "xyz", origin: { x: 477000, y: 6770000, z: 300 } });
212
+ ```
213
+
214
+ The origin is subtracted in Float64 before storing, so world coordinates keep their decimals in a
215
+ Float32 buffer; an axis left out of `origin` is not shifted ({ x, y } keeps world Z). `float32`
216
+ defaults to true and picks the type of a new array; `into` must be a Float32Array or Float64Array
217
+ long enough, else `INVALID_ARGUMENT`.
218
+
219
+ ### Sparse points (dust)
220
+
221
+ ```js
222
+ import { selectSparse, removePoints } from "las-laz-js";
223
+
224
+ var dust = selectSparse(pts, { radius: 2 }); // fewer than 1 other point within 2 m
225
+ var clean = removePoints(pts, dust);
226
+ var lone = selectSparse(pts, { radius: 0.5, minNeighbours: 4, xy: true }); // counted in 2D
227
+ ```
228
+
229
+ A point is sparse when fewer than `minNeighbours` (default 1) **other** points lie within
230
+ `radius` of it, inclusive; `radius` is required, in the columns' units. The result is a selection,
231
+ so it can be shown before anything is deleted. `minNeighbours: 1` keeps any point with a single
232
+ neighbour inside the radius, so thin sparse features — a line of points down a scanned blasthole —
233
+ survive; dust that clumps survives too. A higher `minNeighbours` removes small clumps, at the
234
+ cost of thin features. It works on columns in memory (x / y / z, or a k = 3 `positions`): 34 million
235
+ points take about 2 s in Node. With PDAL's `filters.outlier` (`method=radius`), `min_k` is the same
236
+ as `minNeighbours`, but PDAL does not count a point exactly `radius` away.
237
+
238
+ ### Neighbour index
239
+
240
+ ```js
241
+ import { buildIndex, nearestPoint, pointsInRadius, pointsNearRay } from "las-laz-js";
242
+
243
+ var index = buildIndex(pts); // or { cellSize: 0.5 }; omitted: ~16 points a cell
244
+ var i = nearestPoint(index, [x, y, z]); // point number, or -1
245
+ var j = nearestPoint(index, [x, y], { xy: true, maxDistance: 0.3 }); // nearest in plan, within 0.3
246
+ var near = pointsInRadius(index, [x, y, z], 5); // a selection
247
+ var hit = pointsNearRay(index, origin, direction, { radius: 0.2, growth: 0 });
248
+ // hit = { points, t, d }: every point within radius + growth · t of the ray, t along it, d from its axis
249
+ ```
250
+
251
+ A sorted voxel grid on flat typed arrays, 6 to 8 bytes a point (34 million points build in about
252
+ 1 s). Results are exact — every candidate is distance-checked in Float64 — "within" is inclusive,
253
+ and ties go to the lowest point number. `pointsNearRay` is a cylinder for `growth: 0` (an
254
+ orthographic camera's pick ray) and a cone for `growth > 0` (a perspective camera's, `growth` the
255
+ tangent of the half-angle); points behind `origin` are left out, and choosing a snap point from `t`
256
+ and `d` is the caller's. The index refers to the coordinate arrays, it does not copy them: rebuild
257
+ it after any keep, remove, merge or transform — after an `inPlace` edit an old index would read
258
+ overwritten memory, and queries cannot tell.
259
+
260
+ ### Dedupe with an index map
261
+
262
+ ```js
263
+ import { dedupeSelection } from "las-laz-js";
264
+
265
+ var { keep, map } = dedupeSelection(pts, 0.01); // { z: true } for 3D
266
+ var unique = keepPoints(pts, keep); // what dedupe(pts, 0.01) returns
267
+ // map[i]: where input point i went in unique — a dropped point maps to its nearest kept point
268
+ ```
269
+
79
270
  ## LAZ
80
271
 
81
272
  A LAZ file is opened and read with the same calls as LAS. Each LASzip chunk (50,000 points in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "las-laz-js",
3
- "version": "0.4.0",
3
+ "version": "0.6.1",
4
4
  "description": "Zero-dependency JavaScript reader and writer for ASPRS LAS point clouds, and reader and writer for LAZ (LASzip), for the browser and Node, built for files larger than memory.",
5
5
  "type": "module",
6
6
  "exports": {
package/src/LasReader.js CHANGED
@@ -25,6 +25,7 @@ import { createChunkDecoder } from "./laz/decompressChunk.js";
25
25
  import { createLazPool, defaultThreads } from "./laz/pool.js";
26
26
  import { makeKeep, countKept } from "./filter/filters.js";
27
27
  import { dedupe } from "./filter/dedupe.js";
28
+ import { checkTransform } from "./ops/transform.js";
28
29
  import { openScratchSyncWriter } from "./io/ScratchBinaryFile.js";
29
30
 
30
31
  var DEFAULT_WINDOW = 64 * 1024 * 1024;
@@ -179,7 +180,7 @@ LasReader.prototype._plan = async function () {
179
180
 
180
181
  /**
181
182
  * Iterate the selected points window by window.
182
- * @param {object} [opts] - fields, classes, returns, bounds, maxPoints, localOrigin, float32, onProgress, signal
183
+ * @param {object} [opts] - fields, classes, returns, bounds, polygon (crop; with boundary "touching" | "inside" and tolerance), maxPoints, transform ({ m, t } from makeTransform: world coordinates are transformed in Float64 before localOrigin is subtracted; filters apply in file coordinates), localOrigin, float32, onProgress, signal
183
184
  * @returns {AsyncGenerator<object>} PointColumns per window
184
185
  */
185
186
  LasReader.prototype.chunks = async function* (opts) {
@@ -192,6 +193,7 @@ LasReader.prototype.chunks = async function* (opts) {
192
193
  // makes decodeRecords write straight into readPoints' result columns.
193
194
  LasReader.prototype._scan = async function* (opts, into) {
194
195
  var h = this.header;
196
+ if (opts.transform) checkTransform(opts.transform, "transform");
195
197
  var plan = await this._plan();
196
198
  var keep = makeKeep(opts, h);
197
199
  var fields = resolveFields(h, opts.fields);
@@ -208,7 +210,7 @@ LasReader.prototype._scan = async function* (opts, into) {
208
210
  var kept = 0, w = 0;
209
211
  for await (var bytes of windowBytes(plan, pool, buf, opts.signal)) {
210
212
  var win = list[w];
211
- var cols = decodeRecords(bytes, h, { fields: fields, keep: keep, thin: thin, localOrigin: opts.localOrigin, float32: opts.float32, into: into ? into(win) : null });
213
+ var cols = decodeRecords(bytes, h, { fields: fields, keep: keep, thin: thin, localOrigin: opts.localOrigin, float32: opts.float32, transform: opts.transform, firstIndex: win.firstRecord, into: into ? into(win) : null });
212
214
  kept += cols.count;
213
215
  w++;
214
216
  if (typeof opts.onProgress === "function") opts.onProgress(w / list.length, "read " + kept + " points");
@@ -257,7 +259,7 @@ LasReader.prototype._countPassing = async function (plan, pool, buf, keep, signa
257
259
 
258
260
  /**
259
261
  * Read the selected points into one set of columns.
260
- * @param {object} [opts] - chunks() options plus dedupe (tolerance) and dedupeZ
262
+ * @param {object} [opts] - chunks() options plus dedupe (tolerance) and dedupeZ; dedupe runs on the output coordinates (after transform and localOrigin)
261
263
  * @returns {Promise<object>} PointColumns plus bounds and stats
262
264
  */
263
265
  LasReader.prototype.readPoints = async function (opts) {
@@ -273,7 +275,7 @@ LasReader.prototype.readPoints = async function (opts) {
273
275
  var cap = !filtered ? (opts.maxPoints > 0 ? Math.min(opts.maxPoints, h.pointCount) : h.pointCount)
274
276
  : (opts.maxPoints > 0 ? Math.min(opts.maxPoints, h.pointCount) : 0);
275
277
  var acc = {}, n = 0;
276
- fields.forEach(function (f) { acc[f] = new (columnType(f, opts))(cap || 1024); });
278
+ fields.forEach(function (f) { acc[f] = new (columnType(f, opts, h))(cap || 1024); });
277
279
  function into(win) {
278
280
  // A known cap is never exceeded, so it never grows; filters alone grow by doubling
279
281
  if (!cap && n + win.count > acc[fields[0]].length) {
@@ -5,18 +5,25 @@
5
5
  // tolerance-sized cells, neighbours checked), ported from point
6
6
  // objects to columns. XY by default (one Z per XY location, as a
7
7
  // 2.5D surface wants); { z: true } compares in 3D.
8
- // Every column follows the kept points.
8
+ // Every column follows the kept points (k values per point).
9
+ // dedupeSelection gives the same keep as a selection, plus a map from
10
+ // every input point to the output point it merged into.
9
11
  //==========================================================
10
12
 
11
13
  import { LasError, ERROR_CODES } from "../errors.js";
14
+ import { columnsOf, gather, sameType } from "../ops/columns.js";
12
15
 
13
16
  /**
17
+ * The points dedupe keeps, and where every input point went.
18
+ * map[i] is the output number (position in keep) of the kept point that point i
19
+ * merged into: for a dropped point, the nearest kept point within tolerance
20
+ * (lowest number on a tie); a kept point maps to itself.
14
21
  * @param {object} cols - PointColumns ({ count, x, y, z?, ... }).
15
22
  * @param {number} tolerance - distance below which points merge (> 0).
16
- * @param {{z?: boolean}} [opts]
17
- * @returns {object} PointColumns with only the kept points
23
+ * @param {{z?: boolean}} [opts] - z: compare in 3D.
24
+ * @returns {{keep: Uint32Array, map: Uint32Array}}
18
25
  */
19
- export function dedupe(cols, tolerance, opts) {
26
+ export function dedupeSelection(cols, tolerance, opts) {
20
27
  if (!(tolerance > 0)) throw new LasError(ERROR_CODES.INVALID_ARGUMENT, "dedupe tolerance must be > 0");
21
28
  var use3d = !!(opts && opts.z && cols.z);
22
29
  var tol2 = tolerance * tolerance;
@@ -43,6 +50,8 @@ export function dedupe(cols, tolerance, opts) {
43
50
  var head = new Map();
44
51
  var next = new Int32Array(n);
45
52
  var kept = new Uint32Array(n);
53
+ var map = new Uint32Array(n);
54
+ var dropped = [];
46
55
  var nKept = 0;
47
56
  for (var i = 0; i < n; i++) {
48
57
  var cx = Math.floor(x[i] / tolerance) - lo[0] + 1, cy = Math.floor(y[i] / tolerance) - lo[1] + 1;
@@ -61,21 +70,62 @@ export function dedupe(cols, tolerance, opts) {
61
70
  }
62
71
  }
63
72
  }
64
- if (dup) continue;
73
+ if (dup) { dropped.push(i); continue; }
65
74
  var own = key(cx, cy, cz);
66
75
  var prev = head.get(own);
67
76
  next[i] = prev === undefined ? -1 : prev;
68
77
  head.set(own, i);
78
+ map[i] = nKept;
69
79
  kept[nKept++] = i;
70
80
  }
71
81
 
82
+ // Each dropped point maps to its nearest kept point (the cell lists hold kept points only)
83
+ dropped.forEach(function (i) {
84
+ var cx = Math.floor(x[i] / tolerance) - lo[0] + 1, cy = Math.floor(y[i] / tolerance) - lo[1] + 1;
85
+ var cz = use3d ? Math.floor(z[i] / tolerance) - lo[2] + 1 : 1;
86
+ var best = -1, bd = Infinity;
87
+ for (var dx = -1; dx <= 1; dx++) {
88
+ for (var dy = -1; dy <= 1; dy++) {
89
+ for (var dz = -dzRange; dz <= dzRange; dz++) {
90
+ var k = head.get(key(cx + dx, cy + dy, cz + dz));
91
+ while (k !== undefined && k >= 0) {
92
+ var ex = x[k] - x[i], ey = y[k] - y[i];
93
+ var ez = use3d ? z[k] - z[i] : 0;
94
+ var d = ex * ex + ey * ey + ez * ez;
95
+ if (d <= tol2 && (d < bd || (d === bd && k < best))) { bd = d; best = k; }
96
+ k = next[k];
97
+ }
98
+ }
99
+ }
100
+ }
101
+ map[i] = map[best];
102
+ });
103
+ return { keep: kept.slice(0, nKept), map: map };
104
+ }
105
+
106
+ /**
107
+ * @param {object} cols - PointColumns ({ count, x, y, z?, ... }).
108
+ * @param {number} tolerance - distance below which points merge (> 0).
109
+ * @param {{z?: boolean}} [opts]
110
+ * @returns {object} PointColumns with only the kept points
111
+ */
112
+ export function dedupe(cols, tolerance, opts) {
113
+ var sel = dedupeSelection(cols, tolerance, opts).keep, nKept = sel.length;
114
+
115
+ // Every column (k values per point) follows the kept points; anything else passes through
116
+ var ks = Object.create(null); // a column may be named like an Object.prototype member
117
+ columnsOf(cols).forEach(function (c) { ks[c.name] = c.k; });
72
118
  var out = { count: nKept };
73
119
  Object.keys(cols).forEach(function (name) {
120
+ if (name === "count") return;
74
121
  var col = cols[name];
75
- if (!ArrayBuffer.isView(col) || col.length !== cols.count) { if (name !== "count") out[name] = col; return; }
76
- var c = new col.constructor(nKept);
77
- for (var q = 0; q < nKept; q++) c[q] = col[kept[q]];
78
- out[name] = c;
122
+ var v = col;
123
+ if (Object.prototype.hasOwnProperty.call(ks, name)) {
124
+ v = sameType(col, nKept * ks[name]);
125
+ gather(col, v, ks[name], sel);
126
+ }
127
+ // defineProperty so a column named "__proto__" stays a column
128
+ Object.defineProperty(out, name, { value: v, enumerable: true, writable: true, configurable: true });
79
129
  });
80
130
  return out;
81
131
  }
@@ -2,8 +2,8 @@
2
2
  //==========================================================
3
3
  // Record filters that run on the RAW record, before decoding, so
4
4
  // rejected points cost one or two byte reads (Kirra's classFilter
5
- // trick, generalised): classification codes, return numbers and a
6
- // 3D box. Filters combine with AND.
5
+ // trick, generalised): classification codes, return numbers, a
6
+ // 3D box and a polygon (crop on read). Filters combine with AND.
7
7
  // maxPoints thinning is NOT Kirra's floor-stride-and-cap rule, which
8
8
  // kept only the start of the file and, run before the filters,
9
9
  // could leave nothing: decodeRecords thins the records that PASS,
@@ -11,12 +11,16 @@
11
11
  //==========================================================
12
12
 
13
13
  import { POINT_FORMATS } from "../format/PointFormats.js";
14
+ import { makePolygonTest } from "../ops/polygon.js";
14
15
 
15
16
  var LE = true;
16
17
 
18
+ function limit(v, open) { return v === undefined || v === null ? open : v; }
19
+
17
20
  /**
18
21
  * Build a keep(view, recordOffset) test for decodeRecords.
19
- * @param {object} [filter] - { classes?: number[], returns?: number[], bounds?: {minX,maxX,minY,maxY,minZ,maxZ} }
22
+ * @param {object} [filter] - { classes?: number[], returns?: number[], bounds?: {minX,maxX,minY,maxY,minZ,maxZ} (an omitted or null limit is open),
23
+ * polygon?: number[] (null or omitted: no crop) [x0, y0, x1, y1, ...] with boundary ("touching" | "inside") and tolerance as selectPolygon, in the file's world coordinates }
20
24
  * @param {object} header - from parseHeader.
21
25
  * @returns {function(DataView, number): boolean|null} null when there is nothing to filter
22
26
  */
@@ -38,15 +42,32 @@ export function makeKeep(filter, header) {
38
42
  ? function (v, at) { return ret[v.getUint8(at + 14) & 15] === 1; } // bits-ok
39
43
  : function (v, at) { return ret[v.getUint8(at + 14) & 7] === 1; }); // bits-ok
40
44
  }
45
+ var sc = header.scale, of = header.offset;
41
46
  if (filter.bounds) {
42
- var b = filter.bounds, s = header.scale, o = header.offset;
47
+ // an omitted limit is open
48
+ var b = filter.bounds;
49
+ var minX = limit(b.minX, -Infinity), maxX = limit(b.maxX, Infinity);
50
+ var minY = limit(b.minY, -Infinity), maxY = limit(b.maxY, Infinity);
51
+ var minZ = limit(b.minZ, -Infinity), maxZ = limit(b.maxZ, Infinity);
52
+ tests.push(function (v, at) {
53
+ var x = v.getInt32(at, LE) * sc.x + of.x;
54
+ if (x < minX || x > maxX) return false;
55
+ var y = v.getInt32(at + 4, LE) * sc.y + of.y;
56
+ if (y < minY || y > maxY) return false;
57
+ var z = v.getInt32(at + 8, LE) * sc.z + of.z;
58
+ return z >= minZ && z <= maxZ;
59
+ });
60
+ }
61
+ if (filter.polygon !== undefined && filter.polygon !== null) {
62
+ // the file's world x, y of the raw record, as the bounds test computes them; boundary and tolerance as selectPolygon
63
+ var poly = makePolygonTest(filter.polygon, { boundary: filter.boundary, tolerance: filter.tolerance });
64
+ var inPoly = poly.test, pminX = poly.minX, pmaxX = poly.maxX, pminY = poly.minY, pmaxY = poly.maxY;
43
65
  tests.push(function (v, at) {
44
- var x = v.getInt32(at, LE) * s.x + o.x;
45
- if (x < b.minX || x > b.maxX) return false;
46
- var y = v.getInt32(at + 4, LE) * s.y + o.y;
47
- if (y < b.minY || y > b.maxY) return false;
48
- var z = v.getInt32(at + 8, LE) * s.z + o.z;
49
- return z >= b.minZ && z <= b.maxZ;
66
+ var x = v.getInt32(at, LE) * sc.x + of.x;
67
+ if (x < pminX || x > pmaxX) return false; // y is not decoded for a point outside the box in x
68
+ var y = v.getInt32(at + 4, LE) * sc.y + of.y;
69
+ if (y < pminY || y > pmaxY) return false;
70
+ return inPoly(x, y);
50
71
  });
51
72
  }
52
73
  if (!tests.length) return null;