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 +17 -0
- vixar/cli.py +76 -0
- vixar/errors.py +32 -0
- vixar/html.py +50 -0
- vixar/io/__init__.py +21 -0
- vixar/io/block_model_reader.py +78 -0
- vixar/io/coords.py +84 -0
- vixar/io/csv_reader.py +160 -0
- vixar/io/encode.py +25 -0
- vixar/io/las_reader.py +108 -0
- vixar/io/mesh_reader.py +166 -0
- vixar/io/stats.py +43 -0
- vixar/io/volume_reader.py +141 -0
- vixar/layers.py +100 -0
- vixar/schema.py +170 -0
- vixar/server.py +127 -0
- vixar/static/viewer.js +4018 -0
- vixar/static/viewer.js.map +1 -0
- vixar/static.py +59 -0
- vixar/tiler.py +150 -0
- vixar/viewer.py +1015 -0
- vixar/widget.py +128 -0
- vixar-1.0.0.dist-info/METADATA +75 -0
- vixar-1.0.0.dist-info/RECORD +26 -0
- vixar-1.0.0.dist-info/WHEEL +4 -0
- vixar-1.0.0.dist-info/entry_points.txt +2 -0
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
|