<bmx-point-cloud>
Shows a point cloud - a laser scan or a photogrammetry survey - in the page: LAS and LAZ (the ASPRS formats, LAZ decompressed in the browser), PLY, PCD and plain XYZ text. Millions of points draw smoothly because the cloud is cut into an octree of detail levels and each frame draws the near part in full and the far part as a sample, within a point budget. Colour the points by the file's own colour, intensity, elevation, classification or return number; hide classes; turn, move and zoom; pick standard views and an orthographic projection; read the file's facts; and save a picture.
21 properties · 7 events · 15 methods · 5 parts
Example
Try it with your own scans in confidence: LAS, LAZ, PLY, PCD and XYZ files are opened by your browser, on your device. Nothing is uploaded, no copy reaches us or anyone else, and nothing is kept once you close this tab.
Making a survey…
Show markup
<bmx-point-cloud id="ex-pc" label="Quarry survey" allow-open color-mode="auto" style="--bmx-point-cloud-height: 30rem"
annotations='[{"id":"n1","text":"Site shed - roof scanned from the drone","position":[300105,5000127.5,44]},{"id":"n2","text":"Stockpile, measured 14 October","position":[300060,5000060,52]}]'></bmx-point-cloud>
<p class="note" id="ex-pc-out" role="status" style="margin: 12px 0 0">Making a survey…</p>
<script type="module">
await customElements.whenDefined('bmx-point-cloud');
const viewer = document.getElementById('ex-pc');
const out = document.getElementById('ex-pc-out');
// A made-up survey written as a binary PLY in memory: a quarry floor with benches cut into one side,
// a stockpile, a shed, a few trees and some stray points high up - the shapes a laser scan of a site
// has, so that colouring by elevation, intensity and classification each shows something. The
// points are invented here, not read from any real survey.
let seed = 20261021;
const rnd = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296);
const N = 180000;
const pts = [];
for (let i = 0; i < N; i += 1) {
const x = rnd() * 240;
const y = rnd() * 180;
// The floor dips towards the middle; benches step up on the east side.
let z = 40 - 6 * Math.exp(-(((x - 120) / 70) ** 2 + ((y - 90) / 50) ** 2)) + 0.8 * Math.sin(x / 9) * Math.cos(y / 11);
if (x > 170) z += 5 * Math.floor((x - 170) / 18);
let cls = 2; // ground
const stockpile = Math.hypot(x - 60, y - 60);
if (stockpile < 22) { z += (22 - stockpile) * 0.55; cls = 2; }
if (x > 95 && x < 115 && y > 120 && y < 135) { z = 44; cls = 6; } // the shed roof
const tree = [[30, 150], [45, 160], [200, 30], [215, 45], [150, 170]].find(([tx, ty]) => Math.hypot(x - tx, y - ty) < 5);
if (tree) { z += 4 + rnd() * 7; cls = 5; }
if (rnd() < 0.0015) { z += 15 + rnd() * 20; cls = 7; } // noise: birds, dust
const intensity = cls === 6 ? 2600 + rnd() * 400 : cls === 5 ? 600 + rnd() * 500 : 1200 + 900 * (0.5 + 0.5 * Math.sin(x / 15)) + rnd() * 200;
pts.push(x + rnd() * 0.08, y + rnd() * 0.08, z + rnd() * 0.05, Math.round(intensity), cls);
}
const header = `ply\nformat binary_little_endian 1.0\ncomment Invented survey for the BMX docs\nelement vertex ${N}\nproperty double x\nproperty double y\nproperty float z\nproperty ushort intensity\nproperty uchar classification\nend_header\n`;
const head = new TextEncoder().encode(header);
const body = new Uint8Array(N * 23);
const view = new DataView(body.buffer);
for (let i = 0; i < N; i += 1) {
const at = i * 23;
view.setFloat64(at, 300000 + pts[i * 5], true);
view.setFloat64(at + 8, 5000000 + pts[i * 5 + 1], true);
view.setFloat32(at + 16, pts[i * 5 + 2], true);
view.setUint16(at + 20, pts[i * 5 + 3], true);
body[at + 22] = pts[i * 5 + 4];
}
const file = new Uint8Array(head.length + body.length);
file.set(head, 0);
file.set(body, head.length);
viewer.addEventListener('bmxLoad', e => {
const d = e.detail;
out.textContent = `${d.points.toLocaleString()} points read in ${(d.millis / 1000).toFixed(1)} s. Colour by classification and untick Ground to see what stands on it; drag to turn, right-drag to move, wheel to zoom, click a point for its coordinates. Measure the shed, cut a profile across the benches, box in the stockpile, and save the view.`;
});
viewer.addEventListener('bmxPick', e => {
const p = e.detail;
out.textContent = `Point ${p.index.toLocaleString()} at ${p.position.map(v => v.toFixed(2)).join(', ')}` + (p.classification !== null ? `, class ${p.classification}` : '') + (p.intensity !== null ? `, intensity ${p.intensity}` : '');
});
viewer.addEventListener('bmxMeasure', e => {
const m = e.detail;
out.textContent = m.kind === 'area' ? `Area ${m.area.toFixed(2)} m², perimeter ${m.length.toFixed(2)} m` : `${m.kind === 'height' ? 'Height' : 'Distance'} ${m.length.toFixed(3)} m`;
});
viewer.addEventListener('bmxProfile', e => {
out.textContent = `Profile of ${e.detail.samples.toLocaleString()} points over ${Math.hypot(e.detail.to[0] - e.detail.from[0], e.detail.to[1] - e.detail.from[1]).toFixed(1)} m; the chart is in the Profile tab, and the CSV button saves it.`;
});
viewer.addEventListener('bmxError', e => {
out.textContent = e.detail.message;
});
await viewer.load(file, { name: 'quarry.ply' });
</script>
<bmx-point-cloud src="/surveys/site.laz" label="Site survey" color-mode="elevation"></bmx-point-cloud>
Everything runs in the browser: the file is decoded on background threads and drawn with WebGL 2; nothing is uploaded. A file given by address is read in ranges, so a large LAZ shows while its chunks are still arriving.
Keyboard: the view takes focus with Tab. Arrow keys turn the cloud, Shift with the arrows moves it, plus and minus zoom, Home fits it, 1 to 6 choose top, front, back, left, right and isometric, P switches projection, C cycles the colouring, Enter reports the point under the cross in the middle.
Properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
allowOpen |
allow-open |
boolean |
false |
Offer an Open button, and open files dropped on the view. |
annotations |
annotations |
BmxPointAnnotation[] | string |
[] |
Notes pinned to points, as an array or JSON. Two-way: bmxChange reports edits. |
clipBox |
clip-box |
BmxPointClipBox | string | null |
null |
Only points inside this box are drawn ({min: [x, y, z], max: [x, y, z]} in the file's coordinates, or JSON); null for all. Two-way. |
colorMode |
color-mode |
BmxPointColorMode | 'auto' |
'auto' |
How the points are coloured. auto uses the file's colour when it has one, else elevation. Two-way. |
hiddenClasses |
hidden-classes |
number[] | string |
[] |
Classification codes not drawn, as an array or JSON. Two-way: bmxChange reports edits. |
label |
label |
string |
— | The cloud's name for assistive technology and the toolbar. Default: its file name. |
panel |
panel |
boolean |
true |
Show the side panel (information, classes). Two-way (the toolbar's panel button). |
pointBudget |
point-budget |
number |
2_000_000 |
The most points drawn in one frame. Two-way. |
pointColor |
point-color |
string |
'' |
The colour for the single-colour mode (any CSS colour). |
pointSize |
point-size |
number |
1 |
Point size: 1 is the natural size, 0.5 half, 2 double. Two-way. |
profileWidth |
profile-width |
number |
1 |
How wide the band of points a profile gathers is, in the file's units. Two-way. |
projection |
projection |
'perspective' | 'orthographic' |
'perspective' |
Two-way. |
readonly |
readonly |
boolean |
false |
Notes, measurements, views and the box can be read but not added, changed or removed. |
shading |
shading |
'edl' | 'none' |
'edl' |
Shade points by their depth against their neighbours (eye-dome lighting), so surfaces read without lights. |
shadingStrength |
shading-strength |
number |
1 |
How strong the shading is, 0 to 2. |
sizing |
sizing |
'adaptive' | 'fixed' |
'adaptive' |
adaptive sizes each point by the detail around it (no holes close up); fixed draws every point the same size. |
snapshots |
snapshots |
BmxPointSnapshot[] | string |
[] |
Saved views, as an array or JSON. Two-way: bmxChange reports edits. |
src |
src |
string |
— | The file's address. LAS and LAZ are read in ranges when the server allows it. |
strings |
strings |
Partial<BmxPointCloudStrings> | string |
— | Words to show instead of the English ones, as an object or JSON. |
tool |
tool |
BmxPointCloudTool |
'none' |
The tool in use: pick points on the cloud to measure, cut a profile, add a note or set the clipping box. Two-way. |
toolbar |
toolbar |
boolean |
true |
Show the toolbar. |
Events
| Event | Detail | Description |
|---|---|---|
bmxChange |
BmxPointCloudChangeDetail |
The hidden classes, notes, saved views or clipping box changed. |
bmxError |
{ readonly message: string; } |
A cloud could not be read. |
bmxLoad |
BmxPointCloudLoadDetail |
A cloud was read and shown. |
bmxMeasure |
BmxPointMeasurement & { readonly length: number; readonly area: number; } |
A measurement was taken. |
bmxPick |
BmxPointCloudPickDetail |
A point was picked (a click, or Enter on the view). |
bmxProfile |
{ readonly from: BmxPointXyz; readonly to: BmxPointXyz; readonly width: number; readonly samples: number; } |
A profile was cut. |
bmxProgress |
BmxPointCloudProgressDetail |
Reading is under way: how far, and what is happening. |
Methods
| Method | Signature | Description |
|---|---|---|
applySnapshot |
applySnapshot(id: string) => Promise<void> |
Returns to a saved view. |
clearMeasurements |
clearMeasurements() => Promise<void> |
|
cutProfile |
cutProfile(from: BmxPointXyz, to: BmxPointXyz, width?: number) => Promise<BmxPointProfile | null> |
Cuts a profile along a line (two points in the file's coordinates) and shows it. |
fit |
fit() => Promise<void> |
Fits the camera to the whole cloud. |
getCamera |
getCamera() => Promise<BmxPointCloudCamera> |
|
getClasses |
getClasses() => Promise<{ code: number; name: string; count: number; visible: boolean; }[]> |
The classification codes the file uses, with their names and counts. |
getInfo |
getInfo() => Promise<BmxPointCloudLoadDetail | null> |
What the file says about itself. |
getMeasurements |
getMeasurements() => Promise<(BmxPointMeasurement & { length: number; area: number; })[]> |
The measurements taken, with their results. |
getProfile |
getProfile() => Promise<BmxPointProfile | null> |
The last profile cut, or null. |
load |
load(data: ArrayBuffer | Uint8Array | string | Blob, options?: BmxPointCloudLoadOptions) => Promise<void> |
Shows a cloud from its data: bytes, text, or a File or Blob. |
screenshot |
screenshot(options?: { type?: "image/png" | "image/jpeg" | "image/webp"; quality?: number; background?: boolean; }) => Promise<Blob | null> |
The picture as an image. With background, the view's background is laid under it (always, for JPEG). |
setCamera |
setCamera(camera: Partial<BmxPointCloudCamera>) => Promise<void> |
Moves the camera (a saved view, for example). Parts left out keep their values. |
setClassVisible |
setClassVisible(code: number, visible: boolean) => Promise<void> |
Shows or hides a classification. |
setView |
setView(view: BmxPointCloudView) => Promise<void> |
Looks from a standard direction, keeping what is looked at. |
takeSnapshot |
takeSnapshot(name?: string) => Promise<BmxPointSnapshot> |
Saves the camera and the way the cloud is shown as a view. |
CSS shadow parts
| Part | Description |
|---|---|
panel |
The side panel (information, classes). |
pin |
|
stage |
The area the cloud is drawn in. |
status |
The line under the view. |
toolbar |
The bar of commands. |
CSS custom properties
| Property | Description |
|---|---|
--bmx-point-cloud-background |
Behind the points. |
--bmx-point-cloud-box |
The clipping box's outline. |
--bmx-point-cloud-height |
The height of the view. |
--bmx-point-cloud-measure |
Measurement lines and points. |
--bmx-point-cloud-panel-width |
The side panel's width. |
--bmx-point-cloud-pin |
A note's pin. |
--bmx-point-cloud-point |
The colour of points in the single-colour mode (when point-color is not set). |
--bmx-point-cloud-profile |
The profile line, and the profile chart's marks without classes. |
--bmx-point-cloud-radius |
Corner radius of the frame. |
--bmx-point-cloud-surface |
The colour a saved picture is laid on. |