Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions loopstructural/gui/visualisation/cross_section_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,30 @@ def build_line_extrusion_mesh(
yy = np.tile(xy[:, 1][:, None], (1, z_resolution))
zz = np.tile(z[None, :], (resolution, 1))
return pv.StructuredGrid(xx, yy, zz)


def build_block_model_mesh(origin, maximum, ncells) -> pv.ImageData:
"""Build a regular voxel grid (block model) that fills a bounding box.

Parameters
----------
origin, maximum : array_like
(3,) opposite corners of the box, in the model's coordinate system.
ncells : array_like
(3,) number of cells (blocks) along x, y and z.

Returns
-------
pv.ImageData
A grid with `prod(ncells)` cells. Callers evaluate the model at
`mesh.cell_centers().points` and store the result as cell data.
"""
origin = np.asarray(origin, dtype=float)
maximum = np.asarray(maximum, dtype=float)
ncells = np.maximum(np.asarray(ncells, dtype=int), 1)
spacing = (maximum - origin) / ncells
return pv.ImageData(
dimensions=tuple(int(n) for n in ncells + 1),
spacing=tuple(float(s) for s in spacing),
origin=tuple(float(o) for o in origin),
)
821 changes: 555 additions & 266 deletions loopstructural/gui/visualisation/feature_list_widget.py

Large diffs are not rendered by default.

119 changes: 118 additions & 1 deletion loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
from pyvistaqt import QtInteractor
from qgis.PyQt.QtCore import pyqtSignal

from .mesh_scalar_utils import threshold_mesh


class LoopPyVistaQTPlotter(QtInteractor):
objectAdded = pyqtSignal(QtInteractor) # Signal to request deletion
# emitted when objects are marked out of date, or brought up to date
outOfDateChanged = pyqtSignal()

def __init__(self, parent):
super().__init__(parent=parent)
Expand Down Expand Up @@ -42,6 +46,9 @@ def add_mesh_object(
source_feature: Optional[str] = None,
source_type: Optional[str] = None,
isovalue: Optional[float] = None,
metadata: Optional[Dict[str, Any]] = None,
out_of_date: bool = False,
threshold: Optional[Dict[str, Any]] = None,
**kwargs,
) -> None:
"""Add a mesh to the plotter.
Expand Down Expand Up @@ -73,6 +80,16 @@ def add_mesh_object(
source_type : Optional[str]
A short tag describing the kind of source (e.g. 'feature_surface',
'fault_surface', 'bounding_box').
metadata : Optional[dict]
Extra values needed to build the mesh again from the model (for
example the number of blocks of a block model).
out_of_date : bool
True if the mesh no longer matches the model.
threshold : Optional[dict]
Show only the part of the mesh whose values are in a range (see
`mesh_scalar_utils.threshold_mesh`). The full mesh is still
stored, so the filter can be changed or removed later. Raises
ValueError if no cells are in the range.

Returns
-------
Expand Down Expand Up @@ -113,8 +130,14 @@ def add_mesh_object(
# merge any extra kwargs (allow caller to override default choices)
add_kwargs.update(kwargs)

display_mesh = mesh
if threshold:
display_mesh = threshold_mesh(mesh, threshold)
if display_mesh.n_cells == 0:
raise ValueError("No cells are in the filter range")

# attempt to add to the underlying pyvista plotter
actor = self.add_mesh(mesh, name=name, **add_kwargs)
actor = self.add_mesh(display_mesh, name=name, **add_kwargs)

# store the mesh, actor and kwargs for future re-adds
# persist source metadata so callers can find meshes created from model features
Expand All @@ -125,9 +148,101 @@ def add_mesh_object(
'source_feature': source_feature,
'source_type': source_type,
'isovalue': isovalue,
'metadata': dict(metadata or {}),
'out_of_date': out_of_date,
'threshold': dict(threshold) if threshold else None,
# the mesh shown in the viewer (the filtered part of `mesh`)
'display_mesh': display_mesh,
}
self.objectAdded.emit(self)

def get_source_metadata(self, name: str) -> Dict[str, Any]:
"""Return the source values of an object as keyword arguments for
`add_mesh_object`, so that an object removed and added again (for
example to change its colour map) can still be built again from the
model.
"""
entry = self.meshes.get(name)
if not entry:
return {}
return {
'source_feature': entry.get('source_feature'),
'source_type': entry.get('source_type'),
'isovalue': entry.get('isovalue'),
'metadata': entry.get('metadata'),
'out_of_date': bool(entry.get('out_of_date', False)),
'threshold': entry.get('threshold'),
}

def replace_mesh_object(self, name: str, mesh=None, overrides=None, **source_updates) -> None:
"""Add the object `name` again, and keep its source values, viewer
settings (colour map, opacity, colour picked by the user, ...) and
visibility.

Parameters
----------
name : str
Name of an object in the viewer.
mesh : optional
A new mesh for the object (e.g. built again from the model). If
None, the current mesh is used.
overrides : Optional[dict]
Viewer settings that replace the stored ones (e.g. a new unit
colour). A colour picked by the user still has priority.
**source_updates
Source values to change, e.g. `threshold=...` or
`out_of_date=False`.

pyvista replaces the actor that has the same name, so if the new
object cannot be added, the old object stays and the error is raised.
"""
entry = self.meshes[name]
if mesh is None:
mesh = entry['mesh']
source = self.get_source_metadata(name)
source.update(source_updates)
kwargs = {
key: value
for key, value in (entry.get('kwargs') or {}).items()
if key not in source and key != 'name'
}
kwargs.update(overrides or {})
user_colour = entry.get('color')
if user_colour is not None:
kwargs['color'] = user_colour
actor = entry.get('actor')
visible = bool(getattr(actor, 'visibility', True))

try:
self.add_mesh_object(mesh, name=name, **source, **kwargs)
except Exception:
# e.g. a scalar array selected in the properties panel that the
# new mesh does not have; add it with the default colouring
for key in ('scalars', 'cmap', 'clim', 'rgb'):
kwargs.pop(key, None)
self.add_mesh_object(mesh, name=name, **source, **kwargs)

new_entry = self.meshes[name]
if user_colour is not None:
new_entry['color'] = user_colour
if not visible and new_entry.get('actor') is not None:
new_entry['actor'].visibility = False

def set_out_of_date(self, names, out_of_date: bool = True) -> None:
"""Mark the named objects as out of date (or up to date)."""
changed = False
for name in names:
entry = self.meshes.get(name)
if entry is not None and bool(entry.get('out_of_date')) != out_of_date:
entry['out_of_date'] = out_of_date
changed = True
if changed:
self.outOfDateChanged.emit()

def out_of_date_objects(self):
"""Return the names of the objects that are out of date."""
return [name for name, entry in self.meshes.items() if entry.get('out_of_date')]

def remove_object(self, name: str) -> None:
"""Remove an object by name and clean up stored metadata.

Expand Down Expand Up @@ -157,6 +272,8 @@ def remove_object(self, name: str) -> None:
del self.meshes[name]
except Exception:
pass
if entry.get('out_of_date'):
self.outOfDateChanged.emit()

def set_object_visibility(self, name: str, visibility):
"""Change the visibility of an object."""
Expand Down
65 changes: 65 additions & 0 deletions loopstructural/gui/visualisation/mesh_scalar_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,68 @@ def apply_colormap_lut(mapper, cmap, clim=None, nan_color=(0.6, 0.6, 0.6, 1.0)):
pass
except Exception:
pass


def filter_array_names(mesh):
"""Return the names of the arrays of `mesh` that a threshold filter can
use: the single-component point arrays, and the single-component cell
arrays as `cell:<name>` (the same naming as `get_scalar_values`).
"""
names = []
for prefix, data in (('', 'point_data'), ('cell:', 'cell_data')):
arrays = getattr(mesh, data, None) or {}
for key in sorted(arrays.keys()):
values = np.asarray(arrays[key])
if values.ndim == 1 and np.issubdtype(values.dtype, np.number):
names.append(f"{prefix}{key}")
return names


def threshold_mesh(mesh, threshold):
"""Return the part of `mesh` whose values pass a filter.

`threshold` is a dict with `scalars` (the array name, `cell:<name>` for
a cell array) and one of:

- `min`, `max`: keep the values in this range (the limits are
included); with `invert`, keep the values outside the range
- `values`: keep only these values (e.g. the ids of some units)

For a point array, a cell is kept only if all its points pass. Raises
KeyError if the array is not on the mesh.
"""
name = threshold['scalars']
preference = 'point'
if name.startswith('cell:'):
name = name.split(':', 1)[1]
preference = 'cell'
data = mesh.cell_data if preference == 'cell' else mesh.point_data
if name not in data:
raise KeyError(f"The object has no {preference} array '{name}'")

if 'values' in threshold:
# threshold a 0/1 mask on a shallow copy, so the result has the same
# type as a range filter and the mesh of the caller does not change
mask = np.isin(np.asarray(data[name]), list(threshold['values'])).astype(float)
masked = mesh.copy(deep=False)
mask_data = masked.cell_data if preference == 'cell' else masked.point_data
mask_data['_filter_mask'] = mask
result = masked.threshold(
value=(0.5, 1.5),
scalars='_filter_mask',
preference=preference,
all_scalars=preference == 'point',
)
for arrays in (result.cell_data, result.point_data):
if '_filter_mask' in arrays:
del arrays['_filter_mask']
return result

low, high = sorted((float(threshold['min']), float(threshold['max'])))
return mesh.threshold(
value=(low, high),
scalars=name,
preference=preference,
invert=bool(threshold.get('invert', False)),
all_scalars=preference == 'point',
)
12 changes: 11 additions & 1 deletion loopstructural/gui/visualisation/object_list_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def __init__(self, parent=None, *, viewer=None, properties_widget=None):
self.setLayout(self.mainLayout)
self.viewer = viewer
self.viewer.objectAdded.connect(self.update_object_list)
self.viewer.outOfDateChanged.connect(self._on_out_of_date_changed)
self.treeWidget.installEventFilter(self)
self.treeWidget.itemSelectionChanged.connect(self.on_object_selected)
self.treeWidget.itemDoubleClicked.connect(self.onDoubleClick)
Expand Down Expand Up @@ -94,6 +95,9 @@ def update_object_list(self, new_object):
mesh = meshes[mesh_name]
self.add_mesh_item(mesh_name, mesh)

def _on_out_of_date_changed(self):
self.update_object_list(None)

def add_mesh_item(self, mesh_name, mesh):
"""Add a top-level tree item for a mesh and populate children for
point/cell data arrays.
Expand Down Expand Up @@ -139,7 +143,13 @@ def _on_vis(state, name=mesh_name, m=mesh):
itemLayout = QHBoxLayout(itemWidget)
itemLayout.setContentsMargins(0, 0, 0, 0)
itemLayout.addWidget(visibilityCheckbox)
itemLayout.addWidget(QLabel(mesh_name))
nameLabel = QLabel(mesh_name)
if isinstance(mesh, dict) and mesh.get('out_of_date'):
# the label text is the object name used elsewhere, so show the
# state with the style and tooltip only
nameLabel.setStyleSheet("color: gray; font-style: italic;")
nameLabel.setToolTip("Out of date: the model changed after this object was added")
itemLayout.addWidget(nameLabel)
itemWidget.setLayout(itemLayout)

self.treeWidget.setItemWidget(top, 0, itemWidget)
Expand Down
Loading
Loading