From b5bca87a0b20f808b799edd1fbff331ee8ad98a8 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 20 Oct 2026 13:26:57 +1030 Subject: [PATCH 1/3] fix(visualisation): upgrade visualisation dependencies --- .../loopstructural_visualisation/README.md | 25 ++- .../pyproject.toml | 2 +- .../loopstructuralvisualisation/_2d_viewer.py | 62 ++---- .../loopstructuralvisualisation/_3d_viewer.py | 207 +++++++++--------- .../loopstructuralvisualisation/__init__.py | 2 +- .../_register_loop_ui.py | 7 +- .../_stratigraphic_column.py | 4 +- .../src/loopstructuralvisualisation/api.py | 4 +- .../trame/__init__.py | 6 +- .../trame/ui/vuetify3.py | 5 +- .../tests/test_import.py | 9 +- .../tests/test_plotting.py | 192 ++++++++++++++++ .../tests/test_trame.py | 31 +++ 13 files changed, 397 insertions(+), 159 deletions(-) create mode 100644 packages/loopstructural_visualisation/tests/test_plotting.py create mode 100644 packages/loopstructural_visualisation/tests/test_trame.py diff --git a/packages/loopstructural_visualisation/README.md b/packages/loopstructural_visualisation/README.md index 91ab10f9b..f5140b97f 100644 --- a/packages/loopstructural_visualisation/README.md +++ b/packages/loopstructural_visualisation/README.md @@ -1,6 +1,29 @@ # loopstructural-visualisation -A LoopStructural interface for pyvista's Plotter class. +A LoopStructural interface for pyvista's Plotter class. + +The viewer uses the current LoopStructural geometry and stratigraphic-column +APIs. Feature evaluation takes world coordinates; the viewer does not apply an +additional model-coordinate transform. Notebook integration supports both +PyVista's original Trame modules and the newer `trame-pyvista` package. + +Compatibility checked on Windows with Python 3.13 and workspace LoopStructural +1.8.4: + +| VTK | PyVista | Checks | +| --- | --- | --- | +| 9.7.1 | 0.49.1 | Core plotting, off-screen rendering, clipping, notebook UI | +| 9.6.2 | 0.48.4 | Core plotting, off-screen rendering, clipping | + +CI also runs the plotting and notebook tests on Windows and Linux against the +VTK 9.6 and 9.7 release lines with the latest compatible PyVista. + +From the workspace root, run the tests with: + +```sh +uv sync --package loopstructuralvisualisation --extra tests --extra jupyter +uv run --no-sync pytest packages/loopstructural_visualisation/tests +``` To install `pip install loopstructuralvisualisation` or for a jupyter notebook environment (including the pyvista[jupyuter] dependencies) `pip install loopstructuralvisualisation[jupyter]` diff --git a/packages/loopstructural_visualisation/pyproject.toml b/packages/loopstructural_visualisation/pyproject.toml index 2e31e68b0..963371b7b 100644 --- a/packages/loopstructural_visualisation/pyproject.toml +++ b/packages/loopstructural_visualisation/pyproject.toml @@ -30,7 +30,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", ] -dependencies = ["numpy>=1.18", "pyvista>=0.42", "LoopStructural>=1.6.17"] +dependencies = ["numpy>=1.18", "matplotlib>=3.6", "pyvista>=0.44", "LoopStructural>=1.8.0"] [project.optional-dependencies] jupyter = ["pyvista[jupyter]"] diff --git a/packages/loopstructural_visualisation/src/loopstructuralvisualisation/_2d_viewer.py b/packages/loopstructural_visualisation/src/loopstructuralvisualisation/_2d_viewer.py index 23cb01101..dbe80683f 100644 --- a/packages/loopstructural_visualisation/src/loopstructuralvisualisation/_2d_viewer.py +++ b/packages/loopstructural_visualisation/src/loopstructuralvisualisation/_2d_viewer.py @@ -10,7 +10,7 @@ class Loop2DView: """ """ - def __init__(self, model=None, bounding_box=np.zeros((2, 2)), nsteps=None, ax=None, **kwargs): + def __init__(self, model=None, bounding_box=None, nsteps=None, ax=None, **kwargs): """ Parameters @@ -23,8 +23,9 @@ def __init__(self, model=None, bounding_box=np.zeros((2, 2)), nsteps=None, ax=No self.xx = None self.yy = None + self._model = None - self._bounding_box = bounding_box + self._bounding_box = np.zeros((2, 2)) if bounding_box is None else bounding_box self._nsteps = nsteps if self._nsteps is not None and self._bounding_box is not None: self._update_grid() @@ -50,9 +51,9 @@ def model(self): @model.setter def model(self, model): if model is not None: - bb = np.array([model.origin[:2], model.maximum[:2]]) + bb = np.array([model.bounding_box.origin[:2], model.bounding_box.maximum[:2]]) self.bounding_box = bb # model.bounding_box - self.nsteps = model.nsteps[:2] + self.nsteps = model.bounding_box.nsteps[:2] self._model = model self._update_grid() @@ -171,7 +172,6 @@ def add_data(self, feature, val=True, grad=True, unfault=False, dip=True, **kwar if unfault: value_data = np.copy(feature.interpolator.get_value_constraints()) - value_data[:, :3] = self.model.rescale(value_data[:, :3], inplace=False) point_colour = kwargs.pop("point_colour", None) if point_colour is None: self.ax.scatter( @@ -184,11 +184,10 @@ def add_data(self, feature, val=True, grad=True, unfault=False, dip=True, **kwar ) if point_colour is not None: self.ax.scatter(value_data[:, 0], value_data[:, 1], c=point_colour) - if grad: + if grad and ori_data: symb_colour = kwargs.pop("symb_colour", "black") symb_scale = kwargs.pop("symb_scale", 1.0) - gradient_data = np.hstack(ori_data) - gradient_data[:, :3] = self.model.rescale(gradient_data[:, :3], inplace=False) + gradient_data = np.vstack(ori_data).copy() gradient_data[:, 3:5] /= np.linalg.norm(gradient_data[:, 3:5], axis=1)[:, None] t = gradient_data[:, [4, 3]] * np.array([1, -1]).T n = gradient_data[:, 3:5] @@ -209,18 +208,19 @@ def add_data(self, feature, val=True, grad=True, unfault=False, dip=True, **kwar def add_fault_ellipse(self, faults=None, **kwargs): from matplotlib.patches import Ellipse - for f in self.model.stratigraphic_column["faults"].values(): - center = self.model.rescale(f["FaultCenter"]) + if faults is None: + faults = self.model.faults + for fault in faults: + center = fault.fault_centre + strike = fault.fault_strike_vector e = Ellipse( (center[0], center[1]), - f["HorizontalRadius"] * 2, - f["InfluenceDistance"] * 2, - 360 - f["FaultDipDirection"], - facecolor="None", - edgecolor="k", + fault.fault_major_axis * 2, + fault.fault_minor_axis * 2, + angle=np.degrees(np.arctan2(strike[1], strike[0])), + **({'facecolor': 'none', 'edgecolor': 'k'} | kwargs), ) - - self.ax.add_patch(e) + self.ax.add_patch(e) def add_scalar_field(self, feature, z=0, **kwargs): """ @@ -240,9 +240,7 @@ def add_scalar_field(self, feature, z=0, **kwargs): """ zz = np.zeros(self.xx.shape) zz[:] = z - v = feature.evaluate_value( - self.model.scale(np.array([self.xx, self.yy, zz]).T, inplace=False) - ) + v = feature.evaluate_value(np.array([self.xx, self.yy, zz]).T) return self.ax.imshow( v.reshape(self.nsteps).T, extent=[ @@ -271,11 +269,10 @@ def add_contour(self, feature, values, z=0, mask=None, **kwargs): """ zz = np.zeros(self.xx.shape) zz[:] = z - v = feature.evaluate_value( - self.model.scale(np.array([self.xx, self.yy, zz]).T, inplace=False) - ) + points = np.array([self.xx, self.yy, zz]).T + v = feature.evaluate_value(points) if mask: - maskv = mask(self.model.scale(np.array([self.xx, self.yy, zz]).T, inplace=False)) + maskv = mask(points) v[~maskv] = np.nan return self.ax.contour( v.reshape(self.nsteps).T, @@ -300,27 +297,14 @@ def add_model(self, z=0, cmap=None): cmap : str/matplotlib colourmap, optional specify a colour map, by default 'tab20' """ - if cmap is None: - import matplotlib.colors as colors - - colours = [] - boundaries = [] - data = [] - for g in self.model.stratigraphic_column.keys(): - if g == "faults": - continue - for v in self.model.stratigraphic_column[g].values(): - data.append((v["id"], v["colour"])) - colours.append(v["colour"]) - boundaries.append(v["id"]) # print(u,v) - cmap = colors.ListedColormap(colours) - zz = np.zeros_like(self.xx) zz[:] = z # self.bounding_box[1,2] pts = np.vstack([self.xx.flatten(), self.yy.flatten(), zz.flatten()]) if self.model is None: logger.error("Mapview needs a model assigned to plot model on map") return + if cmap is None: + cmap = self.model.stratigraphic_column.cmap() vals = self.model.evaluate_model(pts.T, scale=True) return self.ax.imshow( vals.reshape(self.nsteps).T, diff --git a/packages/loopstructural_visualisation/src/loopstructuralvisualisation/_3d_viewer.py b/packages/loopstructural_visualisation/src/loopstructuralvisualisation/_3d_viewer.py index 093abcf7d..49961a55c 100644 --- a/packages/loopstructural_visualisation/src/loopstructuralvisualisation/_3d_viewer.py +++ b/packages/loopstructural_visualisation/src/loopstructuralvisualisation/_3d_viewer.py @@ -1,12 +1,13 @@ +from __future__ import annotations + import pyvista as pv import numpy as np import re -from LoopStructural.datatypes import VectorPoints, ValuePoints +from LoopStructural.geometry import BoundingBox, VectorPoints, ValuePoints from LoopStructural.modelling.features import BaseFeature, StructuralFrame from LoopStructural.modelling.features.fault import FaultSegment -from LoopStructural.datatypes import BoundingBox from LoopStructural import GeologicalModel from LoopStructural.utils import getLogger from typing import Callable, Union, Optional, List @@ -15,7 +16,7 @@ class Loop3DView(pv.Plotter): - def __init__(self, model=None, background='white', *args, **kwargs): + def __init__(self, model=None, background="white", *args, **kwargs): """Loop3DView is a subclass of pyvista. Plotter that is designed to interface with the LoopStructural geological modelling package. @@ -26,51 +27,52 @@ def __init__(self, model=None, background='white', *args, **kwargs): background : str, optional colour for the background, by default 'white' """ - if 'shape' in kwargs: - logger.warning('shape argument is not used in Loop3DView') - kwargs.pop('shape') + if "shape" in kwargs: + logger.warning("shape argument is not used in Loop3DView") + kwargs.pop("shape") super().__init__(*args, **kwargs) self.set_background(background) self.model = model self.objects = {} def subplot(self, *args, **kwargs): - logger.warning('subplot is not supported in Loop3DView') + logger.warning("subplot is not supported in Loop3DView") return self def add_mesh(self, *args, **kwargs): - if 'name' not in kwargs or kwargs['name'] is None: - name = 'unnamed_object' - kwargs['name'] = name + if "name" not in kwargs or kwargs["name"] is None: + name = "unnamed_object" + kwargs["name"] = name logger.warning( - f'No name provided, using {name}. Pass name argument to add_mesh to remove this error' + f"No name provided, using {name}. Pass name argument to add_mesh to remove this error" ) - kwargs['name'] = kwargs['name'].replace(' ', '_') - kwargs['name'] = re.sub(r'[^a-zA-Z0-9_$]', '_', kwargs['name']) - if kwargs['name'][0].isdigit(): - kwargs['name'] = 'ls_' + kwargs['name'] - if kwargs['name'][0] == '_': - kwargs['name'] = 'ls' + kwargs['name'] - kwargs['name'] = self.increment_name(kwargs['name']) - if '__opacity' in kwargs['name']: - raise ValueError('Cannot use __opacity in name') - if '__visibility' in kwargs['name']: - raise ValueError('Cannot use __visibility in name') - if '__control_visibility' in kwargs['name']: - raise ValueError('Cannot use __control_visibility in name') + kwargs["name"] = kwargs["name"] or "unnamed_object" + kwargs["name"] = kwargs["name"].replace(" ", "_") + kwargs["name"] = re.sub(r"[^a-zA-Z0-9_$]", "_", kwargs["name"]) + if kwargs["name"][0].isdigit(): + kwargs["name"] = "ls_" + kwargs["name"] + if kwargs["name"][0] == "_": + kwargs["name"] = "ls" + kwargs["name"] + kwargs["name"] = self.increment_name(kwargs["name"]) + if "__opacity" in kwargs["name"]: + raise ValueError("Cannot use __opacity in name") + if "__visibility" in kwargs["name"]: + raise ValueError("Cannot use __visibility in name") + if "__control_visibility" in kwargs["name"]: + raise ValueError("Cannot use __control_visibility in name") return super().add_mesh(*args, **kwargs) def increment_name(self, name): - parts = name.split('_') + parts = name.split("_") if len(parts) == 1: - name = name + '_1' + name = name + "_1" while name in self.actors: - parts = name.split('_') + parts = name.split("_") try: parts[-1] = str(int(parts[-1]) + 1) except ValueError: - parts.append('1') - name = '_'.join(parts) + parts.append("1") + name = "_".join(parts) return name def _check_model(self, model: GeologicalModel) -> GeologicalModel: @@ -107,7 +109,7 @@ def plot_surface( opacity: Optional[float] = None, vmin: Optional[float] = None, vmax: Optional[float] = None, - pyvista_kwargs: dict = {}, + pyvista_kwargs: Optional[dict] = None, show_scalar_bar: bool = False, slicer: bool = False, name: Optional[str] = None, @@ -143,8 +145,10 @@ def plot_surface( Whether to show the scalar bar, by default False """ + pyvista_kwargs = dict(pyvista_kwargs or {}) + pyvista_kwargs.setdefault("show_scalar_bar", show_scalar_bar) if name is None: - name = geological_feature.name + '_surfaces' + name = geological_feature.name + "_surfaces" name = self.increment_name(name) # , 'surface') surfaces = geological_feature.surfaces(value, bounding_box=bounding_box) @@ -158,18 +162,17 @@ def plot_surface( if vmax is not None: clim[1] = vmax pyvista_kwargs["clim"] = clim - pts = np.copy(surface.vertices) - if self.model is not None: - pts = self.model.scale(pts) - scalars = paint_with(pts) + scalars = paint_with.evaluate_value(surface.vertices) s["values"] = scalars s.set_active_scalars("values") colour = None meshes.append(s) + if not meshes: + logger.warning("No surfaces to plot") + return None mesh = pv.MultiBlock(meshes).combine() actor = None try: - if slicer: actor = self.add_mesh_clip_plane( mesh, @@ -191,8 +194,6 @@ def plot_surface( except ValueError: logger.warning("No surfaces to plot") - if paint_with is not None and not show_scalar_bar: - self.remove_scalar_bar('values') return actor def plot_scalar_field( @@ -202,7 +203,7 @@ def plot_scalar_field( vmin: Optional[float] = None, vmax: Optional[float] = None, opacity: Optional[float] = None, - pyvista_kwargs: dict = {}, + pyvista_kwargs: Optional[dict] = None, show_scalar_bar: bool = False, slicer: bool = False, name: Optional[str] = None, @@ -239,30 +240,34 @@ def plot_scalar_field( a reference to the actor that is added to the mesh """ + pyvista_kwargs = dict(pyvista_kwargs or {}) + pyvista_kwargs.setdefault("show_scalar_bar", show_scalar_bar) if name is None: - name = geological_feature.name + '_scalar_field' + name = geological_feature.name + "_scalar_field" name = self.increment_name(name) # , 'scalar_field') volume = geological_feature.scalar_field(bounding_box=bounding_box).vtk() - if vmin is not None: - pyvista_kwargs["clim"][0] = vmin - if vmax is not None: - pyvista_kwargs["clim"][1] = vmax + pyvista_kwargs.setdefault("scalars", geological_feature.name) + if vmin is not None or vmax is not None: + clim = list(pyvista_kwargs.get("clim", volume.get_data_range(geological_feature.name))) + if vmin is not None: + clim[0] = vmin + if vmax is not None: + clim[1] = vmax + pyvista_kwargs["clim"] = clim if slicer: actor = self.add_mesh_clip_plane( volume, cmap=cmap, opacity=opacity, name=name, **pyvista_kwargs ) else: actor = self.add_mesh(volume, cmap=cmap, opacity=opacity, name=name, **pyvista_kwargs) - if not show_scalar_bar: - self.remove_scalar_bar(geological_feature.name) return actor def plot_block_model( self, cmap=None, model=None, - pyvista_kwargs={}, + pyvista_kwargs=None, show_scalar_bar: bool = False, slicer: bool = False, threshold: Optional[Union[float, List[float]]] = None, @@ -289,20 +294,22 @@ def plot_block_model( threshold : Optional[Union[float, List[float]]], optional Whether to threshold values of the stratigraphy. Uses same syntax as pyvista threshold., by default None """ + pyvista_kwargs = dict(pyvista_kwargs or {}) + pyvista_kwargs.setdefault("show_scalar_bar", show_scalar_bar) model = self._check_model(model) if name is None: - name = 'block_model' + name = "block_model" name = self.increment_name(name) # , 'block_model') block, codes = model.get_block_model() block = block.vtk() - block.set_active_scalars('stratigraphy') + block.set_active_scalars("stratigraphy") actor = None if cmap is None: - cmap = self._build_stratigraphic_cmap(model) + cmap = model.stratigraphic_column.cmap() if "clim" not in pyvista_kwargs: - pyvista_kwargs["clim"] = (np.min(block['stratigraphy']), np.max(block['stratigraphy'])) + pyvista_kwargs["clim"] = (np.min(block["stratigraphy"]), np.max(block["stratigraphy"])) if threshold is not None: - if isinstance(threshold, float): + if np.isscalar(threshold): block = block.threshold(threshold) elif isinstance(threshold, (list, tuple, np.ndarray)) and len(threshold) == 2: block = block.threshold((threshold[0], threshold[1])) @@ -311,8 +318,6 @@ def plot_block_model( else: actor = self.add_mesh(block, cmap=cmap, name=name, **pyvista_kwargs) - if not show_scalar_bar: - self.remove_scalar_bar('stratigraphy') return actor def plot_fault_displacements( @@ -321,7 +326,7 @@ def plot_fault_displacements( bounding_box: Optional[BoundingBox] = None, model=None, cmap="rainbow", - pyvista_kwargs={}, + pyvista_kwargs=None, show_scalar_bar: bool = False, name: Optional[str] = None, ): @@ -343,8 +348,10 @@ def plot_fault_displacements( show_scalar_bar : bool, optional _description_, by default False """ + pyvista_kwargs = dict(pyvista_kwargs or {}) + pyvista_kwargs.setdefault("show_scalar_bar", show_scalar_bar) if name is None: - name = 'fault_displacement' + name = "fault_displacement" name = self.increment_name(name) # , 'fault_displacement_map') if fault_list is None: model = self._check_model(model) @@ -352,16 +359,13 @@ def plot_fault_displacements( if bounding_box is None: model = self._check_model(model) bounding_box = model.bounding_box - pts = bounding_box.regular_grid() - displacement_value = np.zeros(pts.shape[0]) + volume = bounding_box.vtk() + displacement_value = np.zeros(volume.n_points) for f in fault_list: - disp = f.displacementfeature.evaluate_value(bounding_box.vtk().points) + disp = f.displacementfeature.evaluate_value(volume.points) displacement_value[~np.isnan(disp)] += disp[~np.isnan(disp)] - volume = bounding_box.vtk() - volume['displacement'] = displacement_value - actor = self.add_mesh(volume, cmap=cmap, **pyvista_kwargs) - if not show_scalar_bar: - self.remove_scalar_bar('displacement') + volume["displacement"] = displacement_value + actor = self.add_mesh(volume, cmap=cmap, name=name, **pyvista_kwargs) return actor def plot_model_surfaces( @@ -371,7 +375,7 @@ def plot_model_surfaces( cmap: Optional[str] = None, model: Optional[GeologicalModel] = None, fault_colour: str = "black", - pyvista_kwargs: dict = {}, + pyvista_kwargs: Optional[dict] = None, show_scalar_bar: bool = False, name: Optional[str] = None, ): @@ -401,6 +405,8 @@ def plot_model_surfaces( pv.Actor The actor that is added to the scene """ + pyvista_kwargs = dict(pyvista_kwargs or {}) + pyvista_kwargs.setdefault("show_scalar_bar", show_scalar_bar) model = self._check_model(model) actors = [] @@ -412,27 +418,26 @@ def plot_model_surfaces( for s in surfaces: strati_surfaces.append(s.vtk()) if name is None: - object_name = 'model_surfaces' + object_name = "model_surfaces" else: - object_name = f'{name}_model_surfaces' + object_name = f"{name}_model_surfaces" object_name = self.increment_name(object_name) # , 'model_surfaces') - actors.append( - self.add_mesh( - pv.MultiBlock(strati_surfaces).combine(), - cmap=cmap, - name=object_name, - **pyvista_kwargs, + if strati_surfaces: + actors.append( + self.add_mesh( + pv.MultiBlock(strati_surfaces).combine(), + cmap=cmap, + name=object_name, + **pyvista_kwargs, + ) ) - ) - if not show_scalar_bar: - self.remove_scalar_bar() if faults: fault_list = model.get_fault_surfaces() for f in fault_list: if name is None: - object_name = f'{f.name}_surface' + object_name = f"{f.name}_surface" if name is not None: - object_name = f'{name}_{f.name}_surface' + object_name = f"{name}_{f.name}_surface" object_name = self.increment_name(object_name) # , 'fault_surfaces') actors.append( self.add_mesh(f.vtk(), color=fault_colour, name=object_name, **pyvista_kwargs) @@ -444,11 +449,11 @@ def plot_vector_field( geological_feature: BaseFeature, scale: Optional[float] = None, name: Optional[str] = None, - geom='arrow', + geom="arrow", scalars: Optional[np.ndarray] = None, normalise: bool = False, scale_function: Optional[Callable[[np.ndarray], np.ndarray]] = None, - pyvista_kwargs: dict = {}, + pyvista_kwargs: Optional[dict] = None, bounding_box: Optional[BoundingBox] = None, ) -> pv.Actor: """Plot a vector field @@ -469,8 +474,9 @@ def plot_vector_field( pv.Actor actor that is added to the scene """ + pyvista_kwargs = dict(pyvista_kwargs or {}) if name is None: - name = geological_feature.name + '_vector_field' + name = geological_feature.name + "_vector_field" name = self.increment_name(name) # , 'vector_field') vectorfield = geological_feature.vector_field(bounding_box=bounding_box) scale = self._get_vector_scale(scale) @@ -496,7 +502,7 @@ def plot_data( name: Optional[str] = None, scalars: Optional[np.ndarray] = None, normalise: bool = True, - pyvista_kwargs: dict = {}, + pyvista_kwargs: Optional[dict] = None, ) -> List[pv.Actor]: """Add the data associated with a feature to the plotter @@ -531,7 +537,8 @@ def plot_data( multiplier for this value. If you sent normalise to False the vectors will not be normalised """ - if issubclass(type(feature), BaseFeature): + pyvista_kwargs = dict(pyvista_kwargs or {}) + if isinstance(feature, BaseFeature) and not isinstance(feature, StructuralFrame): feature = [feature] logger.info(f"Scale vectors by {scale}") scale = self._get_vector_scale(scale) @@ -539,13 +546,13 @@ def plot_data( actors = [] bb = self.model.bounding_box if self.model is not None else None for f in feature: - for d in f.get_data(): + for d in f.get_data() or []: if isinstance(d, ValuePoints): if value: if name is None: - object_name = d.name + '_values' + object_name = d.name + "_values" else: - object_name = f'{d.name}_values_{name}' + object_name = f"{d.name}_values_{name}" object_name = self.increment_name(object_name) # , 'values') actors.append( self.add_mesh( @@ -555,9 +562,9 @@ def plot_data( if isinstance(d, VectorPoints): if vector: if name is None: - object_name = d.name + '_vectors' + object_name = d.name + "_vectors" else: - object_name = f'{d.name}_vectors_{name}' + object_name = f"{d.name}_vectors_{name}" object_name = self.increment_name(object_name) # , 'vectors') actors.append( self.add_mesh( @@ -569,13 +576,13 @@ def plot_data( tolerance=None, normalise=normalise, ), - name=name, + name=object_name, **pyvista_kwargs, ) ) return actors - def plot_fold(self, folded_feature: BaseFeature, pyvista_kwargs={}): + def plot_fold(self, folded_feature: BaseFeature, pyvista_kwargs=None): # folded_feature. pass @@ -590,7 +597,7 @@ def plot_fault( vector_scale: Optional[Union[float, int]] = None, name: Optional[str] = None, geom: str = "arrow", - pyvista_kwargs: dict = {}, + pyvista_kwargs: Optional[dict] = None, bounding_box: Optional[BoundingBox] = None, ) -> List[pv.Actor]: """Plot a fault including the surface, slip vector and displacement volume @@ -619,20 +626,21 @@ def plot_fault( List[pv.Actor] list of actors added to the plot """ + pyvista_kwargs = dict(pyvista_kwargs or {}) actors = [] if surface: if name is None: - surface_name = fault.name + '_surface' + surface_name = fault.name + "_surface" else: - surface_name = f'{fault.name}_surface_{name}' + surface_name = f"{fault.name}_surface_{name}" surface_name = self.increment_name(surface_name) surf = fault.surfaces([0], bounding_box=bounding_box)[0] actors.append(self.add_mesh(surf.vtk(), name=surface_name, **pyvista_kwargs)) if slip_vector: if name is None: - vector_name = fault.name + '_vector' + vector_name = fault.name + "_vector" else: - vector_name = f'{fault.name}_vector_{name}' + vector_name = f"{fault.name}_vector_{name}" vector_name = self.increment_name(vector_name) vectorfield = fault.vector_field(bounding_box=bounding_box) @@ -646,9 +654,9 @@ def plot_fault( ) if fault_volume: if name is None: - volume_name = fault.name + '_volume' + volume_name = fault.name + "_volume" else: - volume_name = f'{fault.name}_volume_{name}' + volume_name = f"{fault.name}_volume_{name}" volume = fault.displacementfeature.scalar_field(bounding_box=bounding_box) @@ -666,7 +674,7 @@ def plot_fault( return actors def plot_fault_ellipsoid( - self, fault: FaultSegment, name: Optional[str] = None, pyvista_kwargs: dict = {} + self, fault: FaultSegment, name: Optional[str] = None, pyvista_kwargs: Optional[dict] = None ) -> pv.Actor: """Plot the fault ellipsoid @@ -684,8 +692,9 @@ def plot_fault_ellipsoid( pv.Actor actor added to the plot """ + pyvista_kwargs = dict(pyvista_kwargs or {}) if name is None: - name = fault.name + '_ellipsoid' + name = fault.name + "_ellipsoid" name = self.increment_name(name) ellipsoid = fault.fault_ellipsoid() return self.add_mesh(ellipsoid, name=name, **pyvista_kwargs) diff --git a/packages/loopstructural_visualisation/src/loopstructuralvisualisation/__init__.py b/packages/loopstructural_visualisation/src/loopstructuralvisualisation/__init__.py index cb83b3b04..3faaccfa2 100644 --- a/packages/loopstructural_visualisation/src/loopstructuralvisualisation/__init__.py +++ b/packages/loopstructural_visualisation/src/loopstructuralvisualisation/__init__.py @@ -8,6 +8,6 @@ logger = getLogger(__name__) try: - from ._register_loop_ui import * # this replaces default pyvista trame ui + from . import _register_loop_ui # noqa: F401 # registers the optional notebook UI except ImportError: logger.warning("Could not import trame ui") diff --git a/packages/loopstructural_visualisation/src/loopstructuralvisualisation/_register_loop_ui.py b/packages/loopstructural_visualisation/src/loopstructuralvisualisation/_register_loop_ui.py index 8ec129b4a..48a7baedf 100644 --- a/packages/loopstructural_visualisation/src/loopstructuralvisualisation/_register_loop_ui.py +++ b/packages/loopstructural_visualisation/src/loopstructuralvisualisation/_register_loop_ui.py @@ -1,5 +1,8 @@ -import pyvista +try: + from trame_pyvista import jupyter +except ImportError: + from pyvista.trame import jupyter from .trame import initialize -pyvista.trame.jupyter.initialize = initialize +jupyter.initialize = initialize diff --git a/packages/loopstructural_visualisation/src/loopstructuralvisualisation/_stratigraphic_column.py b/packages/loopstructural_visualisation/src/loopstructuralvisualisation/_stratigraphic_column.py index 9dca3483c..20fc54804 100644 --- a/packages/loopstructural_visualisation/src/loopstructuralvisualisation/_stratigraphic_column.py +++ b/packages/loopstructural_visualisation/src/loopstructuralvisualisation/_stratigraphic_column.py @@ -1,6 +1,6 @@ import numpy as np import matplotlib.pyplot as plt -from matplotlib import cm +from matplotlib import colormaps from matplotlib.patches import Polygon from matplotlib.collections import PatchCollection from LoopStructural.utils import rng @@ -66,7 +66,7 @@ def plot(self): boundaries.append(u.id) # print(u,v) cmap = colors.ListedColormap(colours) else: - cmap = cm.get_cmap(self.cmap, n_units - 1) + cmap = colormaps.get_cmap(self.cmap).resampled(max(n_units, 1)) p = PatchCollection(patches, cmap=cmap) colors = np.arange(len(patches)) diff --git a/packages/loopstructural_visualisation/src/loopstructuralvisualisation/api.py b/packages/loopstructural_visualisation/src/loopstructuralvisualisation/api.py index 035859c72..f048836c1 100644 --- a/packages/loopstructural_visualisation/src/loopstructuralvisualisation/api.py +++ b/packages/loopstructural_visualisation/src/loopstructuralvisualisation/api.py @@ -15,9 +15,7 @@ def plot_block_model(model, filename=None, **kwargs): p = Loop3DView(model) p.plot_block_model(**kwargs) - if filename is not None: - p.screenshot(filename) - p.show() + p.show(screenshot=filename) def plot_surface(model, geological_feature, **kwargs): diff --git a/packages/loopstructural_visualisation/src/loopstructuralvisualisation/trame/__init__.py b/packages/loopstructural_visualisation/src/loopstructuralvisualisation/trame/__init__.py index 8cf564742..e4ee27f88 100644 --- a/packages/loopstructural_visualisation/src/loopstructuralvisualisation/trame/__init__.py +++ b/packages/loopstructural_visualisation/src/loopstructuralvisualisation/trame/__init__.py @@ -1,5 +1,7 @@ -from pyvista.trame.ui import UI_TITLE -from pyvista.trame.ui import get_viewer +try: + from trame_pyvista.ui import UI_TITLE, get_viewer +except ImportError: + from pyvista.trame.ui import UI_TITLE, get_viewer from .ui.vuetify3 import LoopViewer as Viewer from .. import Loop3DView diff --git a/packages/loopstructural_visualisation/src/loopstructuralvisualisation/trame/ui/vuetify3.py b/packages/loopstructural_visualisation/src/loopstructuralvisualisation/trame/ui/vuetify3.py index c9511ad35..ac0a5cd86 100644 --- a/packages/loopstructural_visualisation/src/loopstructuralvisualisation/trame/ui/vuetify3.py +++ b/packages/loopstructural_visualisation/src/loopstructuralvisualisation/trame/ui/vuetify3.py @@ -14,7 +14,10 @@ from trame.widgets import vuetify3 as vuetify import pyvista -from pyvista.trame.ui.vuetify3 import Viewer +try: + from trame_pyvista.ui.vuetify3 import Viewer +except ImportError: + from pyvista.trame.ui.vuetify3 import Viewer if TYPE_CHECKING: # pragma: no cover diff --git a/packages/loopstructural_visualisation/tests/test_import.py b/packages/loopstructural_visualisation/tests/test_import.py index 2477cbf93..f0fd353d8 100644 --- a/packages/loopstructural_visualisation/tests/test_import.py +++ b/packages/loopstructural_visualisation/tests/test_import.py @@ -1,11 +1,4 @@ -"""Import smoke test. - -Upstream loopstructural-visualisation ships with no test suite at all; this -is a minimal placeholder so the package has real CI coverage (packages.yml -runs `pytest packages//tests`, which needs a tests dir to exist) -rather than skipping it entirely. Extend with real behavioral tests as a -follow-up. -""" +"""Public import smoke tests; plotting and notebook behavior have separate suites.""" import loopstructuralvisualisation diff --git a/packages/loopstructural_visualisation/tests/test_plotting.py b/packages/loopstructural_visualisation/tests/test_plotting.py new file mode 100644 index 000000000..d90ec1917 --- /dev/null +++ b/packages/loopstructural_visualisation/tests/test_plotting.py @@ -0,0 +1,192 @@ +"""Exercise real VTK pipelines against the workspace LoopStructural API.""" + +from types import SimpleNamespace + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt +import numpy as np +import pytest +import pyvista as pv +from loopstructuralvisualisation import Loop2DView, Loop3DView, StratigraphicColumnView + +from LoopStructural import GeologicalModel +from LoopStructural.geometry import ValuePoints, VectorPoints +from LoopStructural.modelling.features import AnalyticalGeologicalFeature, StructuralFrame +from LoopStructural.modelling.features.fault import FaultSegment + + +@pytest.fixture +def model(): + model = GeologicalModel([10, 20, 30], [14, 26, 38]) + model.bounding_box.nsteps = np.array([5, 6, 7]) + feature = AnalyticalGeologicalFeature("strati", [0, 0, 1], [10, 20, 30], model=model) + model["strati"] = feature + column = model.stratigraphic_column + column.clear(basement=False) + column.add_unit("lower", thickness=4, colour="red") + column.add_unit("upper", thickness=4, colour="blue") + column.group_mapping["Group_0"] = "strati" + return model + + +@pytest.fixture +def view(model): + plotter = Loop3DView(model, off_screen=True, window_size=(160, 120)) + yield plotter + plotter.close() + + +@pytest.mark.parametrize("slicer", [False, True]) +def test_scalar_field_limits_and_scalar_bars(view, model, slicer): + options = {"clim": (0, 8)} + actor = view.plot_scalar_field( + model["strati"], vmin=1, vmax=7, slicer=slicer, pyvista_kwargs=options + ) + assert isinstance(actor, pv.Actor) + assert actor.mapper.scalar_range == (1, 7) + assert options == {"clim": (0, 8)} + assert len(view.scalar_bars) == 0 + view.plot_scalar_field(model["strati"], show_scalar_bar=True) + assert len(view.scalar_bars) == 1 + view.plot_scalar_field(model["strati"]) + assert len(view.scalar_bars) == 1 # Preserve an existing bar. + + +def test_scalar_field_limits_without_options(view, model): + actor = view.plot_scalar_field(model["strati"], vmin=1, vmax=7) + assert actor.mapper.scalar_range == (1, 7) + + +@pytest.mark.parametrize("slicer", [False, True]) +def test_surface_paint_uses_world_coordinates(view, model, slicer): + paint = AnalyticalGeologicalFeature("paint", [1, 0, 0], [10, 20, 30], model=model) + options = {} + actor = view.plot_surface( + model["strati"], value=[4.0], paint_with=paint, slicer=slicer, pyvista_kwargs=options + ) + assert isinstance(actor, pv.Actor) + mesh = actor.mapper.dataset + np.testing.assert_allclose(mesh["values"], mesh.points[:, 0] - 10, atol=1e-6) + assert options == {} + assert len(view.scalar_bars) == 0 + + +def test_empty_surface(view, model): + assert view.plot_surface(model["strati"], value=[100.0]) is None + + +@pytest.mark.parametrize("slicer", [False, True]) +def test_block_model(view, model, slicer): + options = {} + actor = view.plot_block_model(slicer=slicer, pyvista_kwargs=options) + assert isinstance(actor, pv.Actor) + assert options == {} + assert len(view.scalar_bars) == 0 + assert {0, 1}.issubset(np.unique(actor.mapper.dataset["stratigraphy"])) + + +def test_model_surfaces_and_vectors(view, model): + actors = view.plot_model_surfaces(faults=False) + assert actors and all(isinstance(a, pv.Actor) for a in actors) + assert len(view.scalar_bars) == 0 + actor = view.plot_vector_field(model["strati"], normalise=True) + assert actor.mapper.dataset.n_points > 0 + + +def test_plot_data_names_and_frame(view, model, monkeypatch): + feature = model["strati"] + points = np.array([[11.0, 21.0, 31.0], [12.0, 22.0, 32.0]]) + data = [ + ValuePoints(points, np.array([1.0, 2.0]), name="observations"), + VectorPoints(points, np.array([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0]]), name="normals"), + ] + monkeypatch.setattr(feature, "get_data", lambda: data) + frame = StructuralFrame("frame", [feature], model=model) + actors = view.plot_data(frame) + assert len(actors) == 2 + assert "observations_values" in view.actors + assert "normals_vectors" in view.actors + + +def test_offscreen_render(view, model): + view.plot_surface(model["strati"], value=[4.0]) + image = view.screenshot(return_img=True) + assert image.shape[:2] == (120, 160) + assert np.any(image != image[0, 0]) + + +def test_block_model_api_screenshot(model, tmp_path, monkeypatch): + from loopstructuralvisualisation.api import plot_block_model + + monkeypatch.setattr(pv, 'OFF_SCREEN', True) + filename = tmp_path / 'block-model.png' + plot_block_model(model, filename=filename) + assert filename.is_file() + assert filename.stat().st_size > 0 + + +def test_fault_plotting(view, model): + coordinates = [ + AnalyticalGeologicalFeature(f"fault_{i}", direction, [12, 23, 34], model=model) + for i, direction in enumerate(np.eye(3)) + ] + fault = FaultSegment("fault", coordinates, model=model) + actors = view.plot_fault(fault) + assert len(actors) == 3 + assert all(isinstance(actor, pv.Actor) for actor in actors) + actor = view.plot_fault_displacements(fault_list=[fault], name="test_displacement") + assert "test_displacement" in view.actors + mesh = actor.mapper.dataset + np.testing.assert_allclose( + mesh["displacement"], fault.displacementfeature.evaluate_value(mesh.points) + ) + + +def test_map_uses_world_coordinates_and_column(model): + view = Loop2DView(model) + feature = AnalyticalGeologicalFeature("x", [1, 0, 0], [10, 20, 30], model=model) + try: + image = view.add_scalar_field(feature, z=34) + np.testing.assert_allclose(image.get_array(), (view.xx - 10).reshape(view.nsteps).T) + assert view.add_contour(feature, [2.0], z=34) is not None + assert view.add_model(z=32) is not None + finally: + plt.close(view.ax.figure) + + +def test_stratigraphic_column_custom_cmap(model): + view = StratigraphicColumnView(model, cmap="viridis") + try: + assert view.plot() is not None + assert len(view.ax.collections) == 1 + finally: + plt.close(view.ax.figure) + + +def test_map_data_and_fault_ellipses(model): + feature = model["strati"] + empty = np.empty((0, 7)) + feature.builder = SimpleNamespace( + get_value_constraints=lambda: np.array([[11.0, 22.0, 33.0, 3.0]]), + get_gradient_constraints=lambda: np.array([[11.0, 22.0, 33.0, 1.0, 1.0, 0.0, 1.0]]), + get_norm_constraints=lambda: empty, + ) + fault = SimpleNamespace( + fault_centre=np.array([12.0, 23.0, 34.0]), + fault_strike_vector=np.array([0.0, 1.0, 0.0]), + fault_major_axis=2.0, + fault_minor_axis=1.0, + ) + view = Loop2DView(model) + try: + view.add_data(feature, dip=False) + np.testing.assert_allclose(view.ax.collections[0].get_offsets(), [[11.0, 22.0]]) + view.add_fault_ellipse(faults=[fault, fault], edgecolor="red") + assert len(view.ax.patches) == 2 + assert view.ax.patches[0].center == (12.0, 23.0) + assert view.ax.patches[0].angle == 90 + finally: + plt.close(view.ax.figure) diff --git a/packages/loopstructural_visualisation/tests/test_trame.py b/packages/loopstructural_visualisation/tests/test_trame.py new file mode 100644 index 000000000..ff816d444 --- /dev/null +++ b/packages/loopstructural_visualisation/tests/test_trame.py @@ -0,0 +1,31 @@ +"""Optional notebook integration, without launching a web server.""" + +import pytest +import pyvista as pv + +pytest.importorskip("trame") +pytest.importorskip("trame.widgets.vuetify3") + +from loopstructuralvisualisation import Loop3DView +from loopstructuralvisualisation.trame import initialize +from loopstructuralvisualisation.trame.ui.vuetify3 import LoopViewer +from trame.app import get_server + + +@pytest.mark.parametrize("plotter_class", [Loop3DView, pv.Plotter]) +def test_notebook_initialization(plotter_class): + plotter = plotter_class(off_screen=True) + try: + plotter.add_mesh(pv.Sphere(), name="sphere") + server = get_server(f"compat_{plotter._id_name}", client_type="vue3") + viewer = initialize(server, plotter, mode="client") + assert viewer.layout is not None + if plotter_class is Loop3DView: + assert isinstance(viewer, LoopViewer) + name = next(k for k, a in plotter.actors.items() if isinstance(a, pv.Actor)) + viewer.toggle_visibility(**{f"{name}__visibility": False}) + assert not plotter.actors[name].visibility + viewer.set_opacity(**{f"{name}__opacity": 0.5}) + assert plotter.actors[name].prop.opacity == 0.5 + finally: + plotter.close() From dc0c96e3cac1312ae606a8decf366a978855b969 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 20 Oct 2026 13:39:03 +1030 Subject: [PATCH 2/3] test(visualisation): skip rendering tests on Windows CI without OpenGL --- .../tests/test_plotting.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/loopstructural_visualisation/tests/test_plotting.py b/packages/loopstructural_visualisation/tests/test_plotting.py index d90ec1917..70d13b0c3 100644 --- a/packages/loopstructural_visualisation/tests/test_plotting.py +++ b/packages/loopstructural_visualisation/tests/test_plotting.py @@ -1,5 +1,7 @@ """Exercise real VTK pipelines against the workspace LoopStructural API.""" +import os +import sys from types import SimpleNamespace import matplotlib @@ -18,6 +20,13 @@ from LoopStructural.modelling.features.fault import FaultSegment +# Windows CI runners have no OpenGL context; VTK then crashes with an access +# violation, which cannot be caught, so rendering tests are skipped there. +requires_gl = pytest.mark.skipif( + sys.platform == "win32" and os.environ.get("CI") == "true", + reason="no OpenGL context on Windows CI runners", +) + @pytest.fixture def model(): model = GeologicalModel([10, 20, 30], [14, 26, 38]) @@ -111,6 +120,7 @@ def test_plot_data_names_and_frame(view, model, monkeypatch): assert "normals_vectors" in view.actors +@requires_gl def test_offscreen_render(view, model): view.plot_surface(model["strati"], value=[4.0]) image = view.screenshot(return_img=True) @@ -118,6 +128,7 @@ def test_offscreen_render(view, model): assert np.any(image != image[0, 0]) +@requires_gl def test_block_model_api_screenshot(model, tmp_path, monkeypatch): from loopstructuralvisualisation.api import plot_block_model From 7ea2190c6de20b3bdf5842e241842ebdc1d4bd78 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 20 Oct 2026 13:49:22 +1030 Subject: [PATCH 3/3] test(visualisation): skip rendering tests on all CI platforms --- .../loopstructural_visualisation/tests/test_plotting.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/loopstructural_visualisation/tests/test_plotting.py b/packages/loopstructural_visualisation/tests/test_plotting.py index 70d13b0c3..5f7855ee8 100644 --- a/packages/loopstructural_visualisation/tests/test_plotting.py +++ b/packages/loopstructural_visualisation/tests/test_plotting.py @@ -1,7 +1,6 @@ """Exercise real VTK pipelines against the workspace LoopStructural API.""" import os -import sys from types import SimpleNamespace import matplotlib @@ -20,11 +19,11 @@ from LoopStructural.modelling.features.fault import FaultSegment -# Windows CI runners have no OpenGL context; VTK then crashes with an access -# violation, which cannot be caught, so rendering tests are skipped there. +# CI runners have no display or OpenGL context; VTK then crashes with a segfault +# or access violation, which cannot be caught, so rendering tests are skipped there. requires_gl = pytest.mark.skipif( - sys.platform == "win32" and os.environ.get("CI") == "true", - reason="no OpenGL context on Windows CI runners", + os.environ.get("CI") == "true", + reason="no OpenGL context on CI runners", ) @pytest.fixture