las-laz-js 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-10-21)
4
+
5
+ First release: read and write ASPRS LAS.
6
+
7
+ - `openLas` reads the header, VLRs, EVLRs and coordinate system (GeoKeys / WKT) of LAS 1.0–1.4,
8
+ from a File / Blob, ArrayBuffer, Uint8Array, Node path or any `{ size, read }` source.
9
+ - `readPoints` and `chunks` decode every point format (0–10) in windows of whole records, never
10
+ holding the file whole; filters (classes, returns, bounds), even thinning (`maxPoints`),
11
+ `dedupe`, `localOrigin` + `float32`, progress and abort.
12
+ - `readPointsToScratch` streams columns into OPFS scratch files.
13
+ - `colourize` by RGB (with 8-bit files detected), intensity, elevation or classification.
14
+ - `writeLas` and the streaming `createLasWriter` write LAS 1.0–1.4, formats 0–10, with EPSG or
15
+ WKT coordinate systems.
16
+ - `las-laz-js/worker`: a module Worker with a documented message protocol.
17
+ - A Three.js demo.
18
+ - LAZ files open and report `compressed`; reading their points arrives in 0.2.0.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brent Buffham
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,154 @@
1
- # Temporary Holding Version
1
+ # las-laz-js
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A JavaScript reader and writer for ASPRS **LAS** point clouds (LAZ to follow), published on npm
4
+ as `las-laz-js`. Plain ES modules, no dependencies, runs in the browser and in Node — and built
5
+ for files far larger than memory.
6
+
7
+ **Status: early (0.x).** It works and is tested, but the API may still change before 1.0.
8
+
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
+ - Big files: a 1.1 GB, 34-million-point cloud reads in about 2 seconds in Node, and is never held
13
+ in memory whole.
14
+ - Also: a ready-made module Worker (`las-laz-js/worker`) and a Three.js demo.
15
+ - Not yet: reading LAZ (0.2.0) and writing LAZ (0.3.0).
16
+
17
+ ## Install
18
+
19
+ ```sh
20
+ npm install las-laz-js
21
+ ```
22
+
23
+ Node 22 or newer, or any current browser. No dependencies.
24
+
25
+ ## Demo
26
+
27
+ Drop a LAS file onto the page and it is read locally (never uploaded), in the library's Worker,
28
+ and streamed into a Three.js viewer. The side panel runs every library function.
29
+ Run it locally with `npm run demo`, then open http://localhost:8080/.
30
+
31
+ ## Reading
32
+
33
+ ```js
34
+ import { openLas } from "las-laz-js";
35
+
36
+ var las = await openLas(file); // File / Blob, ArrayBuffer, Uint8Array, a Node path, or { size, read }
37
+ las.header; // { versionMajor, versionMinor, pointFormat, pointCount, bounds, scale, offset,
38
+ // software, systemId, creationDay, creationYear, pointsByReturn, compressed, ... }
39
+ las.crs; // { kind: "geokeys" | "wkt" | null, epsg, wkt?, keys? } — parsed, never reprojected
40
+ las.vlrs; // [{ userId, recordId, description, bytes }]
41
+
42
+ var pts = await las.readPoints({
43
+ fields: ["x", "y", "z", "classification", "red", "green", "blue"],
44
+ classes: [2], // keep only these classification codes
45
+ returns: [1], // keep only these return numbers
46
+ bounds: { minX, maxX, minY, maxY, minZ, maxZ },
47
+ maxPoints: 5000000, // even thinning; 0 keeps every point
48
+ dedupe: 0.01, // drop points within 1 cm (XY; dedupeZ: true for 3D)
49
+ localOrigin: { x: 478000, y: 6772000 }, float32: true, // small Float32 coordinates for the GPU
50
+ onProgress: function (fraction, label) {},
51
+ signal: abortController.signal
52
+ });
53
+ pts.count; pts.x; pts.y; pts.z; // one typed array per field
54
+ pts.bounds; pts.stats; // world bounds, per-class counts
55
+ ```
56
+
57
+ Fields: `x y z intensity returnNumber numberOfReturns scanDirection edgeOfFlightLine
58
+ classification classificationFlags scannerChannel scanAngle userData pointSourceId gpsTime
59
+ red green blue nir`. Fields a point format does not hold are left out. `scanAngle` is in
60
+ degrees for every format.
61
+
62
+ Filters run on the raw record before it is decoded, so a filtered-out point costs a byte read.
63
+
64
+ ## Files bigger than memory
65
+
66
+ `openLas` reads only the header and the VLRs. Points are read in windows of whole records
67
+ (64 MiB by default, `openLas(file, { windowSize })`), so memory holds one window plus what you
68
+ keep.
69
+
70
+ ```js
71
+ for await (var chunk of las.chunks({ fields: ["x", "y", "z"] })) {
72
+ draw(chunk); // one window's points
73
+ }
74
+ ```
75
+
76
+ In the browser, `readPointsToScratch` streams the selected columns into OPFS scratch files (one
77
+ per field) instead of memory; run it in a Worker, which OPFS sync access needs.
78
+
79
+ ## Colour
80
+
81
+ ```js
82
+ import { colourize } from "las-laz-js";
83
+ var rgb = colourize(pts, { by: "elevation" }); // "rgb" | "intensity" | "elevation" | "classification"
84
+ ```
85
+
86
+ Returns a `Uint8Array`, three bytes per point. LAS colour is 16-bit and is scaled to 8 bits —
87
+ except in files that store 8-bit values in the 16-bit fields (a common writer bug), which are
88
+ detected and used as they are instead of turning almost black. Elevation uses a blue → cyan →
89
+ green → yellow → red ramp; intensity a greyscale over the 2nd–98th percentile; classification
90
+ the usual LiDAR-viewer colours (ASPRS defines the codes, not colours).
91
+
92
+ ## Writing
93
+
94
+ ```js
95
+ import { writeLas, createLasWriter } from "las-laz-js";
96
+
97
+ var bytes = await writeLas(pts, { version: "1.4", pointFormat: 7, crs: { epsg: 28350 } });
98
+
99
+ var w = await createLasWriter(sink, { version: "1.2", pointFormat: 3, scale, offset }); // sink: { write(bytes, position) }
100
+ await w.writePoints(batch1); await w.writePoints(batch2);
101
+ await w.close(); // patches the header totals
102
+ ```
103
+
104
+ A version and point format that do not go together (format 6 in LAS 1.2) are refused, never
105
+ silently changed. Scale and offset are chosen from the bounds when not given. `crs.epsg`
106
+ writes a GeoKey record; `crs.wkt` a WKT record.
107
+
108
+ ## In a Worker
109
+
110
+ ```js
111
+ var worker = new Worker(new URL("las-laz-js/worker", import.meta.url), { type: "module" });
112
+ worker.postMessage({ id: 1, type: "open", payload: { blob: file } });
113
+ worker.postMessage({ id: 2, type: "chunks", payload: { fields: ["x", "y", "z"], colour: { by: "rgb" } } });
114
+ // replies: { id, type: "progress" | "chunk" | "result" | "error", ... }; buffers are transferred
115
+ ```
116
+
117
+ The protocol is documented in `src/workers/workerProtocol.js` and can be driven directly with
118
+ `createWorkerHandler(post)`.
119
+
120
+ ## Errors
121
+
122
+ Every failure is a `LasError` with a stable `code` (`ERROR_CODES`) and, for byte-level problems,
123
+ the file `offset`: `NOT_LAS`, `UNSUPPORTED_VERSION`, `UNSUPPORTED`, `COMPRESSED_LAZ`,
124
+ `TRUNCATED`, `BAD_HEADER`, `BAD_VLR`, `INVALID_ARGUMENT`, `ABORTED`, `TOO_MANY_POINTS`.
125
+ A file shorter than its header promises is `TRUNCATED`, never garbage points.
126
+
127
+ ## Scope
128
+
129
+ Reprojection is out of scope (the coordinate system is reported, not changed). Waveform
130
+ formats 4, 5, 9 and 10 have their point fields read; the waveform data itself is not. COPC,
131
+ EPT and E57 are not read.
132
+
133
+ ## Provenance
134
+
135
+ The LAS reader, writer and big-file handling come from [Kirra](https://kirra-design.com),
136
+ Brent Buffham's blast-design app, rewritten as a standalone library. The LAZ codec (0.2.0) is
137
+ being ported from [laz-rs](https://github.com/laz-rs/laz-rs), used under its MIT licence.
138
+ Formats are written to the ASPRS LAS 1.4 R15 specification; see `docs/LAS_FORMAT_NOTES.md`.
139
+
140
+ ## Development
141
+
142
+ ```sh
143
+ npm test # node --test, every test
144
+ npm run test:coverage # with the coverage gate
145
+ npm run demo # the demo on http://localhost:8080/
146
+ npm run check:private # before every push: no private sample details in the repo
147
+ ```
148
+
149
+ Private sample files go in the gitignored `samples/` folder, or are listed by path in
150
+ `samples/paths.txt`; the sample tests compare each file with its own header.
151
+
152
+ ## Licence
153
+
154
+ MIT © Brent Buffham
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
- "name": "las-laz-js",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "las-laz-js",
3
+ "version": "0.1.0",
4
+ "description": "Zero-dependency JavaScript reader and writer for ASPRS LAS point clouds (LAZ to follow), for the browser and Node, built for files larger than memory.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.js",
8
+ "./worker": "./src/workers/lasWorker.js"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "README.md",
13
+ "LICENSE",
14
+ "CHANGELOG.md"
15
+ ],
16
+ "sideEffects": false,
17
+ "engines": {
18
+ "node": ">=22"
19
+ },
20
+ "scripts": {
21
+ "test": "node --test \"test/**/*.test.js\"",
22
+ "test:coverage": "node --test --experimental-test-coverage --test-coverage-include=\"src/**\" --test-coverage-lines=97 --test-coverage-branches=88 --test-coverage-functions=97 \"test/**/*.test.js\"",
23
+ "demo": "node _dev/serve.mjs",
24
+ "check:private": "node _dev/checkPrivate.mjs"
25
+ },
26
+ "keywords": [
27
+ "las",
28
+ "laz",
29
+ "lidar",
30
+ "point-cloud",
31
+ "pointcloud",
32
+ "asprs",
33
+ "gis",
34
+ "survey",
35
+ "opfs",
36
+ "threejs"
37
+ ],
38
+ "author": "Brent Buffham (https://kirra-design.com)",
39
+ "license": "MIT",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/brentbuffham/las-laz-js.git"
43
+ },
44
+ "bugs": {
45
+ "url": "https://github.com/brentbuffham/las-laz-js/issues"
46
+ },
47
+ "homepage": "https://github.com/brentbuffham/las-laz-js#readme"
48
+ }
@@ -0,0 +1,242 @@
1
+ // src/LasReader.js
2
+ //==========================================================
3
+ // openLas(source) -> LasReader. Opening reads only the header and
4
+ // the VLRs; points are read later in windows of whole records
5
+ // (default 64 MiB), so a file far larger than memory streams
6
+ // through and is never held whole — Kirra's current import reads
7
+ // the whole file into one ArrayBuffer; this does not.
8
+ //
9
+ // Sources: a File / Blob, an ArrayBuffer / Uint8Array, a Node file
10
+ // path, or any async source { size, read(start, end) }.
11
+ //==========================================================
12
+
13
+ import { LasError, ERROR_CODES } from "./errors.js";
14
+ import { BlobSource } from "./io/BlobSource.js";
15
+ import { MemorySource } from "./io/MemorySource.js";
16
+ import { NodeFileSource } from "./io/NodeFileSource.js";
17
+ import { parseHeader } from "./format/LasHeader.js";
18
+ import { parseVlrs, parseEvlrs } from "./format/LasVlr.js";
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";
23
+ import { dedupe } from "./filter/dedupe.js";
24
+ import { openScratchSyncWriter } from "./io/ScratchBinaryFile.js";
25
+
26
+ var DEFAULT_WINDOW = 64 * 1024 * 1024;
27
+
28
+ function memorySource(bytes) {
29
+ return { size: bytes.length, read: async function (a, b) {
30
+ if (b > bytes.length) throw new LasError(ERROR_CODES.TRUNCATED, "read past end of " + bytes.length + "-byte buffer", b);
31
+ return bytes.subarray(a, b);
32
+ } };
33
+ }
34
+
35
+ async function toSource(source) {
36
+ if (source instanceof Uint8Array) return memorySource(source);
37
+ if (source instanceof ArrayBuffer) return memorySource(new Uint8Array(source));
38
+ if (typeof source === "string") return NodeFileSource.open(source);
39
+ 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;
41
+ throw new LasError(ERROR_CODES.INVALID_ARGUMENT, "openLas needs a File / Blob, ArrayBuffer, Uint8Array, file path or { size, read } source");
42
+ }
43
+
44
+ /**
45
+ * Open a LAS (or LAZ) file and read its header and VLRs.
46
+ * @param {*} source
47
+ * @param {{windowSize?: number}} [opts]
48
+ * @returns {Promise<LasReader>}
49
+ */
50
+ export async function openLas(source, opts) {
51
+ var src = await toSource(source);
52
+ var headBytes = await src.read(0, Math.min(src.size, 375));
53
+ var header = parseHeader(new MemorySource(headBytes));
54
+ var vlrs = [];
55
+ if (header.numberOfVlrs > 0) {
56
+ var vlrEnd = Math.min(header.offsetToPointData, src.size);
57
+ var vlrBytes = await src.read(0, vlrEnd);
58
+ vlrs = parseVlrs(new MemorySource(vlrBytes), header.headerSize, header.numberOfVlrs, vlrEnd);
59
+ }
60
+ var evlrs = [];
61
+ 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
+ });
66
+ }
67
+ return new LasReader(src, header, vlrs, evlrs, (opts && opts.windowSize) || DEFAULT_WINDOW);
68
+ }
69
+
70
+ /**
71
+ * A LAS file opened for reading. Create with openLas().
72
+ */
73
+ export function LasReader(src, header, vlrs, evlrs, windowSize) {
74
+ this._src = src;
75
+ this._windowSize = windowSize;
76
+ /** Public header. */
77
+ this.header = header;
78
+ /** Variable length records. */
79
+ this.vlrs = vlrs;
80
+ /** Extended variable length records (LAS 1.4). */
81
+ this.evlrs = evlrs;
82
+ /** Declared coordinate system (never reprojected). */
83
+ this.crs = parseCrs(vlrs.concat(evlrs));
84
+ /** True for LAZ (compressed points). */
85
+ this.compressed = header.compressed;
86
+ }
87
+
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);
96
+ }
97
+ };
98
+
99
+ /**
100
+ * Iterate the selected points window by window.
101
+ * @param {object} [opts] - fields, classes, returns, bounds, maxPoints, localOrigin, float32, onProgress, signal
102
+ * @returns {AsyncGenerator<object>} PointColumns per window
103
+ */
104
+ LasReader.prototype.chunks = async function* (opts) {
105
+ opts = opts || {};
106
+ if (opts.dedupe) throw new LasError(ERROR_CODES.INVALID_ARGUMENT, "dedupe needs every point at once: use readPoints, not chunks");
107
+ this._check();
108
+ var h = this.header;
109
+ var keep = makeKeep(opts, h);
110
+ var stride = strideFor(h.pointCount, opts.maxPoints);
111
+ var cap = opts.maxPoints > 0 ? opts.maxPoints : Infinity;
112
+ 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++) {
116
+ 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;
124
+ }
125
+ if (opts.signal && opts.signal.aborted) throw new LasError(ERROR_CODES.ABORTED, "read aborted");
126
+ };
127
+
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;
132
+ }
133
+
134
+ /**
135
+ * Read the selected points into one set of columns.
136
+ * @param {object} [opts] - chunks() options plus dedupe (tolerance) and dedupeZ
137
+ * @returns {Promise<object>} PointColumns plus bounds and stats
138
+ */
139
+ LasReader.prototype.readPoints = async function (opts) {
140
+ opts = opts || {};
141
+ var h = this.header;
142
+ var chunkOpts = Object.assign({}, opts);
143
+ delete chunkOpts.dedupe;
144
+ 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);
154
+ fields.forEach(function (f) { var g = new acc[f].constructor(size); g.set(acc[f].subarray(0, n)); acc[f] = g; });
155
+ }
156
+ for (var i = 0; i < fields.length; i++) acc[fields[i]].set(c[fields[i]], n);
157
+ n += c.count;
158
+ }
159
+ var out = { count: n };
160
+ fields.forEach(function (f) { out[f] = acc ? acc[f].slice(0, n) : new Float64Array(0); });
161
+ if (opts.dedupe) out = dedupe(out, opts.dedupe, { z: !!opts.dedupeZ });
162
+ out.bounds = boundsOf(out, opts.localOrigin);
163
+ out.stats = statsOf(out);
164
+ return out;
165
+ };
166
+
167
+ function emptyBounds() { return { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity, minZ: Infinity, maxZ: -Infinity }; }
168
+
169
+ // Grow raw (stored-value) bounds over one set of columns
170
+ function extendBounds(b, c) {
171
+ for (var i = 0; i < c.count; i++) {
172
+ var x = c.x[i], y = c.y ? c.y[i] : 0, z = c.z ? c.z[i] : 0;
173
+ if (x < b.minX) b.minX = x; if (x > b.maxX) b.maxX = x;
174
+ if (y < b.minY) b.minY = y; if (y > b.maxY) b.maxY = y;
175
+ if (z < b.minZ) b.minZ = z; if (z > b.maxZ) b.maxZ = z;
176
+ }
177
+ return b;
178
+ }
179
+
180
+ // Raw bounds -> world bounds (origin added back), to the stored precision
181
+ function worldBounds(b, origin) {
182
+ if (!(b.minX <= b.maxX)) return null;
183
+ var ox = origin && isFinite(origin.x) ? origin.x : 0;
184
+ var oy = origin && isFinite(origin.y) ? origin.y : 0;
185
+ var oz = origin && isFinite(origin.z) ? origin.z : 0;
186
+ var r = function (v) { return Math.round(v * 1e6) / 1e6; };
187
+ return { minX: r(b.minX + ox), maxX: r(b.maxX + ox), minY: r(b.minY + oy), maxY: r(b.maxY + oy), minZ: r(b.minZ + oz), maxZ: r(b.maxZ + oz) };
188
+ }
189
+
190
+ function boundsOf(c, origin) {
191
+ if (!c.x || !c.count) return null;
192
+ return worldBounds(extendBounds(emptyBounds(), c), origin);
193
+ }
194
+
195
+ function statsOf(c) {
196
+ var s = { count: c.count };
197
+ if (c.classification) {
198
+ var counts = {};
199
+ for (var i = 0; i < c.count; i++) { var k = c.classification[i]; counts[k] = (counts[k] || 0) + 1; }
200
+ s.classCounts = counts;
201
+ }
202
+ return s;
203
+ }
204
+
205
+ /**
206
+ * Read the selected points straight into OPFS scratch files — one per
207
+ * field, raw little-endian typed-array bytes — for selections too big
208
+ * for memory (browser, in a worker: OPFS sync handles are worker-only).
209
+ * @param {object} [opts] - chunks() options plus root / dir (OPFS) and prefix (file names)
210
+ * @returns {Promise<{count:number, fields:Object<string,string>, types:Object<string,string>, bounds:object}>}
211
+ */
212
+ LasReader.prototype.readPointsToScratch = async function (opts) {
213
+ opts = opts || {};
214
+ var fields = resolveFields(this.header, opts.fields);
215
+ var prefix = opts.prefix || ("las-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8));
216
+ var where = { root: opts.root, dir: opts.dir };
217
+ var writers = {}, ids = {}, types = {};
218
+ for (var f = 0; f < fields.length; f++) {
219
+ ids[fields[f]] = prefix + "-" + fields[f];
220
+ writers[fields[f]] = await openScratchSyncWriter(ids[fields[f]], where);
221
+ }
222
+ var count = 0, b = emptyBounds();
223
+ try {
224
+ for await (var c of this.chunks(opts)) {
225
+ fields.forEach(function (name) {
226
+ var col = c[name];
227
+ types[name] = col.constructor.name;
228
+ writers[name].write(new Uint8Array(col.buffer, col.byteOffset, col.byteLength));
229
+ });
230
+ if (c.x) extendBounds(b, c);
231
+ count += c.count;
232
+ }
233
+ } finally {
234
+ fields.forEach(function (name) { writers[name].close(); });
235
+ }
236
+ return { count: count, fields: ids, types: types, bounds: worldBounds(b, opts.localOrigin) };
237
+ };
238
+
239
+ /** Close the underlying file (Node). */
240
+ LasReader.prototype.close = async function () {
241
+ if (this._src && typeof this._src.close === "function") await this._src.close();
242
+ };
@@ -0,0 +1,129 @@
1
+ // src/colour/colourize.js
2
+ //==========================================================
3
+ // Colour points: { by: "rgb" | "intensity" | "elevation" |
4
+ // "classification" } -> Uint8Array, three bytes per point.
5
+ //
6
+ // rgb: LAS stores 16-bit colour; it is scaled with round(v / 256)
7
+ // (Kirra's rule). Files that wrongly store 8-bit values in the
8
+ // 16-bit fields (every value <= 255 — a common writer bug) are
9
+ // used as-is instead of being divided down to almost black. This
10
+ // detection is new in the library; Kirra divides unconditionally.
11
+ // elevation: Kirra's spectrum ramp, blue -> cyan -> green -> yellow
12
+ // -> red, over opts.range or the data's Z range.
13
+ // intensity: greyscale over opts.range, or the 2nd-98th percentile
14
+ // so one hot return does not wash the rest out.
15
+ // classification: conventional viewer colours. ASPRS defines the
16
+ // codes, not colours; these follow common LiDAR-viewer practice.
17
+ //==========================================================
18
+
19
+ import { LasError, ERROR_CODES } from "../errors.js";
20
+
21
+ function spectrum(t) {
22
+ if (t < 0.25) return [0, Math.floor(t * 4 * 255), 255];
23
+ if (t < 0.5) return [0, 255, Math.floor(255 - (t - 0.25) * 4 * 255)];
24
+ if (t < 0.75) return [Math.floor((t - 0.5) * 4 * 255), 255, 0];
25
+ return [255, Math.floor(255 - (t - 0.75) * 4 * 255), 0];
26
+ }
27
+ function greyscale(t) { var g = Math.round(t * 255); return [g, g, g]; }
28
+
29
+ /** Colour ramps: t in [0, 1] -> [r, g, b]. */
30
+ export var RAMPS = Object.freeze({ spectrum: spectrum, greyscale: greyscale });
31
+
32
+ /** Classification code -> [r, g, b] (common LiDAR viewer colours). */
33
+ export var CLASS_COLOURS = Object.freeze({
34
+ 0: [128, 128, 128], // created, never classified
35
+ 1: [170, 170, 170], // unclassified
36
+ 2: [165, 113, 78], // ground
37
+ 3: [144, 238, 144], // low vegetation
38
+ 4: [34, 139, 34], // medium vegetation
39
+ 5: [0, 100, 0], // high vegetation
40
+ 6: [220, 60, 40], // building
41
+ 7: [255, 0, 255], // low point (noise)
42
+ 8: [255, 215, 0], // model key point (LAS 1.2) / reserved
43
+ 9: [30, 144, 255], // water
44
+ 10: [139, 69, 19], // rail
45
+ 11: [90, 90, 90], // road surface
46
+ 12: [255, 255, 0], // overlap (1.2) / reserved
47
+ 13: [0, 255, 255], // wire - guard
48
+ 14: [0, 206, 209], // wire - conductor
49
+ 15: [148, 0, 211], // transmission tower
50
+ 16: [186, 85, 211], // wire-structure connector
51
+ 17: [205, 133, 63], // bridge deck
52
+ 18: [255, 20, 147] // high noise
53
+ });
54
+ var UNKNOWN = [128, 128, 128];
55
+
56
+ function need(points, name) {
57
+ if (!points[name]) throw new LasError(ERROR_CODES.INVALID_ARGUMENT, "colourize by this mode needs the \"" + name + "\" field; read it with fields: [..., \"" + name + "\"]");
58
+ return points[name];
59
+ }
60
+
61
+ function rangeOf(values, n) {
62
+ var lo = Infinity, hi = -Infinity;
63
+ for (var i = 0; i < n; i++) { var v = values[i]; if (v < lo) lo = v; if (v > hi) hi = v; }
64
+ return [lo, hi];
65
+ }
66
+
67
+ function percentiles(values, n) {
68
+ var copy = Array.prototype.slice.call(values, 0, n).sort(function (a, b) { return a - b; });
69
+ return [copy[Math.floor(0.02 * (n - 1))], copy[Math.floor(0.98 * (n - 1))]];
70
+ }
71
+
72
+ function rampColours(values, n, range, ramp) {
73
+ var out = new Uint8Array(n * 3);
74
+ var lo = range[0], span = range[1] - range[0];
75
+ for (var i = 0; i < n; i++) {
76
+ var t = span > 1e-12 ? (values[i] - lo) / span : 0.5;
77
+ if (!(t >= 0)) t = 0; else if (t > 1) t = 1;
78
+ var c = ramp(t);
79
+ out[i * 3] = c[0]; out[i * 3 + 1] = c[1]; out[i * 3 + 2] = c[2];
80
+ }
81
+ return out;
82
+ }
83
+
84
+ /**
85
+ * @param {object} points - PointColumns.
86
+ * @param {{by:string, ramp?:string, range?:number[]}} opts
87
+ * @returns {Uint8Array} r, g, b per point
88
+ */
89
+ export function colourize(points, opts) {
90
+ opts = opts || {};
91
+ var n = points.count;
92
+ var rampName = opts.ramp || (opts.by === "intensity" ? "greyscale" : "spectrum");
93
+ var ramp = RAMPS[rampName];
94
+ if (!ramp) throw new LasError(ERROR_CODES.INVALID_ARGUMENT, "unknown ramp \"" + rampName + "\"");
95
+ var out, i;
96
+ switch (opts.by) {
97
+ case "rgb": {
98
+ 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;
101
+ out = new Uint8Array(n * 3);
102
+ for (i = 0; i < n; i++) {
103
+ out[i * 3] = eightBit ? r[i] : Math.min(255, Math.round(r[i] / 256));
104
+ out[i * 3 + 1] = eightBit ? g[i] : Math.min(255, Math.round(g[i] / 256));
105
+ out[i * 3 + 2] = eightBit ? b[i] : Math.min(255, Math.round(b[i] / 256));
106
+ }
107
+ return out;
108
+ }
109
+ case "elevation": {
110
+ var z = need(points, "z");
111
+ return rampColours(z, n, opts.range || rangeOf(z, n), ramp);
112
+ }
113
+ case "intensity": {
114
+ var v = need(points, "intensity");
115
+ return rampColours(v, n, opts.range || percentiles(v, n), ramp);
116
+ }
117
+ case "classification": {
118
+ var cls = need(points, "classification");
119
+ out = new Uint8Array(n * 3);
120
+ for (i = 0; i < n; i++) {
121
+ var c = CLASS_COLOURS[cls[i]] || UNKNOWN;
122
+ out[i * 3] = c[0]; out[i * 3 + 1] = c[1]; out[i * 3 + 2] = c[2];
123
+ }
124
+ return out;
125
+ }
126
+ default:
127
+ throw new LasError(ERROR_CODES.INVALID_ARGUMENT, "colourize by \"" + opts.by + "\" is not a mode (rgb, intensity, elevation, classification)");
128
+ }
129
+ }
package/src/errors.js ADDED
@@ -0,0 +1,38 @@
1
+ // src/errors.js
2
+ //==========================================================
3
+ // LasError: every failure the library raises carries a stable
4
+ // `code` (see ERROR_CODES) and, for byte-level problems, the
5
+ // file `offset` where it was detected. Callers branch on `code`;
6
+ // the offset is appended to the message for humans.
7
+ // Same shape as ecw-js's EcwError.
8
+ //==========================================================
9
+
10
+ /** Stable error codes. Each value equals its key. */
11
+ export var ERROR_CODES = Object.freeze({
12
+ NOT_LAS: "NOT_LAS",
13
+ UNSUPPORTED_VERSION: "UNSUPPORTED_VERSION",
14
+ UNSUPPORTED: "UNSUPPORTED",
15
+ COMPRESSED_LAZ: "COMPRESSED_LAZ",
16
+ TRUNCATED: "TRUNCATED",
17
+ BAD_HEADER: "BAD_HEADER",
18
+ BAD_VLR: "BAD_VLR",
19
+ INVALID_ARGUMENT: "INVALID_ARGUMENT",
20
+ ABORTED: "ABORTED",
21
+ TOO_MANY_POINTS: "TOO_MANY_POINTS"
22
+ });
23
+
24
+ /**
25
+ * Error raised by las-laz-js.
26
+ * @param {string} code - One of ERROR_CODES.
27
+ * @param {string} message - Human-readable description.
28
+ * @param {number} [offset] - Byte offset in the file, when relevant.
29
+ */
30
+ export class LasError extends Error {
31
+ constructor(code, message, offset) {
32
+ var text = typeof offset === "number" ? message + " (at 0x" + offset.toString(16) + ")" : message;
33
+ super(text);
34
+ this.name = "LasError";
35
+ this.code = code;
36
+ this.offset = offset;
37
+ }
38
+ }