las-laz-js 0.3.1 → 0.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,76 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 (2026-10-22)
4
+
5
+ Point operations.
6
+
7
+ - Operations on point columns (the object `readPoints` returns, or any `{ count, <name>: TypedArray }`
8
+ whose arrays are `count × k` long), with no dependencies. A selection is a `Uint32Array` of
9
+ strictly ascending point indices, checked on use (`INVALID_ARGUMENT` names the bad position).
10
+ - Select: `selectBox`, `selectPolygon` (even-odd; `boundary: "touching"` keeps points on the edge,
11
+ `"inside"` drops them; `tolerance`; `minZ` / `maxZ`), `selectClasses`, `selectReturns` (`returns`,
12
+ `first`, `last`, `single`, any one matches), `thinSelection` (the reader's `maxPoints` rule,
13
+ exported), and `unionSelections`, `intersectSelections`, `subtractSelections`.
14
+ - Apply: `keepPoints`, `removePoints`, `splitPoints`, `mergePoints`. Copies by default; `inPlace: true`
15
+ on keep and remove compacts each column inside its own buffer and returns views of it (columns that
16
+ share memory are refused). `mergePoints` takes the union of the columns, lists zero-filled ones
17
+ in `filled`, widens types (Float64 over Float32 over integers; Int32 or Uint32 with Float32 gives
18
+ Float64), and refuses a `k` mismatch, a column named `count` or `filled`, and signed with unsigned
19
+ integers unless the signed type is strictly wider.
20
+ - Transform: `makeTransform({ shift, rotateZ, rotate, scale, about })` builds a row-major `{ m, t }`;
21
+ `transformPoints(cols, T, { inPlace })` applies it in Float64 and returns a new `{ count, columns }`
22
+ object (a copy has new x, y, z and shares the rest; `inPlace` returns the input's arrays, overwritten).
23
+ - `interleavePositions(cols, { layout, into, at, origin, float32 })` packs x y (z) into a caller's
24
+ Float32 / Float64 buffer, shifting by `origin` in Float64 first.
25
+ - Reads: `index` field (each point's record number; Uint32, Float64 above 4,294,967,295 points);
26
+ `polygon` / `boundary` / `tolerance` crop while reading, so only the region is decoded and held;
27
+ `transform` applies in the fixed order record → world → T → minus `localOrigin` → Float32.
28
+ Filters apply in file coordinates; the result's bounds and `dedupe` are in output coordinates,
29
+ and the worker's elevation colour range follows the transform.
30
+ - Changed: an omitted (or `null`) `bounds` limit is now open. Before, a missing limit kept nothing.
31
+ `polygon: null` means no polygon. `selectBox` and the `minZ` / `maxZ` of `selectPolygon` read a
32
+ `null` bound as open too.
33
+ - `dedupe` moves every column (k values per point, such as an interleaved `positions` or `rgb`) with
34
+ the kept points; other properties pass through. `transformPoints` with `inPlace` refuses x, y or z
35
+ that share memory. `mergePoints` ignores a zero-point input when widening types.
36
+ - Demo: an Edit tab (polygon, box and class selections; keep, delete, split, transform, crop on
37
+ read) and LAZ export.
38
+ - Docs: README "Point operations".
39
+
40
+ ## 0.4.0 (2026-10-22)
41
+
42
+ Writes LAZ.
43
+
44
+ - `writeLas(points, { compress: true })` and `createLasWriter(sink, { compress: true })` write LAZ:
45
+ point formats 0–3 (point-wise) and 6–8 (layered), with a LASzip VLR, 50,000-point chunks and a
46
+ chunk table, as PDAL (LAZperf) lays it out. Plain JavaScript, translated from laz-rs
47
+ (Apache-2.0), like the decoders. Formats 4, 5, 9, 10 cannot be compressed (`UNSUPPORTED`).
48
+ - `chunkSize`: points per chunk, a whole number 1 to 4294967294 (default 50,000); ignored without
49
+ `compress`.
50
+ - `threads`: chunks compress on the worker pool the LAZ reader uses, written in chunk order, so the
51
+ bytes are the same for any thread count. Automatic by default: from 4 chunks up, with at most 4
52
+ workers, as gains flatten after about 4 (explicit `threads` are honoured); `1` is the calling
53
+ thread. A 10 M-point LAZ (format 3) rewritten: `writeLas` 7166 ms on one thread, 1708 ms
54
+ automatic (4 workers, 4.2×), 1624 ms with 16; `createLasWriter` 7109 ms, 2033–2246 ms, 1657 ms.
55
+ - With workers, up to about 2 tasks of 4 chunks per worker of records are in flight: about 55 MB
56
+ at the automatic 4 workers with 50,000-point format 3 chunks. That holds across awaited
57
+ `writePoints` calls; unawaited calls, or one huge batch, submit at once. An abandoned writer
58
+ keeps its workers until `close()`.
59
+ - A streaming LAZ writer stops at its first error (a chunk, a sink write, `close()`):
60
+ `writePoints` and `close` keep rejecting. Start a new writer. After `close()`, both reject
61
+ (`ABORTED`).
62
+ - The compressed bytes are PDAL's (LAZperf's) with one lossless exception: in formats 6–8 a GPS
63
+ time of −0 following +0 is coded as a change (LAZperf finds the doubles equal).
64
+ - Extra bytes are not written yet (as for LAS); they arrive with 0.7.0.
65
+ - Checked against PDAL 2.10: the committed fixtures' chunks, chunk tables and LASzip VLRs are
66
+ PDAL's bytes; PDAL reads what we write (`pdal info`, `pdal translate`) and finds the same points
67
+ field by field. Real files compress and read back to the same points: a 1.1 GB, 34-million-point
68
+ LAS streams to a 155 MB LAZ (0.14 of its size) in 6.4 s; a 10-million-point LAZ recompresses to
69
+ the same size in 2.7 s. That file, written by PDAL 1.6 in 2019, comes back as LAS 1.2 format 3 with
70
+ the original's point data byte for byte, and opens in CloudCompare as LAS 1.2 format 3 and as
71
+ LAS 1.4 format 7.
72
+ - Docs: README "Writing LAZ"; the demo says LAZ is read and written.
73
+
3
74
  ## 0.3.1 (2026-10-21)
4
75
 
5
76
  Worker fixes.
package/NOTICE CHANGED
@@ -1,7 +1,7 @@
1
1
  las-laz-js
2
2
  Copyright (c) 2026 Brent Buffham (https://kirra-design.com). MIT licence (see LICENSE).
3
3
 
4
- The LAZ decoder in src/laz/ is a JavaScript translation of laz-rs
4
+ The LAZ decoder and encoder in src/laz/ are a JavaScript translation of laz-rs
5
5
  (https://github.com/laz-rs/laz-rs), which is itself a Rust port of LASzip
6
6
  (https://laszip.org) by rapidlasso. Those files are licensed under the
7
7
  Apache License, Version 2.0 (see LICENSE-APACHE), not the MIT licence:
package/README.md CHANGED
@@ -1,22 +1,26 @@
1
1
  # las-laz-js
2
2
 
3
- A JavaScript reader and writer for ASPRS **LAS** point clouds, and a reader for **LAZ**
3
+ A JavaScript reader and writer for ASPRS **LAS** point clouds and **LAZ**
4
4
  (LASzip-compressed LAS), published on npm as `las-laz-js`. Plain ES modules, no dependencies,
5
5
  runs in the browser and in Node — and built for files far larger than memory.
6
6
 
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` writes LAS 1.0–1.4.
12
- - LAZ: point formats 0–3 and 6–8, with extra bytes, read exactly like LAS — the same calls, the
13
- same options, decoded chunk by chunk in plain JavaScript (no WebAssembly).
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. See Point operations.
15
+ - LAZ: point formats 0–3 and 6–8, read exactly like LAS — the same calls, the same options,
16
+ decoded chunk by chunk in plain JavaScript (no WebAssembly) — and written with
17
+ `compress: true`, byte for byte what PDAL (LAZperf) writes (bar one lossless exception, see
18
+ Writing LAZ), compressed in parallel.
14
19
  - Big files: a 1.1 GB, 34-million-point LAS reads in under a second in Node, and a 10-million-point
15
20
  LAZ decodes on every core (a 10-million-point file in about 0.6 s); neither is held in memory
16
21
  whole (see LAZ below for the one exception).
17
22
  - Also: a ready-made module Worker (`las-laz-js/worker`) and a
18
23
  [Three.js demo](https://brentbuffham.github.io/las-laz-js/).
19
- - Not yet: writing LAZ (0.3.0).
20
24
 
21
25
  ## Install
22
26
 
@@ -34,8 +38,9 @@ Drop a LAS or LAZ file onto the page. It is read locally (never uploaded), decod
34
38
  library's Worker (LAZ on every core) behind a progress bar, then shown once in a Three.js viewer;
35
39
  colour changes recolour the points already read, without reading the file again. The side panel shows the header, VLRs, coordinate
36
40
  system and LAZ compression; reads with class, return, bounds, thinning and dedupe filters;
37
- colours by RGB, intensity, elevation or class; lists point statistics; and saves the points back
38
- out as LAS. To run it locally: `npm run demo`, then open http://localhost:8080/.
41
+ colours by RGB, intensity, elevation or class; lists point statistics; selects points by polygon,
42
+ box or class and keeps, deletes, splits, transforms or crops them (the Edit tab); and saves the
43
+ points back out as LAS or LAZ. To run it locally: `npm run demo`, then open http://localhost:8080/.
39
44
 
40
45
  ## Reading
41
46
 
@@ -55,7 +60,11 @@ var pts = await las.readPoints({
55
60
  fields: ["x", "y", "z", "classification", "red", "green", "blue"],
56
61
  classes: [2], // keep only these classification codes
57
62
  returns: [1], // keep only these return numbers
58
- bounds: { minX, maxX, minY, maxY, minZ, maxZ },
63
+ bounds: { minX, maxX, minY, maxY, minZ, maxZ }, // any limit may be left out: an omitted limit is open
64
+ polygon: [x0, y0, x1, y1, x2, y2], // crop: keep points inside it (file coordinates, XY, even-odd); null is none
65
+ boundary: "touching", // polygon edge: "touching" keeps points on it (default), "inside" drops them
66
+ tolerance: 0, // file units: within this of a polygon edge counts as on it
67
+ transform: T, // { m, t } from makeTransform, applied to world coordinates (see Point operations)
59
68
  maxPoints: 5000000, // even thinning; 0 keeps every point
60
69
  dedupe: 0.01, // drop points within 1 cm (XY; dedupeZ: true for 3D)
61
70
  localOrigin: { x: 478000, y: 6772000 }, float32: true, // small Float32 coordinates for the GPU
@@ -63,7 +72,7 @@ var pts = await las.readPoints({
63
72
  signal: abortController.signal
64
73
  });
65
74
  pts.count; pts.x; pts.y; pts.z; // one typed array per field
66
- pts.bounds; pts.stats; // world bounds, per-class counts
75
+ pts.bounds; pts.stats; // world bounds (after any transform), per-class counts
67
76
  ```
68
77
 
69
78
  Fields: `x y z intensity returnNumber numberOfReturns scanDirection edgeOfFlightLine
@@ -71,10 +80,141 @@ classification classificationFlags scannerChannel scanAngle userData pointSource
71
80
  red green blue nir`. Fields a point format does not hold are left out. `scanAngle` is in
72
81
  degrees for every format.
73
82
 
83
+ `index` is one more field, for every format: the column is present for the points the filters and
84
+ `maxPoints` keep, and each value is that record's position in the file (0-based; it is not
85
+ renumbered after filtering). It is a `Uint32Array`, or a `Float64Array` for a file of more than
86
+ 4,294,967,295 points. It is an ordinary column, so keep, remove, split and merge carry it, and it
87
+ tells you which records of the file are left.
88
+
74
89
  Filters run on the raw record before it is decoded, so a filtered-out point costs a byte read.
90
+ `polygon` is a crop on read: only the points inside it are decoded and held, so a site region of
91
+ a large file never costs the whole file in memory. It needs at least 3 vertices (a flat list of
92
+ x, y pairs, implicitly closed) and combines with the other filters and `maxPoints`. A LAZ crop
93
+ saves memory and field decoding but still decompresses every chunk (there is no spatial index), so
94
+ its time is close to a full read.
75
95
  `maxPoints` keeps exactly that many of the points that pass the filters, spread evenly over
76
96
  the whole file; with filters set it first counts the passing points, which reads the file twice.
77
97
 
98
+ In `bounds`, a limit that is left out (or `null`) is open; before 0.5.0 a missing limit kept
99
+ nothing. `polygon: null` means no polygon. `boundary` and `tolerance` belong to `polygon`: a point
100
+ within `tolerance` of an edge or vertex is on the boundary, kept by `"touching"` and dropped by
101
+ `"inside"`. They are not `dedupe`'s tolerance, which is the value of the `dedupe` option.
102
+
103
+ `transform` moves the points while they are decoded, in a fixed order: record → world (scale and
104
+ offset) → `transform` (Float64) → minus `localOrigin` → Float32 (when `float32` and `localOrigin`
105
+ are set). Raw scanner coordinates never pass through Float32 at full magnitude. The filters
106
+ (`classes`, `returns`, `bounds`, `polygon`) apply in **file** coordinates, before the transform;
107
+ the result's `bounds`, its stats and `dedupe` are in the output coordinates. To crop a scan in
108
+ site coordinates, read it transformed and use `selectPolygon`. The Worker's elevation colour
109
+ range follows the transform.
110
+
111
+ ## Point operations
112
+
113
+ Zero-dependency operations on **point columns**: the object `readPoints` returns, or any object
114
+ of the same shape, `{ count, <name>: TypedArray, ... }`. A property is a column when it is a typed
115
+ array whose length is `count × k` for a whole number `k` of 1 or more (`k` is 1 for `x` or
116
+ `classification`, 3 for an interleaved `positions` or `rgb`). Operations carry every column, `k`
117
+ values per point, and nothing else: `bounds`, `stats` and the like are not copied, so rebuild them
118
+ from the result. A **selection** is a `Uint32Array` of point indices, strictly ascending, each
119
+ below `count`. Everything that takes one checks it in one pass and throws `INVALID_ARGUMENT`
120
+ naming the first bad position; everything that makes one returns a valid one.
121
+
122
+ ### Select
123
+
124
+ ```js
125
+ import { selectBox, selectPolygon, selectClasses, selectReturns, thinSelection,
126
+ unionSelections, intersectSelections, subtractSelections } from "las-laz-js";
127
+
128
+ var inBox = selectBox(pts, { minX: 477010, maxX: 477050, minZ: 295 }); // inclusive; a limit left out is open
129
+ var inSite = selectPolygon(pts, poly, { boundary: "inside", tolerance: 0.001 }); // poly: [x0, y0, x1, y1, ...]
130
+ var ground = selectClasses(pts, [2]);
131
+ var last = selectReturns(pts, { last: true, single: true }); // returns: [..], first, last, single; any one matches
132
+ var thin = thinSelection(pts.count, 1000); // exactly min(count, 1000) indices, spread evenly
133
+ var wanted = intersectSelections(inSite, ground); // also unionSelections, subtractSelections
134
+ ```
135
+
136
+ `selectPolygon` takes a flat `[x0, y0, x1, y1, ...]` list of at least 3 vertices, closed for you,
137
+ in the columns' own coordinates; the interior is the even-odd rule, so a concave polygon works.
138
+ Points on the boundary are kept by `boundary: "touching"` (the default) and dropped by `"inside"`,
139
+ so the two differ by exactly the boundary points, and `splitPoints` by one polygon loses and
140
+ duplicates nothing. Coordinates are not exact in binary, so a point mathematically on an edge may not
141
+ count as on it at `tolerance: 0`; use a small tolerance (for example 1e-6 of the units) to catch
142
+ boundary points. `minZ` / `maxZ` add a height limit. `thinSelection` is the reader's
143
+ `maxPoints` rule: `keepPoints(all, thinSelection(n, t))` gives the points
144
+ `readPoints({ maxPoints: t })` does.
145
+
146
+ ### Keep, remove, split, merge
147
+
148
+ ```js
149
+ import { keepPoints, removePoints, splitPoints, mergePoints } from "las-laz-js";
150
+
151
+ var kept = keepPoints(pts, wanted); // new columns of the same types
152
+ var without = removePoints(pts, wanted);
153
+ var [inside, outside] = splitPoints(pts, inSite); // always copies; every point lands in one result
154
+ var both = mergePoints([inside, outside]); // concatenated in order; both.filled is []
155
+
156
+ var crop = keepPoints(big, selectPolygon(big, poly), { inPlace: true }); // no second copy
157
+ ```
158
+
159
+ `inPlace: true` (for `keepPoints` and `removePoints`) slides the kept values down inside each
160
+ column's own buffer and returns views of that memory: the input is overwritten, and a big cloud is
161
+ cropped without a second copy. Columns that share memory (the same array twice, or overlapping
162
+ views) are refused, since compacting one would corrupt the other.
163
+
164
+ `mergePoints` takes the union of the column names; a column missing from an input is zero for
165
+ that input's points, and its name is listed in `result.filled`. A column named `count` or
166
+ `filled` is refused. A column's `k` must agree across inputs. The result's type is the widest:
167
+ Float64 over Float32 over the integer types; an Int32 or Uint32 with a Float32 gives Float64
168
+ (Float32 holds integers exactly only to 2^24). Signed with unsigned integers merge only when the
169
+ signed type is strictly wider (Int16 with Uint8 is Int16; Int8 with Uint8, or Int32 with Uint32,
170
+ is `INVALID_ARGUMENT`). An input with no points is ignored when the type is chosen, so an empty
171
+ Int8 column does not block a Uint16 one; a column only an empty input has keeps its type.
172
+ Coordinates must already share one frame.
173
+
174
+ ### Transform
175
+
176
+ ```js
177
+ import { makeTransform, transformPoints } from "las-laz-js";
178
+
179
+ // p' = scale · R · (p − about) + about + shift; rotateZ is degrees anticlockwise about +Z
180
+ var T = makeTransform({ rotateZ: 90, about: [477050, 6770015, 0], shift: [10, 0, 0] });
181
+ // T = { m: [0, -1, 0, 1, 0, 0, 0, 0, 1], t: [7247075, 6292965, 0] } row-major: p' = m·p + t
182
+
183
+ var moved = transformPoints(pts, T); // { count, columns }: new x, y, z; the rest shared
184
+ var same = transformPoints(pts, T, { inPlace: true }); // the same x, y, z arrays, overwritten
185
+
186
+ var local = await las.readPoints({ // or while reading
187
+ fields: ["x", "y", "z"], transform: T,
188
+ localOrigin: { x: 477050, y: 6770015 }, float32: true
189
+ });
190
+ ```
191
+
192
+ A transform is a plain object, `{ m, t }`, with `m` nine numbers (a row-major 3×3) and `t` three.
193
+ Any affine map fits, so a fitted registration can be passed the same way; it is not Three.js's
194
+ column-major `Matrix4`. `makeTransform` takes `shift`, `about`, `scale` and either `rotateZ` or
195
+ `rotate` (a row-major 3×3; not both). `transformPoints` needs `x`, `y` and `z`, computes in
196
+ Float64 and stores in the columns' own types. It returns a new `{ count, columns }` object either
197
+ way: with `inPlace` the arrays in it are the input's, overwritten (and `x`, `y` and `z` must not
198
+ share memory, or it throws `INVALID_ARGUMENT`). On read, the order is the one
199
+ above.
200
+
201
+ ### Interleave for a renderer
202
+
203
+ ```js
204
+ import { interleavePositions } from "las-laz-js";
205
+
206
+ var positions = interleavePositions(pts, { origin: { x: 477000, y: 6770000 } }); // Float32Array, x y z per point
207
+ geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
208
+
209
+ // or into a buffer you own, starting at point 'at'; layout "xy" writes two values per point
210
+ interleavePositions(pts, { into: buffer, at: 50000, layout: "xyz", origin: { x: 477000, y: 6770000, z: 300 } });
211
+ ```
212
+
213
+ The origin is subtracted in Float64 before storing, so world coordinates keep their decimals in a
214
+ Float32 buffer; an axis left out of `origin` is not shifted ({ x, y } keeps world Z). `float32`
215
+ defaults to true and picks the type of a new array; `into` must be a Float32Array or Float64Array
216
+ long enough, else `INVALID_ARGUMENT`.
217
+
78
218
  ## LAZ
79
219
 
80
220
  A LAZ file is opened and read with the same calls as LAS. Each LASzip chunk (50,000 points in
@@ -135,6 +275,7 @@ import { writeLas, createLasWriter } from "las-laz-js";
135
275
 
136
276
  var bytes = await writeLas(pts, { version: "1.2", pointFormat: 3, crs: { epsg: 28350 } });
137
277
  var v14 = await writeLas(pts, { version: "1.4", pointFormat: 7, crs: { wkt: las.crs.wkt } });
278
+ var laz = await writeLas(pts, { compress: true, pointFormat: 3 }); // LAZ
138
279
 
139
280
  var w = await createLasWriter(sink, { version: "1.2", pointFormat: 3, scale, offset });
140
281
  // sink: an OPFS writer from openScratchSyncWriter, a Node FileHandle, or any { write(bytes, position) }
@@ -146,7 +287,44 @@ A version and point format that do not go together (format 6 in LAS 1.2) are ref
146
287
  silently changed. Scale and offset are chosen from the bounds when not given. `crs.epsg`
147
288
  writes a GeoKey record; `crs.wkt` a WKT record. Point formats 6–10 must carry WKT (LAS 1.4), so
148
289
  an EPSG code alone is refused for them — the library has no projection database to turn a code
149
- into WKT. Writing LAZ arrives in 0.3.0.
290
+ into WKT.
291
+
292
+ | Option | Default | Meaning |
293
+ |---|---|---|
294
+ | `compress` | `false` | write LAZ (LASzip) instead of LAS: point formats 0–3 and 6–8; formats 4, 5, 9, 10 are refused (`UNSUPPORTED`) |
295
+ | `chunkSize` | `50000` | points per LAZ chunk, a whole number from 1 to 4294967294; ignored without `compress` |
296
+ | `threads` | automatic | workers that compress chunks: automatic by default (from 4 chunks up, at most 4 workers), `1` compresses on the calling thread, `n` asks for n workers; only with `compress` |
297
+
298
+ ### Writing LAZ
299
+
300
+ `writeLas(pts, { compress: true })` and `createLasWriter(sink, { compress: true, ... })` write the
301
+ same records as the LAS writers, compressed `chunkSize` points at a time (LASzip's 50,000 by
302
+ default) behind a LASzip VLR, with the chunk table at the end of the point data. The layout and
303
+ the compressed bytes are those PDAL (LAZperf) writes: for the same points, version, format, scale
304
+ and offset, the point data is identical to PDAL's — with one exception: in formats 6–8 a GPS time
305
+ of −0 following +0 is coded as a change, so it reads back as −0 (lossless), where LAZperf compares
306
+ the doubles, finds them equal and writes no change. Point formats 0–3 are written point-wise
307
+ (LASzip item version 2), 6–8 layered (item version 3).
308
+
309
+ - **Speed.** Chunks are independent, so they compress on the same pool of workers that decode
310
+ LAZ, and are written strictly in chunk order: the bytes are the same for any `threads`. Gains
311
+ flatten after about four workers, so with `threads` automatic the writers use at most four (one
312
+ fewer than the cores, if that is less): a 10-million-point format 3 file writes in about 1.7 s
313
+ that way on a 24-core machine, 1.6 s with `threads: 16`, about 7 s on one thread. A write of
314
+ under four chunks (200,000 points) stays on one thread, where workers would cost more than they
315
+ save. If workers cannot start, it falls back to one thread.
316
+ - **Memory.** With workers, up to about 2 tasks of 4 chunks per worker are in flight at once —
317
+ about 55 MB of records at the automatic four workers with 50,000-point format 3 chunks (about
318
+ 220 MB at `threads: 16`). That bound holds across awaited `writePoints` calls; calls made
319
+ without waiting, or one huge batch, submit all at once. Pass a smaller `threads` or `chunkSize`
320
+ to bound it further. A writer that is abandoned without `close()` keeps its workers in a
321
+ browser until it is closed.
322
+ - **Errors.** A streaming LAZ writer stops for good at its first error — in a chunk, in a sink
323
+ write or in `close()` — and `writePoints` and `close` keep rejecting with it. Start a new
324
+ writer. After a successful `close()`, both reject (`ABORTED`, "the LAZ writer is closed").
325
+ - **Extra bytes** are not written (the writers have never written them): a file read with
326
+ extra bytes is written without them. They arrive with 0.7.0.
327
+ - The streaming writer needs `scale` and `offset` up front, as for LAS.
150
328
 
151
329
  ## In a Worker
152
330
 
@@ -160,7 +338,7 @@ worker.postMessage({ id: 2, type: "chunks", payload: { fields: ["x", "y", "z"],
160
338
  The protocol is documented in `src/workers/workerProtocol.js` and can be driven directly with
161
339
  `createWorkerHandler(post)`.
162
340
 
163
- **Bundlers.** The worker entry and the LAZ decode workers are found through
341
+ **Bundlers.** The worker entry and the LAZ decode and encode workers are found through
164
342
  `new Worker(new URL(..., import.meta.url))`, which Vite and webpack 5 follow. With Vite, keep the
165
343
  package out of dependency pre-bundling (`optimizeDeps: { exclude: ["las-laz-js"] }`), so the
166
344
  worker files stay next to the module that loads them.
@@ -182,11 +360,12 @@ COPC, EPT and E57 are not read.
182
360
  ## Provenance
183
361
 
184
362
  The LAS reader, writer and big-file handling come from [Kirra](https://kirra-design.com),
185
- Brent Buffham's blast-design app, rewritten as a standalone library. The LAZ decoder in
186
- `src/laz/` is a JavaScript translation of [laz-rs](https://github.com/laz-rs/laz-rs) by Thomas
363
+ Brent Buffham's blast-design app, rewritten as a standalone library. The LAZ decoder and encoder in
364
+ `src/laz/` are a JavaScript translation of [laz-rs](https://github.com/laz-rs/laz-rs) by Thomas
187
365
  Montaigu, itself a Rust port of [LASzip](https://laszip.org) by Martin Isenburg (rapidlasso).
188
- It is checked against an independent encoder: the test fixtures were compressed by
189
- [PDAL](https://pdal.io), and every record must equal PDAL's own decompression.
366
+ It is checked against an independent implementation: the test fixtures were compressed by
367
+ [PDAL](https://pdal.io), every record must equal PDAL's own decompression, and what this
368
+ library compresses must equal PDAL's bytes and be read by PDAL.
190
369
  Formats are written to the ASPRS LAS 1.4 R15 specification; see `docs/LAS_FORMAT_NOTES.md`.
191
370
 
192
371
  ## Development
@@ -200,12 +379,14 @@ node _dev/makeLazFixtures.mjs # remake test/fixtures/laz with PDAL (needs PDAL
200
379
  ```
201
380
 
202
381
  Private sample files go in the gitignored `samples/` folder, or are listed by path in
203
- `samples/paths.txt`; the sample tests compare each file with its own header, and each LAZ with a
204
- LAS twin (same count and bounds) field by field.
382
+ `samples/paths.txt`; the sample tests compare each file with its own header, each LAZ with a
383
+ LAS twin (same count and bounds) field by field, and compress each file to LAZ and read it back.
384
+ Set `LAS_PDAL=<path to pdal>` to also have PDAL read what this library writes
385
+ (`test/laz/pdalWrite.test.js` and the sample tests).
205
386
 
206
387
  ## Licence
207
388
 
208
- `MIT AND Apache-2.0`. The library is MIT © Brent Buffham (`LICENSE`). The LAZ decoder in
389
+ `MIT AND Apache-2.0`. The library is MIT © Brent Buffham (`LICENSE`). The LAZ decoder and encoder in
209
390
  `src/laz/`, translated from laz-rs / LASzip, is under the Apache License 2.0 (`LICENSE-APACHE`,
210
391
  `NOTICE`). Both are permissive: use, change and ship it in open or closed software, keeping the
211
392
  notices.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "las-laz-js",
3
- "version": "0.3.1",
4
- "description": "Zero-dependency JavaScript reader and writer for ASPRS LAS point clouds, and reader for LAZ (LASzip), for the browser and Node, built for files larger than memory.",
3
+ "version": "0.5.0",
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": {
7
7
  ".": "./src/index.js",
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,10 +5,11 @@
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
9
  //==========================================================
10
10
 
11
11
  import { LasError, ERROR_CODES } from "../errors.js";
12
+ import { columnsOf, gather, sameType } from "../ops/columns.js";
12
13
 
13
14
  /**
14
15
  * @param {object} cols - PointColumns ({ count, x, y, z?, ... }).
@@ -69,13 +70,21 @@ export function dedupe(cols, tolerance, opts) {
69
70
  kept[nKept++] = i;
70
71
  }
71
72
 
73
+ // Every column (k values per point) follows the kept points; anything else passes through
74
+ var sel = kept.subarray(0, nKept);
75
+ var ks = Object.create(null); // a column may be named like an Object.prototype member
76
+ columnsOf(cols).forEach(function (c) { ks[c.name] = c.k; });
72
77
  var out = { count: nKept };
73
78
  Object.keys(cols).forEach(function (name) {
79
+ if (name === "count") return;
74
80
  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;
81
+ var v = col;
82
+ if (Object.prototype.hasOwnProperty.call(ks, name)) {
83
+ v = sameType(col, nKept * ks[name]);
84
+ gather(col, v, ks[name], sel);
85
+ }
86
+ // defineProperty so a column named "__proto__" stays a column
87
+ Object.defineProperty(out, name, { value: v, enumerable: true, writable: true, configurable: true });
79
88
  });
80
89
  return out;
81
90
  }
@@ -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;
package/src/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  //==========================================================
7
7
 
8
8
  /** Library version, matches package.json. */
9
- export var VERSION = "0.3.1";
9
+ export var VERSION = "0.5.0";
10
10
 
11
11
  export { LasError, ERROR_CODES } from "./errors.js";
12
12
  export { openLas, LasReader } from "./LasReader.js";
@@ -17,6 +17,10 @@ export { POINT_FORMATS, formatHasRgb } from "./format/PointFormats.js";
17
17
  export { decodeRecords, DEFAULT_FIELDS, FIELD_TYPES } from "./read/decodeRecords.js";
18
18
  export { makeKeep, countKept } from "./filter/filters.js";
19
19
  export { dedupe } from "./filter/dedupe.js";
20
+ export { selectBox, selectPolygon, selectClasses, selectReturns, thinSelection, unionSelections, intersectSelections, subtractSelections } from "./ops/select.js";
21
+ export { keepPoints, removePoints, splitPoints, mergePoints } from "./ops/apply.js";
22
+ export { makeTransform, transformPoints } from "./ops/transform.js";
23
+ export { interleavePositions } from "./ops/interleave.js";
20
24
  export { MemorySource } from "./io/MemorySource.js";
21
25
  export { BlobSource } from "./io/BlobSource.js";
22
26
  export { NodeFileSource } from "./io/NodeFileSource.js";