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 +18 -0
- package/LICENSE +21 -0
- package/README.md +153 -2
- package/package.json +47 -5
- package/src/LasReader.js +242 -0
- package/src/colour/colourize.js +129 -0
- package/src/errors.js +38 -0
- package/src/filter/dedupe.js +64 -0
- package/src/filter/filters.js +68 -0
- package/src/format/LasCrs.js +67 -0
- package/src/format/LasHeader.js +97 -0
- package/src/format/LasVlr.js +65 -0
- package/src/format/PointFormats.js +75 -0
- package/src/index.js +27 -0
- package/src/io/BlobSource.js +40 -0
- package/src/io/ByteSource.js +42 -0
- package/src/io/MemorySource.js +84 -0
- package/src/io/NodeFileSource.js +65 -0
- package/src/io/ScratchBinaryFile.js +239 -0
- package/src/read/decodeRecords.js +146 -0
- package/src/read/windows.js +24 -0
- package/src/workers/lasWorker.js +16 -0
- package/src/workers/workerProtocol.js +122 -0
- package/src/write/encodeRecords.js +76 -0
- package/src/write/scaleOffset.js +24 -0
- package/src/write/writeLas.js +190 -0
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
|
-
#
|
|
1
|
+
# las-laz-js
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
+
}
|
package/src/LasReader.js
ADDED
|
@@ -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
|
+
}
|