Source code for pycsamt.format.borehole.exports

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""GeoJSON, VTP, and glTF exports for PCBH visualization subsets."""

from __future__ import annotations

import base64
import json
import math
import struct
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
from typing import Any

import numpy as np

from ...api.property import PyCSAMTObject
from .render import build_render_model
from .schema import PCBHDocument

__all__ = [
    "ExportLoss",
    "PCBHExportReport",
    "write_geojson",
    "write_vtp",
    "write_gltf",
]


[docs] @dataclass(frozen=True, repr=False) class ExportLoss(PyCSAMTObject): """One field or semantic category omitted from a view export.""" code: str message: str
[docs] @dataclass(frozen=True, repr=False) class PCBHExportReport(PyCSAMTObject): """Summary and explicit losses for a non-canonical export.""" format: str boreholes: int features: int source_crs: str target_crs: str | None losses: tuple[ExportLoss, ...]
@dataclass(frozen=True) class _TubeMesh: positions: np.ndarray normals: np.ndarray colors: np.ndarray measured_depth: np.ndarray material_ids: np.ndarray triangles: np.ndarray materials: tuple[tuple[str, str], ...]
[docs] def write_geojson( document: PCBHDocument, path: str | Path, *, target_crs: str = "EPSG:4326", include_z: bool = True, ) -> tuple[Path, PCBHExportReport]: """Write WGS84 collar Points and trajectory LineStrings as GeoJSON.""" document.validate() transformer = _transformer(document.crs.horizontal, target_crs) render = build_render_model(document) features = [] for hole in render.boreholes: collar = hole.collar cx, cy = _xy(transformer, collar.position[0], collar.position[1]) collar_coords = [cx, cy] if include_z: collar_coords.append(collar.position[2]) common = dict(collar.metadata) common["pcbh_document_id"] = document.document_id features.append( { "type": "Feature", "id": f"{hole.borehole_id}:collar", "geometry": {"type": "Point", "coordinates": collar_coords}, "properties": {**common, "feature_type": "collar"}, } ) coordinates = [] for point in hole.centerline.points: x, y = _xy(transformer, point.x, point.y) coordinate = [x, y] if include_z: coordinate.append(point.z) coordinates.append(coordinate) features.append( { "type": "Feature", "id": f"{hole.borehole_id}:trajectory", "geometry": { "type": "LineString", "coordinates": coordinates, }, "properties": { **common, "feature_type": "trajectory", "z_reference": document.crs.vertical, "z_unit": document.crs.coordinate_unit, }, } ) payload = {"type": "FeatureCollection", "features": features} output = _prepare_path(path, ".geojson") output.write_text( json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) losses = _common_losses(document, family=None) if include_z: losses.append( ExportLoss( "geojson_z_semantics", "GeoJSON Z retains PCBH elevation/reference; RFC 7946 only " "standardizes horizontal WGS84 coordinates.", ) ) return output, PCBHExportReport( "geojson", len(document.boreholes), len(features), document.crs.horizontal, target_crs, tuple(losses), )
[docs] def write_vtp( document: PCBHDocument, path: str | Path, *, family: str = "lithology", sides: int = 8, ) -> tuple[Path, PCBHExportReport]: """Write interval tubes as ASCII VTK XML PolyData (``.vtp``).""" mesh = _tube_mesh(document, family=family, sides=sides) root = ET.Element( "VTKFile", type="PolyData", version="1.0", byte_order="LittleEndian", ) poly = ET.SubElement(root, "PolyData") piece = ET.SubElement( poly, "Piece", NumberOfPoints=str(len(mesh.positions)), NumberOfPolys=str(len(mesh.triangles)), ) points = ET.SubElement(piece, "Points") _xml_array(points, "Float64", None, mesh.positions, components=3) point_data = ET.SubElement(piece, "PointData", Scalars="material_id") _xml_array(point_data, "Float64", "measured_depth", mesh.measured_depth) _xml_array(point_data, "Int32", "material_id", mesh.material_ids) _xml_array(point_data, "UInt8", "RGB", mesh.colors, components=3) polys = ET.SubElement(piece, "Polys") _xml_array(polys, "Int64", "connectivity", mesh.triangles.reshape(-1)) offsets = np.arange(1, len(mesh.triangles) + 1, dtype=np.int64) * 3 _xml_array(polys, "Int64", "offsets", offsets) field = ET.SubElement(piece, "FieldData") material_json = json.dumps(dict(mesh.materials), separators=(",", ":")) data = ET.SubElement( field, "DataArray", type="String", Name="pcbh_materials_json", NumberOfTuples="1", format="ascii", ) data.text = material_json output = _prepare_path(path, ".vtp") ET.ElementTree(root).write(output, encoding="utf-8", xml_declaration=True) losses = _common_losses(document, family=family) return output, PCBHExportReport( "vtp", len(document.boreholes), len(mesh.triangles), document.crs.horizontal, document.crs.horizontal, tuple(losses), )
[docs] def write_gltf( document: PCBHDocument, path: str | Path, *, family: str = "lithology", sides: int = 8, ) -> tuple[Path, PCBHExportReport]: """Write browser-ready glTF 2.0 (``.gltf``) or binary GLB tubes.""" output = Path(path) if output.suffix.lower() not in {".gltf", ".glb"}: output = output.with_suffix(".gltf") output.parent.mkdir(parents=True, exist_ok=True) mesh = _tube_mesh(document, family=family, sides=sides) gltf, binary = _gltf_payload(mesh, document) if output.suffix.lower() == ".glb": output.write_bytes(_glb_bytes(gltf, binary)) fmt = "glb" else: gltf["buffers"][0]["uri"] = ( "data:application/octet-stream;base64," + base64.b64encode(binary).decode("ascii") ) output.write_text( json.dumps(gltf, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) fmt = "gltf" return output, PCBHExportReport( fmt, len(document.boreholes), len(mesh.triangles), document.crs.horizontal, document.crs.horizontal, tuple(_common_losses(document, family=family)), )
def _tube_mesh( document: PCBHDocument, *, family: str, sides: int, ) -> _TubeMesh: if isinstance(sides, bool) or not isinstance(sides, int) or sides < 3: raise ValueError("tube sides must be an integer >= 3") model = build_render_model(document, family=family) positions = [] normals = [] colors = [] depths = [] material_ids = [] triangles = [] material_map: dict[str, int] = {} for hole in model.boreholes: segments = hole.interval_segments or (hole.centerline,) for segment in segments: color = segment.color material_id = material_map.setdefault(color, len(material_map)) radius = getattr(segment, "display_radius", hole.display_radius) points = segment.points start = len(positions) rgb = _hex_rgb(color) for index, point in enumerate(points): tangent = _tangent(points, index) u, v = _normal_basis(tangent) for side in range(sides): angle = 2.0 * math.pi * side / sides normal = math.cos(angle) * u + math.sin(angle) * v positions.append( np.array([point.x, point.y, point.z]) + radius * normal ) normals.append(normal) colors.append(rgb) depths.append(point.md) material_ids.append(material_id) for ring in range(len(points) - 1): a = start + ring * sides b = a + sides for side in range(sides): nxt = (side + 1) % sides triangles.extend( [(a + side, b + side, b + nxt), (a + side, b + nxt, a + nxt)] ) if not positions: raise ValueError(f"no {family!r} intervals or trajectories to export") materials = tuple( (str(index), color) for color, index in sorted( material_map.items(), key=lambda item: item[1] ) ) return _TubeMesh( np.asarray(positions, dtype=np.float64), np.asarray(normals, dtype=np.float64), np.asarray(colors, dtype=np.uint8), np.asarray(depths, dtype=np.float64), np.asarray(material_ids, dtype=np.int32), np.asarray(triangles, dtype=np.uint32), materials, ) def _gltf_payload(mesh: _TubeMesh, document: PCBHDocument): arrays = [ mesh.positions.astype("<f4"), mesh.normals.astype("<f4"), mesh.colors.astype(np.float32) / 255.0, mesh.triangles.reshape(-1).astype("<u4"), ] binary = bytearray() views = [] accessors = [] targets = [34962, 34962, 34962, 34963] types = ["VEC3", "VEC3", "VEC3", "SCALAR"] components = [5126, 5126, 5126, 5125] for array, target, accessor_type, component in zip( arrays, targets, types, components ): while len(binary) % 4: binary.append(0) offset = len(binary) raw = array.tobytes(order="C") binary.extend(raw) views.append( {"buffer": 0, "byteOffset": offset, "byteLength": len(raw), "target": target} ) accessor: dict[str, Any] = { "bufferView": len(views) - 1, "componentType": component, "count": int(array.shape[0]), "type": accessor_type, } if len(accessors) == 0: accessor["min"] = array.min(axis=0).tolist() accessor["max"] = array.max(axis=0).tolist() accessors.append(accessor) gltf = { "asset": {"version": "2.0", "generator": "pyCSAMT PCBH"}, "scene": 0, "scenes": [{"nodes": [0]}], "nodes": [{"mesh": 0, "name": document.document_id}], "meshes": [{"primitives": [{ "attributes": {"POSITION": 0, "NORMAL": 1, "COLOR_0": 2}, "indices": 3, "material": 0, }]}], "materials": [{ "name": "PCBH interval colors", "pbrMetallicRoughness": { "baseColorFactor": [1.0, 1.0, 1.0, 1.0], "metallicFactor": 0.0, "roughnessFactor": 0.8, }, "doubleSided": True, }], "buffers": [{"byteLength": len(binary)}], "bufferViews": views, "accessors": accessors, "extras": { "pcbh_crs": document.crs.horizontal, "pcbh_vertical_crs": document.crs.vertical, "pcbh_materials": dict(mesh.materials), }, } return gltf, bytes(binary) def _glb_bytes(gltf: dict[str, Any], binary: bytes) -> bytes: json_chunk = json.dumps(gltf, separators=(",", ":")).encode("utf-8") json_chunk += b" " * ((-len(json_chunk)) % 4) binary += b"\x00" * ((-len(binary)) % 4) total = 12 + 8 + len(json_chunk) + 8 + len(binary) return ( struct.pack("<4sII", b"glTF", 2, total) + struct.pack("<I4s", len(json_chunk), b"JSON") + json_chunk + struct.pack("<I4s", len(binary), b"BIN\x00") + binary ) def _transformer(source: str, target: str): try: from pyproj import CRS, Transformer except ImportError as error: if source != target: raise ImportError( "pyproj is required for GeoJSON reprojection" ) from error return None source_crs = CRS.from_user_input(source) target_crs = CRS.from_user_input(target) if target_crs.to_epsg() != 4326: raise ValueError("RFC 7946 GeoJSON target CRS must be EPSG:4326") if source_crs == target_crs: return None return Transformer.from_crs(source_crs, target_crs, always_xy=True) def _xy(transformer, x, y): if transformer is None: return float(x), float(y) result = transformer.transform(x, y) return float(result[0]), float(result[1]) def _tangent(points, index): left = points[max(0, index - 1)] right = points[min(len(points) - 1, index + 1)] vector = np.array([right.x - left.x, right.y - left.y, right.z - left.z]) norm = np.linalg.norm(vector) return vector / norm if norm else np.array([0.0, 0.0, -1.0]) def _normal_basis(tangent): helper = np.array([0.0, 0.0, 1.0]) if abs(float(np.dot(tangent, helper))) > 0.9: helper = np.array([1.0, 0.0, 0.0]) u = np.cross(tangent, helper) u /= np.linalg.norm(u) return u, np.cross(tangent, u) def _hex_rgb(color): value = color.lstrip("#") return tuple(int(value[index:index + 2], 16) for index in (0, 2, 4)) def _xml_array(parent, vtk_type, name, values, *, components=None): attributes = {"type": vtk_type, "format": "ascii"} if name is not None: attributes["Name"] = name if components is not None: attributes["NumberOfComponents"] = str(components) data = ET.SubElement(parent, "DataArray", **attributes) array = np.asarray(values).reshape(-1) data.text = " ".join(str(value) for value in array) def _common_losses(document, *, family): losses = [ ExportLoss( "noncanonical_view", "Export is a visualization subset; retain PCBH JSON as authority.", ) ] if family is not None: other = sorted( { name for hole in document.boreholes for name in hole.interval_logs if name != family } ) if other: losses.append( ExportLoss( "interval_families_omitted", "Omitted interval families: " + ", ".join(other), ) ) if any(hole.structures for hole in document.boreholes): losses.append( ExportLoss( "structures_omitted", "Structural observations are not encoded in tube geometry.", ) ) return losses def _prepare_path(path, suffix): output = Path(path) if output.suffix.lower() != suffix: output = output.with_suffix(suffix) output.parent.mkdir(parents=True, exist_ok=True) return output