las-laz-js 0.1.0 → 0.3.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/src/LasReader.js CHANGED
@@ -17,13 +17,18 @@ import { NodeFileSource } from "./io/NodeFileSource.js";
17
17
  import { parseHeader } from "./format/LasHeader.js";
18
18
  import { parseVlrs, parseEvlrs } from "./format/LasVlr.js";
19
19
  import { parseCrs } from "./format/LasCrs.js";
20
- import { decodeRecords, resolveFields } from "./read/decodeRecords.js";
21
- import { windows } from "./read/windows.js";
22
- import { makeKeep, strideFor } from "./filter/filters.js";
20
+ import { decodeRecords, resolveFields, columnType } from "./read/decodeRecords.js";
21
+ import { windows, lazWindows } from "./read/windows.js";
22
+ import { findLazVlr } from "./laz/LazVlr.js";
23
+ import { readChunks } from "./laz/chunkTable.js";
24
+ import { createChunkDecoder } from "./laz/decompressChunk.js";
25
+ import { createLazPool, defaultThreads } from "./laz/pool.js";
26
+ import { makeKeep, countKept } from "./filter/filters.js";
23
27
  import { dedupe } from "./filter/dedupe.js";
24
28
  import { openScratchSyncWriter } from "./io/ScratchBinaryFile.js";
25
29
 
26
30
  var DEFAULT_WINDOW = 64 * 1024 * 1024;
31
+ var AUTO_MIN_CHUNKS = 4;
27
32
 
28
33
  function memorySource(bytes) {
29
34
  return { size: bytes.length, read: async function (a, b) {
@@ -32,19 +37,35 @@ function memorySource(bytes) {
32
37
  } };
33
38
  }
34
39
 
40
+ // A caller's own { size, read } source: every read must hand back exactly
41
+ // the bytes asked for, or a short read would silently drop points
42
+ function checkedSource(src) {
43
+ function check(got, a, b) {
44
+ if (got instanceof ArrayBuffer) got = new Uint8Array(got);
45
+ if (!(got instanceof Uint8Array)) throw new LasError(ERROR_CODES.INVALID_ARGUMENT, "source.read(" + a + ", " + b + ") must return a Uint8Array or ArrayBuffer");
46
+ if (got.length !== b - a) throw new LasError(ERROR_CODES.TRUNCATED, "source.read(" + a + ", " + b + ") returned " + got.length + " bytes, not " + (b - a), a + Math.min(got.length, b - a));
47
+ return got;
48
+ }
49
+ var out = { size: src.size, read: async function (a, b) { return check(await src.read(a, b), a, b); } };
50
+ if (typeof src.readInto === "function") out.readInto = async function (a, b, t) { return check(await src.readInto(a, b, t), a, b); };
51
+ if (typeof src.close === "function") out.close = function () { return src.close(); };
52
+ return out;
53
+ }
54
+
35
55
  async function toSource(source) {
36
56
  if (source instanceof Uint8Array) return memorySource(source);
37
57
  if (source instanceof ArrayBuffer) return memorySource(new Uint8Array(source));
38
58
  if (typeof source === "string") return NodeFileSource.open(source);
39
59
  if (source && typeof source.slice === "function" && typeof source.size === "number" && typeof source.read !== "function") return new BlobSource(source);
40
- if (source && typeof source.size === "number" && typeof source.read === "function") return source;
60
+ if (source && typeof source.size === "number" && typeof source.read === "function") return checkedSource(source);
41
61
  throw new LasError(ERROR_CODES.INVALID_ARGUMENT, "openLas needs a File / Blob, ArrayBuffer, Uint8Array, file path or { size, read } source");
42
62
  }
43
63
 
44
64
  /**
45
65
  * Open a LAS (or LAZ) file and read its header and VLRs.
46
66
  * @param {*} source
47
- * @param {{windowSize?: number}} [opts]
67
+ * @param {{windowSize?: number, threads?: number}} [opts] - threads: LAZ decode workers
68
+ * (default automatic: cores - 1, at most 16; 1 = decode on this thread)
48
69
  * @returns {Promise<LasReader>}
49
70
  */
50
71
  export async function openLas(source, opts) {
@@ -59,12 +80,29 @@ export async function openLas(source, opts) {
59
80
  }
60
81
  var evlrs = [];
61
82
  if (header.numberOfEvlrs > 0 && header.evlrStart > 0 && header.evlrStart < src.size) {
62
- var evBytes = await src.read(header.evlrStart, src.size);
63
- evlrs = parseEvlrs(new MemorySource(evBytes), 0, header.numberOfEvlrs).map(function (e) {
64
- return Object.assign({}, e, { start: e.start + header.evlrStart });
65
- });
83
+ evlrs = await walkEvlrs(src, header.evlrStart, header.numberOfEvlrs);
66
84
  }
67
- return new LasReader(src, header, vlrs, evlrs, (opts && opts.windowSize) || DEFAULT_WINDOW);
85
+ var reader = new LasReader(src, header, vlrs, evlrs, (opts && opts.windowSize) || DEFAULT_WINDOW);
86
+ reader._threads = opts && opts.threads > 0 ? Math.floor(opts.threads) : 0; // 0 = automatic
87
+ return reader;
88
+ }
89
+
90
+ // EVLRs sit after the points and can be huge (waveform data): read each
91
+ // 60-byte header, keep the bytes of small and coordinate-system records,
92
+ // and leave the rest on disk for readEvlrBytes.
93
+ var EVLR_EAGER = 65536;
94
+ async function walkEvlrs(src, start, count) {
95
+ var out = [], pos = start;
96
+ for (var i = 0; i < count; i++) {
97
+ if (pos + 60 > src.size) throw new LasError(ERROR_CODES.BAD_VLR, "EVLR " + (i + 1) + " starts past the end of the file", pos);
98
+ var e = parseEvlrs(new MemorySource(await src.read(pos, pos + 60)), 0, 1, true)[0];
99
+ if (pos + 60 + e.length > src.size) throw new LasError(ERROR_CODES.BAD_VLR, "EVLR " + (i + 1) + " runs past the end of the file", pos);
100
+ e.start = pos + 60;
101
+ e.bytes = e.length <= EVLR_EAGER || e.userId === "LASF_Projection" ? new Uint8Array(await src.read(e.start, e.start + e.length)) : null;
102
+ out.push(e);
103
+ pos += 60 + e.length;
104
+ }
105
+ return out;
68
106
  }
69
107
 
70
108
  /**
@@ -83,17 +121,60 @@ export function LasReader(src, header, vlrs, evlrs, windowSize) {
83
121
  this.crs = parseCrs(vlrs.concat(evlrs));
84
122
  /** True for LAZ (compressed points). */
85
123
  this.compressed = header.compressed;
124
+ /** The parsed LASzip VLR of a LAZ file (compressor, chunk size, items), else null. */
125
+ this.laz = null;
126
+ if (header.compressed) { try { this.laz = findLazVlr(vlrs); } catch (e) { this.laz = null; } } // a bad VLR fails on read, not open
86
127
  }
87
128
 
88
- LasReader.prototype._check = function () {
89
- var h = this.header;
90
- if (h.compressed) {
91
- throw new LasError(ERROR_CODES.COMPRESSED_LAZ, "this file is LAZ (compressed); reading LAZ points is supported from las-laz-js 0.2.0");
92
- }
93
- var end = h.offsetToPointData + h.pointCount * h.recordLength;
94
- if (end > this._src.size) {
95
- throw new LasError(ERROR_CODES.TRUNCATED, "the header promises " + h.pointCount + " points ending at byte " + end + " but the file is " + this._src.size + " bytes", this._src.size);
129
+ // How to read this file's points: the windows, and how to turn one into raw
130
+ // LAS records (a LAS window is read as it is; a LAZ window is a run of whole
131
+ // chunks, decompressed into the scan's buffer). Built once per reader.
132
+ LasReader.prototype._plan = async function () {
133
+ if (this._planned) return this._planned;
134
+ var h = this.header, src = this._src, len = h.recordLength;
135
+ var plan;
136
+ if (!h.compressed) {
137
+ var end = h.offsetToPointData + h.pointCount * len;
138
+ if (end > src.size) {
139
+ throw new LasError(ERROR_CODES.TRUNCATED, "the header promises " + h.pointCount + " points ending at byte " + end + " but the file is " + src.size + " bytes", src.size);
140
+ }
141
+ var list = windows(h, this._windowSize);
142
+ plan = {
143
+ list: list,
144
+ // a source that can fill one reused buffer gets one (no bytes outlive a window)
145
+ bufferSize: typeof src.readInto === "function" && list.length ? list[0].end - list[0].start : 0,
146
+ records: function (win, buf) { return buf ? src.readInto(win.start, win.end, buf) : src.read(win.start, win.end); }
147
+ };
148
+ } else {
149
+ var vlr = findLazVlr(this.vlrs);
150
+ if (!vlr) throw new LasError(ERROR_CODES.BAD_VLR, "this LAZ file has no LASzip VLR (user \"laszip encoded\", record 22204), so its points cannot be decompressed");
151
+ var decode = createChunkDecoder(vlr, len);
152
+ var lazList = lazWindows(await readChunks(src, h, vlr), len, this._windowSize);
153
+ plan = {
154
+ list: lazList,
155
+ bufferSize: lazList.reduce(function (m, w) { return Math.max(m, w.count * len); }, 0),
156
+ records: async function (win, buf) {
157
+ var packed = await src.read(win.start, win.end);
158
+ var view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
159
+ win.chunks.forEach(function (c) { decode(packed, c.start - win.start, c.end - win.start, c.count, view, (c.firstRecord - win.firstRecord) * len, win.start); });
160
+ return buf.subarray(0, win.count * len);
161
+ },
162
+ // The same records, each chunk decoded by a pool worker from its own copy of the bytes
163
+ parallel: async function (win, pool) {
164
+ var packed = await src.read(win.start, win.end);
165
+ var out = new Uint8Array(win.count * len);
166
+ await Promise.all(win.chunks.map(function (c) {
167
+ var piece = packed.slice(c.start - win.start, c.end - win.start);
168
+ return pool.decode({ vlr: vlr, recordLength: len, packed: piece.buffer, chunks: [{ s: 0, e: piece.length, count: c.count }], base: c.start })
169
+ .then(function (rec) { out.set(rec, (c.firstRecord - win.firstRecord) * len); });
170
+ }));
171
+ return out;
172
+ },
173
+ chunkCount: lazList.reduce(function (n, w) { return n + w.chunks.length; }, 0)
174
+ };
96
175
  }
176
+ this._planned = plan;
177
+ return plan;
97
178
  };
98
179
 
99
180
  /**
@@ -104,33 +185,76 @@ LasReader.prototype._check = function () {
104
185
  LasReader.prototype.chunks = async function* (opts) {
105
186
  opts = opts || {};
106
187
  if (opts.dedupe) throw new LasError(ERROR_CODES.INVALID_ARGUMENT, "dedupe needs every point at once: use readPoints, not chunks");
107
- this._check();
188
+ yield* this._scan(opts, null);
189
+ };
190
+
191
+ // The window loop behind chunks and readPoints. into(win, n) -> { cols, at }
192
+ // makes decodeRecords write straight into readPoints' result columns.
193
+ LasReader.prototype._scan = async function* (opts, into) {
108
194
  var h = this.header;
195
+ var plan = await this._plan();
109
196
  var keep = makeKeep(opts, h);
110
- var stride = strideFor(h.pointCount, opts.maxPoints);
111
- var cap = opts.maxPoints > 0 ? opts.maxPoints : Infinity;
112
197
  var fields = resolveFields(h, opts.fields);
113
- var list = windows(h, this._windowSize);
114
- var kept = 0;
115
- for (var w = 0; w < list.length && kept < cap; w++) {
198
+ var list = plan.list;
199
+ var pool = await this._pool(plan);
200
+ try {
201
+ var buf = !pool && plan.bufferSize ? new Uint8Array(plan.bufferSize) : null;
202
+ var thin = null;
203
+ if (opts.maxPoints > 0) {
204
+ // Thin the points that pass the filters; with filters set, count them first (a second read)
205
+ var passing = keep ? await this._countPassing(plan, pool, buf, keep, opts.signal) : h.pointCount;
206
+ if (passing > opts.maxPoints) thin = { total: passing, target: opts.maxPoints, acc: 0 };
207
+ }
208
+ var kept = 0, w = 0;
209
+ for await (var bytes of windowBytes(plan, pool, buf, opts.signal)) {
210
+ 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 });
212
+ kept += cols.count;
213
+ w++;
214
+ if (typeof opts.onProgress === "function") opts.onProgress(w / list.length, "read " + kept + " points");
215
+ yield cols;
216
+ }
116
217
  if (opts.signal && opts.signal.aborted) throw new LasError(ERROR_CODES.ABORTED, "read aborted");
117
- var win = list[w];
118
- var bytes = await this._src.read(win.start, win.end);
119
- var cols = decodeRecords(bytes, h, { fields: fields, keep: keep, stride: stride, firstIndex: win.firstRecord, localOrigin: opts.localOrigin, float32: opts.float32 });
120
- if (kept + cols.count > cap) cols = trim(cols, fields, cap - kept);
121
- kept += cols.count;
122
- if (typeof opts.onProgress === "function") opts.onProgress(kept >= cap ? 1 : (w + 1) / list.length, "read " + kept + " points");
123
- yield cols;
218
+ } finally {
219
+ if (pool) pool.close();
124
220
  }
125
- if (opts.signal && opts.signal.aborted) throw new LasError(ERROR_CODES.ABORTED, "read aborted");
126
221
  };
127
222
 
128
- function trim(cols, fields, n) {
129
- var out = { count: n };
130
- fields.forEach(function (f) { out[f] = cols[f].slice(0, n); });
131
- return out;
223
+ // Each window's raw records in order. With a pool the next window is already
224
+ // decoding while this one is used; without one, one buffer is reused.
225
+ async function* windowBytes(plan, pool, buf, signal) {
226
+ var list = plan.list;
227
+ function fetch(i) {
228
+ var p = plan.parallel(list[i], pool);
229
+ p.catch(function () {}); // a prefetch abandoned by an abort must not go unhandled
230
+ return p;
231
+ }
232
+ var next = pool && list.length ? fetch(0) : null;
233
+ for (var w = 0; w < list.length; w++) {
234
+ if (signal && signal.aborted) throw new LasError(ERROR_CODES.ABORTED, "read aborted");
235
+ if (!pool) { yield await plan.records(list[w], buf); continue; }
236
+ var current = next;
237
+ next = w + 1 < list.length ? fetch(w + 1) : null;
238
+ yield await current;
239
+ }
132
240
  }
133
241
 
242
+ // A decode pool for this scan, or null: LAS, threads: 1, one chunk, or no workers available
243
+ LasReader.prototype._pool = async function (plan) {
244
+ if (!plan.parallel || this._threads === 1) return null;
245
+ // automatic: only when there is enough to share (starting workers costs about as much as
246
+ // decoding a few chunks); an explicit thread count is always honoured
247
+ if (!this._threads && plan.chunkCount < AUTO_MIN_CHUNKS) return null;
248
+ var n = Math.min(this._threads || await defaultThreads(), plan.chunkCount);
249
+ return n >= 2 ? createLazPool(n) : null;
250
+ };
251
+
252
+ LasReader.prototype._countPassing = async function (plan, pool, buf, keep, signal) {
253
+ var n = 0;
254
+ for await (var bytes of windowBytes(plan, pool, buf, signal)) n += countKept(bytes, this.header.recordLength, keep);
255
+ return n;
256
+ };
257
+
134
258
  /**
135
259
  * Read the selected points into one set of columns.
136
260
  * @param {object} [opts] - chunks() options plus dedupe (tolerance) and dedupeZ
@@ -142,22 +266,25 @@ LasReader.prototype.readPoints = async function (opts) {
142
266
  var chunkOpts = Object.assign({}, opts);
143
267
  delete chunkOpts.dedupe;
144
268
  var fields = resolveFields(h, opts.fields);
145
- var acc = null, n = 0;
146
- for await (var c of this.chunks(chunkOpts)) {
147
- if (!acc) {
148
- acc = {};
149
- var guess = Math.max(c.count, 1024);
150
- fields.forEach(function (f) { acc[f] = new c[f].constructor(guess); });
151
- }
152
- if (n + c.count > acc[fields[0]].length) {
153
- var size = Math.max(n + c.count, acc[fields[0]].length * 2);
269
+ // Allocate once when the count is known (no filters: every point, or
270
+ // maxPoints of them; filters + maxPoints: at most maxPoints), so the
271
+ // peak is the result itself. Filters alone: grow by doubling.
272
+ var filtered = !!makeKeep(opts, h);
273
+ var cap = !filtered ? (opts.maxPoints > 0 ? Math.min(opts.maxPoints, h.pointCount) : h.pointCount)
274
+ : (opts.maxPoints > 0 ? Math.min(opts.maxPoints, h.pointCount) : 0);
275
+ var acc = {}, n = 0;
276
+ fields.forEach(function (f) { acc[f] = new (columnType(f, opts))(cap || 1024); });
277
+ function into(win) {
278
+ // A known cap is never exceeded, so it never grows; filters alone grow by doubling
279
+ if (!cap && n + win.count > acc[fields[0]].length) {
280
+ var size = Math.max(n + win.count, acc[fields[0]].length * 2);
154
281
  fields.forEach(function (f) { var g = new acc[f].constructor(size); g.set(acc[f].subarray(0, n)); acc[f] = g; });
155
282
  }
156
- for (var i = 0; i < fields.length; i++) acc[fields[i]].set(c[fields[i]], n);
157
- n += c.count;
283
+ return { cols: acc, at: n };
158
284
  }
285
+ for await (var c of this._scan(chunkOpts, into)) n += c.count;
159
286
  var out = { count: n };
160
- fields.forEach(function (f) { out[f] = acc ? acc[f].slice(0, n) : new Float64Array(0); });
287
+ fields.forEach(function (f) { out[f] = acc[f].length === n ? acc[f] : acc[f].slice(0, n); });
161
288
  if (opts.dedupe) out = dedupe(out, opts.dedupe, { z: !!opts.dedupeZ });
162
289
  out.bounds = boundsOf(out, opts.localOrigin);
163
290
  out.stats = statsOf(out);
@@ -236,6 +363,18 @@ LasReader.prototype.readPointsToScratch = async function (opts) {
236
363
  return { count: count, fields: ids, types: types, bounds: worldBounds(b, opts.localOrigin) };
237
364
  };
238
365
 
366
+ /**
367
+ * The bytes of an EVLR — big ones are not read by openLas (bytes: null).
368
+ * @param {number} index - into reader.evlrs
369
+ * @returns {Promise<Uint8Array>}
370
+ */
371
+ LasReader.prototype.readEvlrBytes = async function (index) {
372
+ var e = this.evlrs[index];
373
+ if (!e) throw new LasError(ERROR_CODES.INVALID_ARGUMENT, "no EVLR " + index + " (the file has " + this.evlrs.length + ")");
374
+ if (e.bytes) return e.bytes;
375
+ return new Uint8Array(await this._src.read(e.start, e.start + e.length));
376
+ };
377
+
239
378
  /** Close the underlying file (Node). */
240
379
  LasReader.prototype.close = async function () {
241
380
  if (this._src && typeof this._src.close === "function") await this._src.close();
@@ -14,6 +14,12 @@
14
14
  // so one hot return does not wash the rest out.
15
15
  // classification: conventional viewer colours. ASPRS defines the
16
16
  // codes, not colours; these follow common LiDAR-viewer practice.
17
+ //
18
+ // createChunkColourizer colours a STREAM of chunks with one set of
19
+ // rules, so neighbouring windows match: elevation over the file's
20
+ // header Z range, intensity over the first chunk's percentiles, and
21
+ // the 8-bit decision made once (it only ever moves to 16-bit, when a
22
+ // later chunk holds a value above 255).
17
23
  //==========================================================
18
24
 
19
25
  import { LasError, ERROR_CODES } from "../errors.js";
@@ -81,9 +87,15 @@ function rampColours(values, n, range, ramp) {
81
87
  return out;
82
88
  }
83
89
 
90
+ function maxRgb(points) {
91
+ var n = points.count;
92
+ return Math.max(rangeOf(need(points, "red"), n)[1], rangeOf(need(points, "green"), n)[1], rangeOf(need(points, "blue"), n)[1]);
93
+ }
94
+
84
95
  /**
85
96
  * @param {object} points - PointColumns.
86
- * @param {{by:string, ramp?:string, range?:number[]}} opts
97
+ * @param {{by:string, ramp?:string, range?:number[], eightBit?:boolean}} opts - eightBit
98
+ * overrides the 8-bit colour detection
87
99
  * @returns {Uint8Array} r, g, b per point
88
100
  */
89
101
  export function colourize(points, opts) {
@@ -96,8 +108,7 @@ export function colourize(points, opts) {
96
108
  switch (opts.by) {
97
109
  case "rgb": {
98
110
  var r = need(points, "red"), g = need(points, "green"), b = need(points, "blue");
99
- var max = Math.max(rangeOf(r, n)[1], rangeOf(g, n)[1], rangeOf(b, n)[1]);
100
- var eightBit = max <= 255;
111
+ var eightBit = typeof opts.eightBit === "boolean" ? opts.eightBit : maxRgb(points) <= 255;
101
112
  out = new Uint8Array(n * 3);
102
113
  for (i = 0; i < n; i++) {
103
114
  out[i * 3] = eightBit ? r[i] : Math.min(255, Math.round(r[i] / 256));
@@ -127,3 +138,24 @@ export function colourize(points, opts) {
127
138
  throw new LasError(ERROR_CODES.INVALID_ARGUMENT, "colourize by \"" + opts.by + "\" is not a mode (rgb, intensity, elevation, classification)");
128
139
  }
129
140
  }
141
+
142
+ /**
143
+ * Colour a stream of chunks (reader.chunks) consistently from window to window.
144
+ * @param {{by:string, ramp?:string, range?:number[]}} opts - as colourize
145
+ * @param {{minZ:number, maxZ:number}} [bounds] - the file's header bounds (elevation range)
146
+ * @param {{z?:number}} [localOrigin] - the origin the chunks were read with
147
+ * @returns {function(object): Uint8Array} colours one chunk
148
+ */
149
+ export function createChunkColourizer(opts, bounds, localOrigin) {
150
+ opts = opts || {};
151
+ var range = opts.range || null, eightBit = null;
152
+ if (!range && opts.by === "elevation" && bounds && bounds.maxZ >= bounds.minZ) {
153
+ var oz = localOrigin && isFinite(localOrigin.z) ? localOrigin.z : 0;
154
+ range = [bounds.minZ - oz, bounds.maxZ - oz];
155
+ }
156
+ return function (points) {
157
+ if (opts.by === "intensity" && !range && points.count) range = percentiles(need(points, "intensity"), points.count);
158
+ if (opts.by === "rgb" && points.count && eightBit !== false) eightBit = maxRgb(points) <= 255;
159
+ return colourize(points, Object.assign({}, opts, { range: range || undefined, eightBit: eightBit === null ? undefined : eightBit }));
160
+ };
161
+ }
@@ -20,35 +20,52 @@ export function dedupe(cols, tolerance, opts) {
20
20
  if (!(tolerance > 0)) throw new LasError(ERROR_CODES.INVALID_ARGUMENT, "dedupe tolerance must be > 0");
21
21
  var use3d = !!(opts && opts.z && cols.z);
22
22
  var tol2 = tolerance * tolerance;
23
- var grid = new Map();
24
- var kept = new Uint32Array(cols.count);
25
- var nKept = 0;
23
+ var n = cols.count;
26
24
  var x = cols.x, y = cols.y, z = cols.z;
27
25
  var dzRange = use3d ? 1 : 0;
28
26
 
29
- for (var i = 0; i < cols.count; i++) {
30
- var cx = Math.floor(x[i] / tolerance), cy = Math.floor(y[i] / tolerance);
31
- var cz = use3d ? Math.floor(z[i] / tolerance) : 0;
27
+ // Cell indices, shifted so every neighbour is >= 0
28
+ var lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
29
+ for (var p = 0; p < n; p++) {
30
+ var a0 = Math.floor(x[p] / tolerance), a1 = Math.floor(y[p] / tolerance), a2 = use3d ? Math.floor(z[p] / tolerance) : 0;
31
+ if (a0 < lo[0]) lo[0] = a0; if (a0 > hi[0]) hi[0] = a0;
32
+ if (a1 < lo[1]) lo[1] = a1; if (a1 > hi[1]) hi[1] = a1;
33
+ if (a2 < lo[2]) lo[2] = a2; if (a2 > hi[2]) hi[2] = a2;
34
+ }
35
+ var SY = hi[1] - lo[1] + 3, SZ = hi[2] - lo[2] + 3;
36
+ // A dense numeric key when the cell count fits 2^53; else a hashed one —
37
+ // two cells may then share a key, which only adds candidates: every
38
+ // candidate is still distance-checked, so the result is exact either way
39
+ var dense = (hi[0] - lo[0] + 3) * SY * SZ <= 9007199254740992;
40
+ function key(i, j, k) { return dense ? (i * SY + j) * SZ + k : (i * 73856093 + j * 19349663 + k * 83492791) % 9007199254740881; }
41
+
42
+ // Each cell is a linked list through next[] (Map: key -> most recent point)
43
+ var head = new Map();
44
+ var next = new Int32Array(n);
45
+ var kept = new Uint32Array(n);
46
+ var nKept = 0;
47
+ for (var i = 0; i < n; i++) {
48
+ var cx = Math.floor(x[i] / tolerance) - lo[0] + 1, cy = Math.floor(y[i] / tolerance) - lo[1] + 1;
49
+ var cz = use3d ? Math.floor(z[i] / tolerance) - lo[2] + 1 : 1;
32
50
  var dup = false;
33
51
  for (var dx = -1; dx <= 1 && !dup; dx++) {
34
52
  for (var dy = -1; dy <= 1 && !dup; dy++) {
35
53
  for (var dz = -dzRange; dz <= dzRange && !dup; dz++) {
36
- var cell = grid.get((cx + dx) + "_" + (cy + dy) + "_" + (cz + dz));
37
- if (!cell) continue;
38
- for (var j = 0; j < cell.length; j++) {
39
- var k = cell[j];
54
+ var k = head.get(key(cx + dx, cy + dy, cz + dz));
55
+ while (k !== undefined && k >= 0) {
40
56
  var ex = x[k] - x[i], ey = y[k] - y[i];
41
57
  var ez = use3d ? z[k] - z[i] : 0;
42
58
  if (ex * ex + ey * ey + ez * ez <= tol2) { dup = true; break; }
59
+ k = next[k];
43
60
  }
44
61
  }
45
62
  }
46
63
  }
47
64
  if (dup) continue;
48
- var key = cx + "_" + cy + "_" + cz;
49
- var own = grid.get(key);
50
- if (!own) { own = []; grid.set(key, own); }
51
- own.push(i);
65
+ var own = key(cx, cy, cz);
66
+ var prev = head.get(own);
67
+ next[i] = prev === undefined ? -1 : prev;
68
+ head.set(own, i);
52
69
  kept[nKept++] = i;
53
70
  }
54
71
 
@@ -4,9 +4,10 @@
4
4
  // rejected points cost one or two byte reads (Kirra's classFilter
5
5
  // trick, generalised): classification codes, return numbers and a
6
6
  // 3D box. Filters combine with AND.
7
- // strideFor is Kirra's maxPoints rule: keep every
8
- // floor(count / maxPoints)-th record; 0 keeps every point. The
9
- // reader also caps the kept count at maxPoints.
7
+ // maxPoints thinning is NOT Kirra's floor-stride-and-cap rule, which
8
+ // kept only the start of the file and, run before the filters,
9
+ // could leave nothing: decodeRecords thins the records that PASS,
10
+ // evenly over the whole file (countKept supplies their number).
10
11
  //==========================================================
11
12
 
12
13
  import { POINT_FORMATS } from "../format/PointFormats.js";
@@ -57,12 +58,15 @@ export function makeKeep(filter, header) {
57
58
  }
58
59
 
59
60
  /**
60
- * Record stride for a maxPoints cap (Kirra's rule).
61
- * @param {number} pointCount
62
- * @param {number} maxPoints - 0 = keep every point
63
- * @returns {number} stride, at least 1
61
+ * Count the records in a window of whole records that pass keep.
62
+ * @param {Uint8Array} bytes
63
+ * @param {number} recordLength
64
+ * @param {function(DataView, number): boolean} keep
65
+ * @returns {number}
64
66
  */
65
- export function strideFor(pointCount, maxPoints) {
66
- if (!(maxPoints > 0) || pointCount <= maxPoints) return 1;
67
- return Math.max(1, Math.floor(pointCount / maxPoints));
67
+ export function countKept(bytes, recordLength, keep) {
68
+ var view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
69
+ var n = Math.floor(bytes.length / recordLength), k = 0;
70
+ for (var i = 0; i < n; i++) if (keep(view, i * recordLength)) k++;
71
+ return k;
68
72
  }
@@ -16,7 +16,7 @@ var LE = true;
16
16
  * @param {number} start - offset of the first record in src.
17
17
  * @param {number} count
18
18
  * @param {number} [limit] - records must end at or before this offset (default: end of src).
19
- * @returns {Array<{userId:string, recordId:number, description:string, start:number, bytes:Uint8Array}>}
19
+ * @returns {Array<{userId:string, recordId:number, description:string, start:number, length:number, bytes:Uint8Array}>}
20
20
  */
21
21
  export function parseVlrs(src, start, count, limit) {
22
22
  var end = typeof limit === "number" ? limit : src.length();
@@ -31,6 +31,7 @@ export function parseVlrs(src, start, count, limit) {
31
31
  recordId: src.getUint16(pos + 18, LE),
32
32
  description: readAscii(src, pos + 22, 32),
33
33
  start: pos + 54,
34
+ length: len,
34
35
  bytes: new Uint8Array(src.slice(pos + 54, pos + 54 + len))
35
36
  });
36
37
  pos += 54 + len;
@@ -43,21 +44,23 @@ export function parseVlrs(src, start, count, limit) {
43
44
  * @param {object} src - sync source holding the records (offsets in src).
44
45
  * @param {number} start
45
46
  * @param {number} count
46
- * @returns {Array<{userId:string, recordId:number, description:string, start:number, bytes:Uint8Array}>}
47
+ * @param {boolean} [headersOnly] - src holds only the headers: no bytes (null), no length check
48
+ * @returns {Array<{userId:string, recordId:number, description:string, start:number, length:number, bytes:Uint8Array|null}>}
47
49
  */
48
- export function parseEvlrs(src, start, count) {
50
+ export function parseEvlrs(src, start, count, headersOnly) {
49
51
  var out = [];
50
52
  var pos = start;
51
53
  for (var i = 0; i < count; i++) {
52
54
  if (pos + 60 > src.length()) throw new LasError(ERROR_CODES.BAD_VLR, "EVLR " + (i + 1) + " starts past the end of the file", pos);
53
55
  var len = readU64(src, pos + 20, LE);
54
- if (len === null || pos + 60 + len > src.length()) throw new LasError(ERROR_CODES.BAD_VLR, "EVLR " + (i + 1) + " runs past the end of the file", pos);
56
+ if (len === null || (!headersOnly && pos + 60 + len > src.length())) throw new LasError(ERROR_CODES.BAD_VLR, "EVLR " + (i + 1) + " runs past the end of the file", pos);
55
57
  out.push({
56
58
  userId: readAscii(src, pos + 2, 16),
57
59
  recordId: src.getUint16(pos + 18, LE),
58
60
  description: readAscii(src, pos + 28, 32),
59
61
  start: pos + 60,
60
- bytes: new Uint8Array(src.slice(pos + 60, pos + 60 + len))
62
+ length: len,
63
+ bytes: headersOnly ? null : new Uint8Array(src.slice(pos + 60, pos + 60 + len))
61
64
  });
62
65
  pos += 60 + len;
63
66
  }
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.1.0";
9
+ export var VERSION = "0.3.0";
10
10
 
11
11
  export { LasError, ERROR_CODES } from "./errors.js";
12
12
  export { openLas, LasReader } from "./LasReader.js";
@@ -15,13 +15,13 @@ export { parseVlrs, parseEvlrs } from "./format/LasVlr.js";
15
15
  export { parseCrs } from "./format/LasCrs.js";
16
16
  export { POINT_FORMATS, formatHasRgb } from "./format/PointFormats.js";
17
17
  export { decodeRecords, DEFAULT_FIELDS, FIELD_TYPES } from "./read/decodeRecords.js";
18
- export { makeKeep, strideFor } from "./filter/filters.js";
18
+ export { makeKeep, countKept } from "./filter/filters.js";
19
19
  export { dedupe } from "./filter/dedupe.js";
20
20
  export { MemorySource } from "./io/MemorySource.js";
21
21
  export { BlobSource } from "./io/BlobSource.js";
22
22
  export { NodeFileSource } from "./io/NodeFileSource.js";
23
23
  export { SCRATCH_DIR, openScratchSyncWriter, openScratchReader, removeScratchFile, sweepStaleScratchFiles, listScratchFiles, getScratchFile } from "./io/ScratchBinaryFile.js";
24
- export { colourize, RAMPS, CLASS_COLOURS } from "./colour/colourize.js";
24
+ export { colourize, createChunkColourizer, RAMPS, CLASS_COLOURS } from "./colour/colourize.js";
25
25
  export { writeLas, createLasWriter } from "./write/writeLas.js";
26
26
  export { chooseScaleOffset } from "./write/scaleOffset.js";
27
27
  export { createWorkerHandler } from "./workers/workerProtocol.js";
@@ -26,11 +26,15 @@ export function NodeFileSource(handle, size) {
26
26
  * @param {string} path
27
27
  * @returns {Promise<NodeFileSource>}
28
28
  */
29
+ var NODE_FS = ["node", "fs/promises"].join(":");
30
+
29
31
  NodeFileSource.open = async function (path) {
30
32
  if (typeof path !== "string" || !path) {
31
33
  throw new LasError(ERROR_CODES.INVALID_ARGUMENT, "NodeFileSource needs a file path");
32
34
  }
33
- var fs = await import("node:fs/promises");
35
+ // A non-literal specifier, so browser bundlers (esbuild, webpack, Vite)
36
+ // leave it alone instead of failing to resolve a Node built-in
37
+ var fs = await import(/* webpackIgnore: true */ /* @vite-ignore */ NODE_FS);
34
38
  var handle = await fs.open(path, "r");
35
39
  var stat = await handle.stat();
36
40
  return new NodeFileSource(handle, stat.size);
@@ -49,7 +53,25 @@ NodeFileSource.prototype.read = async function (start, end) {
49
53
  if (end > this.size) {
50
54
  throw new LasError(ERROR_CODES.TRUNCATED, "read past end of " + this.size + "-byte file", end);
51
55
  }
52
- var out = new Uint8Array(end - start);
56
+ return this.readInto(start, end, new Uint8Array(end - start));
57
+ };
58
+
59
+ /**
60
+ * Read bytes [start, end) into the front of target (reused by the reader
61
+ * from window to window, so a big read leaves no per-window garbage).
62
+ * @param {number} start
63
+ * @param {number} end
64
+ * @param {Uint8Array} target - at least end - start bytes
65
+ * @returns {Promise<Uint8Array>} the filled view of target
66
+ */
67
+ NodeFileSource.prototype.readInto = async function (start, end, target) {
68
+ if (start < 0 || end < start) {
69
+ throw new LasError(ERROR_CODES.INVALID_ARGUMENT, "bad read range " + start + ".." + end);
70
+ }
71
+ if (end > this.size) {
72
+ throw new LasError(ERROR_CODES.TRUNCATED, "read past end of " + this.size + "-byte file", end);
73
+ }
74
+ var out = target.subarray(0, end - start);
53
75
  var done = 0;
54
76
  while (done < out.length) {
55
77
  var r = await this._handle.read(out, done, out.length - done, start + done);