las-laz-js 0.3.0 → 0.4.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.
@@ -6,6 +6,15 @@
6
6
  // file handle) and patches the header totals on close. Both share
7
7
  // one header builder, so they produce identical bytes.
8
8
  //
9
+ // With compress: true both write LAZ (point formats 0-3 and 6-8): the same
10
+ // records, compressed chunkSize points at a time (LASzip's 50,000 by
11
+ // default), after a LASzip VLR; the point data is an 8-byte offset to
12
+ // the chunk table, the chunks, then the table — laid out as LAZperf
13
+ // (PDAL) writes it. Chunks compress independently, so with threads
14
+ // they compress on the LAZ worker pool (src/laz/pool.js), several to a
15
+ // task, and are written strictly in chunk order whatever order they
16
+ // finish in: the bytes are the same for any thread count.
17
+ //
9
18
  // Ported from Kirra's LASWriter, with two changes: an invalid
10
19
  // version / format pair is REFUSED (Kirra silently downgraded the
11
20
  // format), and the coordinate system is written (GeoKey EPSG
@@ -16,9 +25,24 @@ import { LasError, ERROR_CODES } from "../errors.js";
16
25
  import { POINT_FORMATS } from "../format/PointFormats.js";
17
26
  import { chooseScaleOffset } from "./scaleOffset.js";
18
27
  import { encodeRecords } from "./encodeRecords.js";
28
+ import { lazVlrFor, LASZIP_USER_ID, LASZIP_RECORD_ID } from "../laz/LazVlr.js";
29
+ import { createChunkCompressor } from "../laz/compressChunk.js";
30
+ import { encodeChunkTable } from "../laz/chunkTable.js";
31
+ import { createLazPool, defaultThreads } from "../laz/pool.js";
19
32
 
20
33
  var LE = true;
21
34
  var HEADER_SIZE = { "1.0": 227, "1.1": 227, "1.2": 227, "1.3": 235, "1.4": 375 };
35
+ var DEFAULT_CHUNK_SIZE = 50000;
36
+ // 0xFFFFFFFF in the VLR's chunk size field means variable-size chunks
37
+ var MAX_CHUNK_SIZE = 4294967294;
38
+ // threads, automatic: a pool once a write has this many chunks (as openLas),
39
+ // of at most AUTO_MAX_THREADS workers — gains flatten there, while the records
40
+ // in flight grow with every worker (an explicit threads is honoured as given)
41
+ var AUTO_MIN_CHUNKS = 4;
42
+ var AUTO_MAX_THREADS = 4;
43
+ // chunks per pool task, and tasks in flight per worker (one working, one queued)
44
+ var CHUNKS_PER_TASK = 4;
45
+ var TASKS_PER_WORKER = 2;
22
46
 
23
47
  function ascii(v, pos, text, max) {
24
48
  for (var i = 0; i < max; i++) v.setUint8(pos + i, i < text.length ? text.charCodeAt(i) & 0x7F : 0); // bits-ok
@@ -68,7 +92,19 @@ function settle(points, opts) {
68
92
  var minor = Number(version.split(".")[1]);
69
93
  if (fmt >= 6 && minor < 4) throw new LasError(ERROR_CODES.INVALID_ARGUMENT, "point format " + fmt + " needs LAS 1.4 (asked for " + version + ")");
70
94
  if ((fmt === 4 || fmt === 5) && minor < 3) throw new LasError(ERROR_CODES.INVALID_ARGUMENT, "point format " + fmt + " needs LAS 1.3 or later (asked for " + version + ")");
71
- return { version: version, minor: minor, fmt: fmt, opts: opts };
95
+ return { version: version, minor: minor, fmt: fmt, opts: opts, laz: lazSetup(fmt, opts) };
96
+ }
97
+
98
+ // compress: the LASzip VLR and the chunk compressor. chunkSize is ignored without compress.
99
+ function lazSetup(fmt, opts) {
100
+ if (!opts.compress) return null;
101
+ var chunkSize = opts.chunkSize == null ? DEFAULT_CHUNK_SIZE : opts.chunkSize;
102
+ if (!Number.isInteger(chunkSize) || chunkSize < 1 || chunkSize > MAX_CHUNK_SIZE) {
103
+ throw new LasError(ERROR_CODES.INVALID_ARGUMENT, "chunkSize must be a whole number of points from 1 to " + MAX_CHUNK_SIZE + " (got " + chunkSize + ")");
104
+ }
105
+ var len = POINT_FORMATS[fmt].size;
106
+ var made = lazVlrFor(fmt, len, chunkSize); // UNSUPPORTED for formats 4, 5, 9, 10
107
+ return { chunkSize: chunkSize, vlr: made.vlr, vlrBytes: made.bytes, compress: createChunkCompressor(made.vlr, len) };
72
108
  }
73
109
 
74
110
  function freshTotals() {
@@ -80,6 +116,7 @@ function headerBytes(s, so, totals) {
80
116
  var o = s.opts;
81
117
  var headerSize = HEADER_SIZE[s.version];
82
118
  var vlrs = crsRecords(o.crs, s.fmt);
119
+ if (s.laz) vlrs.push({ userId: LASZIP_USER_ID, recordId: LASZIP_RECORD_ID, description: "http://laszip.org", bytes: s.laz.vlrBytes });
83
120
  var vlrSize = vlrs.reduce(function (n, r) { return n + 54 + r.bytes.length; }, 0);
84
121
  var out = new Uint8Array(headerSize + vlrSize);
85
122
  var v = new DataView(out.buffer);
@@ -101,7 +138,7 @@ function headerBytes(s, so, totals) {
101
138
  v.setUint16(94, headerSize, LE);
102
139
  v.setUint32(96, headerSize + vlrSize, LE);
103
140
  v.setUint32(100, vlrs.length, LE);
104
- v.setUint8(104, s.fmt);
141
+ v.setUint8(104, s.laz ? s.fmt + 128 : s.fmt); // bit 7: compressed
105
142
  v.setUint16(105, fmt.size, LE);
106
143
  var legacyOk = s.fmt < 6 && totals.count < 4294967296;
107
144
  v.setUint32(107, legacyOk ? totals.count : 0, LE);
@@ -144,9 +181,11 @@ function boundsOfPoints(points, origin) {
144
181
  }
145
182
 
146
183
  /**
147
- * Write a whole LAS file.
184
+ * Write a whole LAS file, or LAZ with compress: true (point formats 0-3 and 6-8).
148
185
  * @param {object} points - PointColumns (world coordinates, or relative to opts.localOrigin)
149
- * @param {object} [opts] - version, pointFormat, scale, offset, crs {epsg|wkt}, software, systemId, date, localOrigin, as ("bytes" | "blob")
186
+ * @param {object} [opts] - version, pointFormat, scale, offset, crs {epsg|wkt}, software, systemId, date, localOrigin, as ("bytes" | "blob"),
187
+ * compress (write LAZ), chunkSize (points per LAZ chunk, default 50000; only with compress),
188
+ * threads (LAZ compression workers: default automatic, from 4 chunks and at most 4 workers; 1 = this thread; n = n workers)
150
189
  * @returns {Promise<Uint8Array|Blob>}
151
190
  */
152
191
  export async function writeLas(points, opts) {
@@ -156,6 +195,10 @@ export async function writeLas(points, opts) {
156
195
  if (o.scale && !o.offset) so = { scale: o.scale, offset: chooseScaleOffset(boundsOfPoints(points, o.localOrigin)).offset };
157
196
  var len = POINT_FORMATS[s.fmt].size;
158
197
  var totals = freshTotals();
198
+ if (s.laz) {
199
+ var parts = await lazParts(points, s, so, len, totals);
200
+ return o.as === "blob" ? new Blob(parts, { type: "application/octet-stream" }) : joinParts(parts);
201
+ }
159
202
  // One buffer: records are encoded in place after the header, whose
160
203
  // size does not depend on the totals; the header is filled in last
161
204
  var headLength = headerBytes(s, so, totals).length;
@@ -166,6 +209,181 @@ export async function writeLas(points, opts) {
166
209
  return o.as === "blob" ? new Blob([out], { type: "application/octet-stream" }) : out;
167
210
  }
168
211
 
212
+ // Columns a..b of a PointColumns (views where the columns are typed arrays)
213
+ function rangeOf(cols, a, b) {
214
+ if (a === 0 && b === cols.count) return cols;
215
+ var out = { count: b - a };
216
+ for (var k in cols) {
217
+ var c = cols[k];
218
+ if (k !== "count" && c && typeof c.length === "number") out[k] = typeof c.subarray === "function" ? c.subarray(a, b) : c.slice(a, b);
219
+ }
220
+ return out;
221
+ }
222
+
223
+ function joinParts(parts) {
224
+ var total = parts.reduce(function (n, p) { return n + p.length; }, 0);
225
+ var out = new Uint8Array(total), at = 0;
226
+ parts.forEach(function (p) { out.set(p, at); at += p.length; });
227
+ return out;
228
+ }
229
+
230
+ // The i64 offset to the chunk table (an absolute file position), as two u32 halves
231
+ function tableOffsetBytes(tablePos) {
232
+ var b = new Uint8Array(8);
233
+ setU64(new DataView(b.buffer), 0, tablePos);
234
+ return b;
235
+ }
236
+
237
+ // threads as openLas: 0 = automatic
238
+ function threadsOf(opts) { return opts.threads > 0 ? Math.floor(opts.threads) : 0; }
239
+
240
+ // A pool of up to `most` workers (one per chunk at most, when the count is known), or null;
241
+ // automatic: at most AUTO_MAX_THREADS
242
+ async function startPool(opts, most) {
243
+ var n = Math.min(threadsOf(opts) || Math.min(AUTO_MAX_THREADS, await defaultThreads()), most);
244
+ if (n < 2) return null;
245
+ return opts._createPool ? opts._createPool(n) : createLazPool(n);
246
+ }
247
+
248
+ // The chunk loop both LAZ writers share. Records fill a buffer; each full
249
+ // chunk is compressed on this thread, until the poolAt-th full chunk calls
250
+ // makePool — from then on chunks go to the pool's workers CHUNKS_PER_TASK
251
+ // at a time (or, with no pool, still to this thread). place(bytes) gets
252
+ // each task's chunks, back to back, strictly in chunk order: a task that
253
+ // finishes early waits for the ones before it. Records are encoded before
254
+ // write()'s first await, so calls made without waiting keep their
255
+ // order. write() returns once fewer than TASKS_PER_WORKER tasks per
256
+ // worker are in flight (on this thread, once its chunks are placed), so
257
+ // memory is bounded. Any error — on a worker, on this thread, in place()
258
+ // or reported through fail(err) — stops the loop for good and closes the
259
+ // pool, as finish() does once it has placed the last chunk; after
260
+ // finish() is called, write() refuses more points (ABORTED).
261
+ function lazChunks(s, so, len, totals, place, poolAt, makePool) {
262
+ var size = s.laz.chunkSize;
263
+ var buf = new Uint8Array(0), view = null, filled = 0, full = 0;
264
+ var pooled = null, closing = false, failure = null, ended = false;
265
+ var order = Promise.resolve(), inflight = [], chunks = [];
266
+
267
+ // grow the buffer to hold n records (doubling; one chunk on this thread, a task's on the pool)
268
+ function room(n) {
269
+ if (buf.length >= n * len) return;
270
+ var most = pooled ? CHUNKS_PER_TASK * size : size;
271
+ var grown = new Uint8Array(Math.max(n, Math.min(most, 2 * buf.length / len)) * len);
272
+ grown.set(buf.subarray(0, filled * len));
273
+ buf = grown;
274
+ view = new DataView(buf.buffer);
275
+ }
276
+ function shut() {
277
+ if (!pooled || closing) return;
278
+ closing = true;
279
+ pooled.then(function (pool) { if (pool) pool.close(); });
280
+ }
281
+ function stop(err) {
282
+ if (!failure) failure = err;
283
+ shut();
284
+ }
285
+ function refuse() {
286
+ if (failure) throw failure;
287
+ if (ended) throw new LasError(ERROR_CODES.ABORTED, "the LAZ writer is closed");
288
+ }
289
+ function onThisThread(records, counts) {
290
+ var parts = [], p = 0;
291
+ counts.forEach(function (n) { parts.push(s.laz.compress(records, p, n)); p += n * len; });
292
+ return { bytes: parts.length === 1 ? parts[0] : joinParts(parts), sizes: parts.map(function (c) { return c.length; }) };
293
+ }
294
+ function submit(records, counts) {
295
+ // on this thread now (its buffer is reused); on the pool from records the task then owns.
296
+ // An error on this thread rejects the task, as a worker's does, so it stops the loop below
297
+ var task;
298
+ if (!pooled) {
299
+ try { task = Promise.resolve(onThisThread(records, counts)); } catch (err) { task = Promise.reject(err); }
300
+ } else {
301
+ task = pooled.then(function (pool) {
302
+ return pool ? pool.compress({ vlr: s.laz.vlr, recordLength: len, records: records.buffer, counts: counts }) : onThisThread(records, counts);
303
+ });
304
+ }
305
+ task.catch(function () {}); // tasks after a failure are never waited for
306
+ var placed = order.then(function () { return task; }).then(function (r) {
307
+ r.sizes.forEach(function (n, i) { chunks.push({ pointCount: counts[i], byteCount: n }); });
308
+ return place(r.bytes);
309
+ });
310
+ placed.catch(stop);
311
+ order = placed;
312
+ inflight.push(placed);
313
+ }
314
+ // everything in the buffer as one task: full chunks, then a partial one at the end
315
+ function submitBuffer() {
316
+ var counts = [];
317
+ for (var k = 0; k < filled; k += size) counts.push(Math.min(size, filled - k));
318
+ var records = buf;
319
+ buf = new Uint8Array(0);
320
+ view = null;
321
+ filled = 0;
322
+ submit(records, counts);
323
+ }
324
+ function chunkFull() {
325
+ full++;
326
+ if (!pooled && full >= poolAt) pooled = makePool().catch(function () { return null; });
327
+ if (!pooled) { submit(buf, [size]); filled = 0; }
328
+ else if (filled === CHUNKS_PER_TASK * size) submitBuffer();
329
+ }
330
+ return {
331
+ // points, the first of which is point `first` of the caller's (for error messages)
332
+ write: async function (points, first) {
333
+ refuse();
334
+ try {
335
+ for (var a = 0; a < points.count;) {
336
+ var n = Math.min(size - filled % size, points.count - a);
337
+ room(filled + n);
338
+ encodeRecords(rangeOf(points, a, a + n), s.fmt, len, so, s.opts.localOrigin, view, filled * len, totals, first + a);
339
+ filled += n;
340
+ a += n;
341
+ totals.count += n;
342
+ if (filled % size === 0) chunkFull();
343
+ }
344
+ } catch (err) {
345
+ stop(err);
346
+ throw err;
347
+ }
348
+ var pool = pooled ? await pooled : null;
349
+ var limit = pool ? TASKS_PER_WORKER * pool.size : 0;
350
+ while (inflight.length && inflight.length >= limit) await inflight.shift();
351
+ },
352
+ finish: async function () {
353
+ refuse();
354
+ ended = true;
355
+ try {
356
+ if (filled) submitBuffer();
357
+ await order;
358
+ } catch (err) {
359
+ stop(err);
360
+ throw err;
361
+ } finally { shut(); }
362
+ return chunks;
363
+ },
364
+ fail: stop
365
+ };
366
+ }
367
+
368
+ // A whole LAZ file as parts: header + VLRs, table offset, chunks, chunk table.
369
+ // The points go through the chunk loop a task's worth at a time, so only
370
+ // the tasks in flight hold encoded records.
371
+ async function lazParts(points, s, so, len, totals) {
372
+ var size = s.laz.chunkSize, count = Math.ceil(points.count / size), t = threadsOf(s.opts);
373
+ var parts = [null, null];
374
+ var poolAt = t === 1 || (!t && count < AUTO_MIN_CHUNKS) ? Infinity : 1;
375
+ var loop = lazChunks(s, so, len, totals, function (bytes) { parts.push(bytes); }, poolAt, function () { return startPool(s.opts, count); });
376
+ var step = CHUNKS_PER_TASK * size;
377
+ for (var a = 0; a < points.count; a += step) await loop.write(rangeOf(points, a, Math.min(points.count, a + step)), a);
378
+ var chunks = await loop.finish();
379
+ // the header's size does not depend on the totals
380
+ var tablePos = chunks.reduce(function (n, c) { return n + c.byteCount; }, headerBytes(s, so, totals).length + 8);
381
+ parts.push(encodeChunkTable(chunks, false));
382
+ parts[0] = headerBytes(s, so, totals);
383
+ parts[1] = tableOffsetBytes(tablePos);
384
+ return parts;
385
+ }
386
+
169
387
  // One positioned write for any sink: an OPFS scratch writer (writeAt),
170
388
  // a Node FileHandle (write(buffer, offset, length, position), which may
171
389
  // write short), or a plain { write(bytes, position) }
@@ -184,11 +402,45 @@ function positionedWrite(sink) {
184
402
  throw new LasError(ERROR_CODES.INVALID_ARGUMENT, "createLasWriter needs a sink with writeAt(bytes, position) or write(bytes, position)");
185
403
  }
186
404
 
405
+ // The streaming LAZ writer: the chunk loop, placing chunks at the running
406
+ // position. close() places the partial chunk, writes the chunk table after
407
+ // it, then patches the table offset and the header totals. A failed write
408
+ // there fails the loop too, so later calls keep rejecting (ruling R11); once
409
+ // close() has begun, writePoints and close reject (ABORTED).
410
+ async function createLazWriter(s, so, len, totals, put, start) {
411
+ var pos = start + 8, t = threadsOf(s.opts);
412
+ // until close(), the table offset is LASzip's "not written yet" (-1)
413
+ await put(joinParts([headerBytes(s, so, totals), new Uint8Array(8).fill(255)]), 0);
414
+ var poolAt = t === 1 ? Infinity : t ? 1 : AUTO_MIN_CHUNKS;
415
+ var loop = lazChunks(s, so, len, totals, function (bytes) {
416
+ var at = pos;
417
+ pos += bytes.length;
418
+ return put(bytes, at);
419
+ }, poolAt, function () { return startPool(s.opts, Infinity); });
420
+ return {
421
+ writePoints: function (points) { return loop.write(points, 0); },
422
+ close: async function () {
423
+ var chunks = await loop.finish();
424
+ var tablePos = pos;
425
+ try {
426
+ await put(encodeChunkTable(chunks, false), tablePos);
427
+ await put(tableOffsetBytes(tablePos), start);
428
+ await put(headerBytes(s, so, totals), 0);
429
+ } catch (err) {
430
+ loop.fail(err);
431
+ throw err;
432
+ }
433
+ return { count: totals.count };
434
+ }
435
+ };
436
+ }
437
+
187
438
  /**
188
- * Stream a LAS file to a sink. Header totals are patched on close().
439
+ * Stream a LAS (or, with compress: true, LAZ) file to a sink. Header totals are patched on close().
189
440
  * @param {object} sink - an OPFS scratch writer (openScratchSyncWriter), a Node
190
441
  * FileHandle, or any { write(bytes, position) }
191
- * @param {object} opts - as writeLas; scale and offset are required (the bounds are not known yet)
442
+ * @param {object} opts - as writeLas; scale and offset are required (the bounds are not known yet).
443
+ * With compress and threads automatic, the pool (at most 4 workers) starts at the 4th full chunk
192
444
  * @returns {Promise<{writePoints: function(object): Promise, close: function(): Promise<{count:number}>}>}
193
445
  */
194
446
  export async function createLasWriter(sink, opts) {
@@ -200,6 +452,7 @@ export async function createLasWriter(sink, opts) {
200
452
  var totals = freshTotals();
201
453
  var put = positionedWrite(sink);
202
454
  var start = headerBytes(s, so, totals).length;
455
+ if (s.laz) return createLazWriter(s, so, len, totals, put, start);
203
456
  var pending = [];
204
457
  await put(headerBytes(s, so, totals), 0);
205
458
  return {