vixar 1.0.0__py3-none-any.whl

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.
vixar/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """Vixar — proprietary geoscientific 3D/2D visualization engine (Python library).
2
+
3
+ import vixar as vx
4
+ viewer = vx.Viewer()
5
+ viewer.add_point_cloud("survey.csv", color_by="elevation")
6
+ viewer.show()
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ __version__ = "1.0.0"
12
+
13
+ from vixar.layers import LayerRef
14
+ from vixar.schema import SCHEMA_VERSION, SceneConfig
15
+ from vixar.viewer import Viewer
16
+
17
+ __all__ = ["Viewer", "LayerRef", "SceneConfig", "SCHEMA_VERSION", "__version__"]
vixar/cli.py ADDED
@@ -0,0 +1,76 @@
1
+ """Vixar command-line interface (plan §6).
2
+
3
+ Provides ``vixar tile`` for spatial pre-tiling (Phase 3) and
4
+ ``vixar --version``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import sys
11
+
12
+ from vixar import __version__
13
+
14
+
15
+ def main(argv: list[str] | None = None) -> int:
16
+ parser = argparse.ArgumentParser(prog="vixar", description="Vixar geoscientific viewer.")
17
+ parser.add_argument("--version", action="version", version=f"vixar {__version__}")
18
+ sub = parser.add_subparsers(dest="command")
19
+
20
+ tile_p = sub.add_parser(
21
+ "tile",
22
+ help="Split a LAS file into spatial tiles for streaming (Phase 3).",
23
+ )
24
+ tile_p.add_argument("input", help="Path to the source LAS file.")
25
+ tile_p.add_argument(
26
+ "--output", "-o", default="./tiles/",
27
+ help="Output directory for tile files and meta.json (default: ./tiles/).",
28
+ )
29
+ tile_p.add_argument(
30
+ "--tile-size", type=float, default=100.0,
31
+ help="Spatial cell size in metres (default: 100).",
32
+ )
33
+ tile_p.add_argument(
34
+ "--color-by",
35
+ help="Point attribute to encode as per-point values (e.g. elevation, intensity).",
36
+ )
37
+ tile_p.add_argument(
38
+ "--lod-factor", type=int, default=4,
39
+ help="Every N-th point is written to the coarse LOD tile (default: 4).",
40
+ )
41
+ tile_p.add_argument(
42
+ "--quiet", "-q", action="store_true",
43
+ help="Suppress progress output.",
44
+ )
45
+
46
+ args = parser.parse_args(argv)
47
+
48
+ if args.command == "tile":
49
+ try:
50
+ from vixar.tiler import tile_point_cloud
51
+ tile_point_cloud(
52
+ args.input,
53
+ output_dir=args.output,
54
+ tile_size=args.tile_size,
55
+ color_by=args.color_by,
56
+ lod_factor=args.lod_factor,
57
+ verbose=not args.quiet,
58
+ )
59
+ return 0
60
+ except ImportError as exc:
61
+ print(
62
+ f"Error: {exc}\n"
63
+ "Install the LAS dependency with: pip install 'vixar[las]'",
64
+ file=sys.stderr,
65
+ )
66
+ return 1
67
+ except Exception as exc: # noqa: BLE001
68
+ print(f"Error: {exc}", file=sys.stderr)
69
+ return 1
70
+
71
+ parser.print_help()
72
+ return 0
73
+
74
+
75
+ if __name__ == "__main__": # pragma: no cover
76
+ raise SystemExit(main())
vixar/errors.py ADDED
@@ -0,0 +1,32 @@
1
+ """Vixar exception hierarchy with clear, user-facing messages (plan Phase 1/2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class VixarError(Exception):
7
+ """Base class for all Vixar errors."""
8
+
9
+
10
+ class InputValidationError(VixarError):
11
+ """Raised for bad user input: missing columns, wrong shapes, bad files."""
12
+
13
+
14
+ class LAZBackendError(VixarError):
15
+ """Raised when a ``.laz`` file is read but no decompression backend is installed.
16
+
17
+ LAZ (LASzip-compressed LAS) is decompressed by ``laspy`` using a pluggable
18
+ backend. ``vixar[las]`` ships the pure-Python-installable ``lazrs`` backend;
19
+ this error means none is importable at read time.
20
+ """
21
+
22
+ def __init__(self, path: str | None = None) -> None:
23
+ where = f" ({path})" if path else ""
24
+ super().__init__(
25
+ f"Reading LAZ files{where} requires a LAZ decompression backend. "
26
+ "Install one with: pip install 'vixar[las]' (bundles 'lazrs'), "
27
+ "or pip install lazrs (or laszip)."
28
+ )
29
+
30
+
31
+ class UnsupportedEPSGError(VixarError):
32
+ """Raised when a CRS/EPSG code cannot be resolved by pyproj."""
vixar/html.py ADDED
@@ -0,0 +1,50 @@
1
+ """Standalone HTML generation (plan Phase 1: ``viewer.to_html``).
2
+
3
+ Produces a self-contained page: the bundled viewer.js inlined as a <script>,
4
+ and the scene config injected as ``window.__VIXAR_SCENE__`` so the engine
5
+ renders immediately with no server. Also reused as the anywidget iframe srcdoc.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from vixar.schema import SceneConfig
13
+ from vixar.static import CSP_META, read_viewer_js
14
+
15
+
16
+ def render_html(config: SceneConfig, width: int = 1200, height: int = 800) -> str:
17
+ """Return a complete standalone HTML document string for ``config``."""
18
+ viewer_js = read_viewer_js()
19
+ scene_json = config.to_json()
20
+ # Guard against an accidental </script> inside JSON breaking the inline tag.
21
+ scene_json = scene_json.replace("</", "<\\/")
22
+ return f"""<!doctype html>
23
+ <html lang="en">
24
+ <head>
25
+ <meta charset="utf-8" />
26
+ <meta http-equiv="Content-Security-Policy" content="{CSP_META}" />
27
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
28
+ <title>Vixar</title>
29
+ <style>
30
+ html, body {{ margin: 0; height: 100%; background: #12141c; }}
31
+ #vixar-root {{
32
+ position: absolute; inset: 0;
33
+ width: {width}px; height: {height}px; max-width: 100%;
34
+ }}
35
+ </style>
36
+ </head>
37
+ <body>
38
+ <div id="vixar-root"></div>
39
+ <script>window.__VIXAR_SCENE__ = {scene_json};</script>
40
+ <script>{viewer_js}</script>
41
+ </body>
42
+ </html>
43
+ """
44
+
45
+
46
+ def write_html(config: SceneConfig, path: str, width: int = 1200, height: int = 800) -> str:
47
+ """Write the standalone HTML to ``path`` and return the absolute path."""
48
+ out = Path(path)
49
+ out.write_text(render_html(config, width=width, height=height), encoding="utf-8")
50
+ return str(out.resolve())
vixar/io/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Data ingestion and coordinate handling for Vixar (plan Phase 1)."""
2
+
3
+ from vixar.io.coords import Origin, compute_origin, reproject_to_utm, subtract_origin
4
+ from vixar.io.csv_reader import read_borehole_csv, read_point_csv
5
+ from vixar.io.encode import encode_f32, encode_u32
6
+ from vixar.io.las_reader import read_las
7
+ from vixar.io.stats import AttributeStats, compute_stats
8
+
9
+ __all__ = [
10
+ "Origin",
11
+ "compute_origin",
12
+ "reproject_to_utm",
13
+ "subtract_origin",
14
+ "read_point_csv",
15
+ "read_borehole_csv",
16
+ "read_las",
17
+ "encode_f32",
18
+ "encode_u32",
19
+ "AttributeStats",
20
+ "compute_stats",
21
+ ]
@@ -0,0 +1,78 @@
1
+ """Block model reader for regular-grid CSV block models (plan Phase 3).
2
+
3
+ Reads a CSV with block-centre coordinates (X, Y, Z) and attribute columns,
4
+ detects the regular grid dimensions from the unique coordinate values, and
5
+ returns the data in the shape expected by ``add_block_model``.
6
+
7
+ The output ``centers`` array is in source-CRS (float64 UTM) and is NOT yet
8
+ origin-subtracted — that happens at ``build_config`` time in ``Viewer``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+
18
+
19
+ @dataclass
20
+ class BlockModelData:
21
+ """Regular-grid block model in source CRS."""
22
+
23
+ centers: np.ndarray # (N, 3) float64 block-centre X, Y, Z
24
+ values: dict[str, np.ndarray] = field(default_factory=dict)
25
+ # Detected uniform block spacing [dx, dy, dz].
26
+ block_size: tuple[float, float, float] = (1.0, 1.0, 1.0)
27
+ # Grid dimensions [nx, ny, nz] — number of cells per axis.
28
+ grid_dims: tuple[int, int, int] = (1, 1, 1)
29
+
30
+
31
+ def read_block_model(
32
+ source,
33
+ x_col: str = "x",
34
+ y_col: str = "y",
35
+ z_col: str = "z",
36
+ attribute_cols: list[str] | None = None,
37
+ ) -> BlockModelData:
38
+ """Read a regular block model from *source* (CSV path or DataFrame).
39
+
40
+ Grid dimensions are auto-detected from the unique sorted X/Y/Z values.
41
+ ``attribute_cols`` lists extra columns to extract as scalar attributes; if
42
+ *None*, all non-coordinate columns are included.
43
+ """
44
+ if isinstance(source, pd.DataFrame):
45
+ df = source
46
+ else:
47
+ df = pd.read_csv(source)
48
+
49
+ x = df[x_col].to_numpy(dtype="float64")
50
+ y = df[y_col].to_numpy(dtype="float64")
51
+ z = df[z_col].to_numpy(dtype="float64")
52
+
53
+ ux = np.unique(x)
54
+ uy = np.unique(y)
55
+ uz = np.unique(z)
56
+ nx, ny, nz = int(len(ux)), int(len(uy)), int(len(uz))
57
+
58
+ dx = float(np.median(np.diff(ux))) if nx > 1 else 1.0
59
+ dy = float(np.median(np.diff(uy))) if ny > 1 else 1.0
60
+ dz = float(np.median(np.diff(uz))) if nz > 1 else 1.0
61
+
62
+ centers = np.stack([x, y, z], axis=1) # (N, 3) float64
63
+
64
+ attr_names = attribute_cols if attribute_cols is not None else [
65
+ c for c in df.columns if c not in (x_col, y_col, z_col)
66
+ ]
67
+ values = {
68
+ name: df[name].to_numpy(dtype="float32")
69
+ for name in attr_names
70
+ if name in df.columns
71
+ }
72
+
73
+ return BlockModelData(
74
+ centers=centers,
75
+ values=values,
76
+ block_size=(dx, dy, dz),
77
+ grid_dims=(nx, ny, nz),
78
+ )
vixar/io/coords.py ADDED
@@ -0,0 +1,84 @@
1
+ """UTM coordinate strategy (plan §4.3).
2
+
3
+ Large UTM coordinates (~500,000 m) lose precision when cast to float32 in
4
+ WebGL. Vixar keeps full float64 precision in Python, computes a scene origin
5
+ (the bounding-box centroid), and subtracts it so the JS engine only ever sees
6
+ small, float32-safe local offsets. The absolute origin travels in the scene
7
+ config so picks/exports can be mapped back to real-world UTM.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+ import numpy as np
15
+
16
+ from vixar.errors import UnsupportedEPSGError
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Origin:
21
+ easting: float
22
+ northing: float
23
+ elevation: float
24
+ epsg: int | None = None
25
+
26
+ def as_array(self) -> np.ndarray:
27
+ return np.array([self.easting, self.northing, self.elevation], dtype="float64")
28
+
29
+
30
+ def reproject_to_utm(
31
+ points: np.ndarray,
32
+ src_epsg: int,
33
+ dst_epsg: int | None = None,
34
+ ) -> np.ndarray:
35
+ """Reproject (N, 3) points from ``src_epsg`` to ``dst_epsg``.
36
+
37
+ When ``dst_epsg`` is None or equals the source, points are returned
38
+ unchanged (already in the working projected CRS). Z is passed through.
39
+ """
40
+ if dst_epsg is None or dst_epsg == src_epsg:
41
+ return np.asarray(points, dtype="float64")
42
+
43
+ try:
44
+ from pyproj import Transformer
45
+ except ImportError as exc: # pragma: no cover - import guard
46
+ raise UnsupportedEPSGError(
47
+ "pyproj is required for reprojection but is not installed."
48
+ ) from exc
49
+
50
+ try:
51
+ transformer = Transformer.from_crs(src_epsg, dst_epsg, always_xy=True)
52
+ except Exception as exc: # noqa: BLE001 - surface a clear message
53
+ raise UnsupportedEPSGError(
54
+ f"Could not build a transform from EPSG:{src_epsg} to EPSG:{dst_epsg}: {exc}"
55
+ ) from exc
56
+
57
+ pts = np.asarray(points, dtype="float64")
58
+ x, y = transformer.transform(pts[:, 0], pts[:, 1])
59
+ out = pts.copy()
60
+ out[:, 0] = x
61
+ out[:, 1] = y
62
+ return out
63
+
64
+
65
+ def compute_origin(points_utm: np.ndarray, epsg: int | None = None) -> Origin:
66
+ """Compute the scene origin as the bounding-box centroid of the points."""
67
+ pts = np.asarray(points_utm, dtype="float64")
68
+ if pts.ndim != 2 or pts.shape[1] != 3:
69
+ raise ValueError("compute_origin expects an (N, 3) array")
70
+ lo = pts.min(axis=0)
71
+ hi = pts.max(axis=0)
72
+ center = (lo + hi) * 0.5
73
+ return Origin(
74
+ easting=float(center[0]),
75
+ northing=float(center[1]),
76
+ elevation=float(center[2]),
77
+ epsg=epsg,
78
+ )
79
+
80
+
81
+ def subtract_origin(points_utm: np.ndarray, origin: Origin) -> np.ndarray:
82
+ """Subtract the origin and downcast to float32 local coordinates."""
83
+ pts = np.asarray(points_utm, dtype="float64") - origin.as_array()
84
+ return pts.astype("float32")
vixar/io/csv_reader.py ADDED
@@ -0,0 +1,160 @@
1
+ """CSV / XYZ ingestion for point clouds and boreholes (plan Phase 1).
2
+
3
+ Accepts a file path, a pandas DataFrame, or a numpy array, and returns float64
4
+ positions plus named attribute arrays. Coordinates are returned in their source
5
+ CRS; reprojection and origin subtraction happen later in the Viewer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from dataclasses import dataclass, field
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+
16
+ from vixar.errors import InputValidationError
17
+
18
+ Source = "str | os.PathLike | pd.DataFrame | np.ndarray"
19
+
20
+
21
+ @dataclass
22
+ class PointCloudData:
23
+ positions: np.ndarray # (N, 3) float64
24
+ attributes: dict[str, np.ndarray] = field(default_factory=dict)
25
+
26
+ @property
27
+ def count(self) -> int:
28
+ return int(self.positions.shape[0])
29
+
30
+
31
+ @dataclass
32
+ class BoreholeData:
33
+ positions: np.ndarray # (M, 3) float64, concatenated across holes
34
+ hole_offsets: np.ndarray # (K,) uint32 start index of each hole
35
+ attributes: dict[str, np.ndarray] = field(default_factory=dict)
36
+
37
+ @property
38
+ def count(self) -> int:
39
+ return int(self.positions.shape[0])
40
+
41
+
42
+ def _to_dataframe(source) -> pd.DataFrame:
43
+ if isinstance(source, pd.DataFrame):
44
+ return source
45
+ if isinstance(source, np.ndarray):
46
+ if source.ndim != 2 or source.shape[1] < 3:
47
+ raise InputValidationError(
48
+ "numpy point source must be an (N, >=3) array of [x, y, z, ...]"
49
+ )
50
+ cols = ["x", "y", "z"] + [f"col{i}" for i in range(3, source.shape[1])]
51
+ return pd.DataFrame(source, columns=cols)
52
+ path = os.fspath(source)
53
+ if not os.path.exists(path):
54
+ raise InputValidationError(f"File not found: {path}")
55
+ # LAS/LAZ paths are routed to the LAS reader upstream (see _is_las_path);
56
+ # this reader only handles delimited text. Let pandas sniff the delimiter.
57
+ sep = None
58
+ return pd.read_csv(path, sep=sep, engine="python")
59
+
60
+
61
+ def _require_columns(df: pd.DataFrame, columns: list[str]) -> None:
62
+ missing = [c for c in columns if c is not None and c not in df.columns]
63
+ if missing:
64
+ raise InputValidationError(
65
+ f"Column(s) not found: {missing}. Available columns: {list(df.columns)}"
66
+ )
67
+
68
+
69
+ def read_point_csv(
70
+ source,
71
+ x_col: str = "x",
72
+ y_col: str = "y",
73
+ z_col: str = "z",
74
+ attribute_cols: dict[str, str] | None = None,
75
+ ) -> PointCloudData:
76
+ """Read a point cloud from CSV/DataFrame/ndarray.
77
+
78
+ ``attribute_cols`` maps an output attribute name to a source column name.
79
+ """
80
+ df = _to_dataframe(source)
81
+ if isinstance(source, np.ndarray):
82
+ x_col, y_col, z_col = "x", "y", "z"
83
+ _require_columns(df, [x_col, y_col, z_col])
84
+
85
+ positions = np.column_stack(
86
+ [
87
+ df[x_col].to_numpy(dtype="float64"),
88
+ df[y_col].to_numpy(dtype="float64"),
89
+ df[z_col].to_numpy(dtype="float64"),
90
+ ]
91
+ )
92
+
93
+ attributes: dict[str, np.ndarray] = {}
94
+ if attribute_cols:
95
+ _require_columns(df, list(attribute_cols.values()))
96
+ for out_name, col in attribute_cols.items():
97
+ attributes[out_name] = df[col].to_numpy(dtype="float64")
98
+
99
+ return PointCloudData(positions=positions, attributes=attributes)
100
+
101
+
102
+ def read_borehole_csv(
103
+ source,
104
+ id_col: str,
105
+ x_col: str,
106
+ y_col: str,
107
+ z_col: str,
108
+ from_col: str | None = None,
109
+ attribute_cols: dict[str, str] | None = None,
110
+ ) -> BoreholeData:
111
+ """Read desurveyed borehole samples grouped into per-hole polylines.
112
+
113
+ Rows are grouped by ``id_col`` and ordered by ``from_col`` (down-hole depth)
114
+ when provided, else by input order. Each row contributes one 3-D sample.
115
+ """
116
+ df = _to_dataframe(source)
117
+ required = [id_col, x_col, y_col, z_col]
118
+ if from_col is not None:
119
+ required.append(from_col)
120
+ _require_columns(df, required)
121
+
122
+ positions_parts: list[np.ndarray] = []
123
+ offsets: list[int] = []
124
+ attr_parts: dict[str, list[np.ndarray]] = {k: [] for k in (attribute_cols or {})}
125
+ if attribute_cols:
126
+ _require_columns(df, list(attribute_cols.values()))
127
+
128
+ cursor = 0
129
+ # Stable grouping preserves hole encounter order.
130
+ for _hole_id, group in df.groupby(id_col, sort=False):
131
+ if from_col is not None:
132
+ group = group.sort_values(from_col, kind="stable")
133
+ if len(group) < 2:
134
+ # A single sample cannot form a tube; skip with no crash.
135
+ continue
136
+ offsets.append(cursor)
137
+ xyz = np.column_stack(
138
+ [
139
+ group[x_col].to_numpy(dtype="float64"),
140
+ group[y_col].to_numpy(dtype="float64"),
141
+ group[z_col].to_numpy(dtype="float64"),
142
+ ]
143
+ )
144
+ positions_parts.append(xyz)
145
+ for out_name, col in (attribute_cols or {}).items():
146
+ attr_parts[out_name].append(group[col].to_numpy(dtype="float64"))
147
+ cursor += len(group)
148
+
149
+ if not positions_parts:
150
+ raise InputValidationError(
151
+ "No borehole had >= 2 samples; cannot build any drill traces."
152
+ )
153
+
154
+ positions = np.concatenate(positions_parts, axis=0)
155
+ attributes = {name: np.concatenate(parts) for name, parts in attr_parts.items()}
156
+ return BoreholeData(
157
+ positions=positions,
158
+ hole_offsets=np.asarray(offsets, dtype="uint32"),
159
+ attributes=attributes,
160
+ )
vixar/io/encode.py ADDED
@@ -0,0 +1,25 @@
1
+ """Encode numpy arrays into the little-endian base64 the JS engine decodes.
2
+
3
+ The JS ``decodeFloat32`` / ``decodeUint32`` helpers read little-endian buffers,
4
+ so we force byte order here regardless of host endianness.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+
11
+ import numpy as np
12
+
13
+ from vixar.schema import Base64Ref
14
+
15
+
16
+ def encode_f32(values: np.ndarray) -> Base64Ref:
17
+ """Encode an array as a little-endian float32 base64 DataRef."""
18
+ arr = np.ascontiguousarray(values, dtype="<f4")
19
+ return Base64Ref(dtype="f32", data=base64.b64encode(arr.tobytes()).decode("ascii"))
20
+
21
+
22
+ def encode_u32(values: np.ndarray) -> Base64Ref:
23
+ """Encode an array as a little-endian uint32 base64 DataRef."""
24
+ arr = np.ascontiguousarray(values, dtype="<u4")
25
+ return Base64Ref(dtype="u32", data=base64.b64encode(arr.tobytes()).decode("ascii"))
vixar/io/las_reader.py ADDED
@@ -0,0 +1,108 @@
1
+ """LAS / LAZ 1.x ingestion via laspy (plan Phase 2).
2
+
3
+ Reads positions and common attributes (intensity, classification, RGB, Z) and
4
+ auto-detects the EPSG from the file's CRS/VLRs. LAZ (LASzip-compressed LAS) is
5
+ read transparently when a decompression backend is installed — ``vixar[las]``
6
+ ships the ``lazrs`` backend, so ``.laz`` works out of the box.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from dataclasses import dataclass, field
13
+
14
+ import numpy as np
15
+
16
+ from vixar.errors import InputValidationError, LAZBackendError
17
+
18
+
19
+ @dataclass
20
+ class LASData:
21
+ positions: np.ndarray # (N, 3) float64 in the file's CRS
22
+ attributes: dict[str, np.ndarray] = field(default_factory=dict)
23
+ epsg: int | None = None
24
+
25
+ @property
26
+ def count(self) -> int:
27
+ return int(self.positions.shape[0])
28
+
29
+
30
+ def read_las(source, color_by: str | None = None) -> LASData:
31
+ """Read a LAS/LAZ 1.x file into positions + attributes with EPSG detection.
32
+
33
+ ``.laz`` files are decompressed transparently when a LAZ backend (e.g.
34
+ ``lazrs``, bundled with ``vixar[las]``) is installed; otherwise a clear
35
+ :class:`~vixar.errors.LAZBackendError` is raised.
36
+ """
37
+ path = os.fspath(source)
38
+ if not os.path.exists(path):
39
+ raise InputValidationError(f"File not found: {path}")
40
+
41
+ try:
42
+ import laspy
43
+ except ImportError as exc: # pragma: no cover - import guard
44
+ raise InputValidationError(
45
+ "Reading LAS/LAZ files requires the optional 'laspy' dependency. "
46
+ "Install it with: pip install 'vixar[las]'"
47
+ ) from exc
48
+
49
+ is_laz = path.lower().endswith(".laz")
50
+ if is_laz and not _laz_backend_available():
51
+ raise LAZBackendError(path)
52
+
53
+ las = laspy.read(path)
54
+
55
+ positions = np.column_stack(
56
+ [np.asarray(las.x, dtype="float64"),
57
+ np.asarray(las.y, dtype="float64"),
58
+ np.asarray(las.z, dtype="float64")]
59
+ )
60
+
61
+ attributes: dict[str, np.ndarray] = {
62
+ "z": positions[:, 2].copy(),
63
+ }
64
+ # Intensity is present in all standard point formats.
65
+ if hasattr(las, "intensity"):
66
+ attributes["intensity"] = np.asarray(las.intensity, dtype="float64")
67
+ if hasattr(las, "classification"):
68
+ attributes["classification"] = np.asarray(las.classification, dtype="float64")
69
+ # RGB only on point formats 2, 3, 5, 7, 8.
70
+ if {"red", "green", "blue"} <= set(las.point_format.dimension_names):
71
+ attributes["red"] = np.asarray(las.red, dtype="float64")
72
+ attributes["green"] = np.asarray(las.green, dtype="float64")
73
+ attributes["blue"] = np.asarray(las.blue, dtype="float64")
74
+
75
+ if color_by is not None and color_by not in attributes:
76
+ raise InputValidationError(
77
+ f"color_by={color_by!r} not found in LAS attributes "
78
+ f"{sorted(attributes)}. Use one of those, or 'z'."
79
+ )
80
+
81
+ return LASData(positions=positions, attributes=attributes, epsg=_detect_epsg(las))
82
+
83
+
84
+ def _laz_backend_available() -> bool:
85
+ """True when laspy has at least one usable LAZ decompression backend."""
86
+ try:
87
+ from laspy.compression import LazBackend
88
+ except Exception: # noqa: BLE001 - older/newer laspy layouts
89
+ return False
90
+ try:
91
+ return any(b.is_available() for b in LazBackend)
92
+ except Exception: # noqa: BLE001
93
+ return False
94
+
95
+
96
+ def _detect_epsg(las) -> int | None:
97
+ """Best-effort EPSG detection from the LAS CRS / VLRs."""
98
+ try:
99
+ crs = las.header.parse_crs()
100
+ except Exception: # noqa: BLE001 - laspy raises various errors here
101
+ return None
102
+ if crs is None:
103
+ return None
104
+ try:
105
+ epsg = crs.to_epsg()
106
+ return int(epsg) if epsg is not None else None
107
+ except Exception: # noqa: BLE001
108
+ return None