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/CHANGELOG.md +55 -0
- package/LICENSE-APACHE +51 -0
- package/NOTICE +14 -0
- package/README.md +84 -27
- package/package.json +5 -3
- package/src/LasReader.js +187 -48
- package/src/colour/colourize.js +35 -3
- package/src/filter/dedupe.js +31 -14
- package/src/filter/filters.js +14 -10
- package/src/format/LasVlr.js +8 -5
- package/src/index.js +3 -3
- package/src/io/NodeFileSource.js +24 -2
- package/src/laz/ArithmeticDecoder.js +136 -0
- package/src/laz/IntegerDecompressor.js +81 -0
- package/src/laz/LazVlr.js +65 -0
- package/src/laz/chunkTable.js +109 -0
- package/src/laz/chunkWorker.js +51 -0
- package/src/laz/decompressChunk.js +115 -0
- package/src/laz/models.js +102 -0
- package/src/laz/pool.js +122 -0
- package/src/laz/v2/byte.js +33 -0
- package/src/laz/v2/gpstime11.js +118 -0
- package/src/laz/v2/point10.js +142 -0
- package/src/laz/v2/rgb12.js +79 -0
- package/src/laz/v3/layered.js +146 -0
- package/src/laz/v3/point14.js +266 -0
- package/src/read/decodeRecords.js +32 -14
- package/src/read/windows.js +21 -0
- package/src/workers/workerProtocol.js +10 -7
- package/src/write/writeLas.js +46 -13
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
var
|
|
94
|
-
|
|
95
|
-
|
|
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.
|
|
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 =
|
|
114
|
-
var
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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)
|
|
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();
|
package/src/colour/colourize.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/filter/dedupe.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
37
|
-
|
|
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
|
|
49
|
-
var
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
package/src/filter/filters.js
CHANGED
|
@@ -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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
*
|
|
61
|
-
* @param {
|
|
62
|
-
* @param {number}
|
|
63
|
-
* @
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
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
|
}
|
package/src/format/LasVlr.js
CHANGED
|
@@ -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
|
-
* @
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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";
|
package/src/io/NodeFileSource.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|