Skip to content

Commit 37fe3cc

Browse files
authored
feat: add block model to the visualisation
* feat: add block model to the visualisation Add a Block Model button to the visualisation panel. It fills the model bounding box with blocks, evaluates the stratigraphy at each block centre on a background thread, and colours the blocks by the stratigraphic column. The number of blocks along each axis is set in a dialog and defaults to the model resolution. * feat: update out-of-date viewer objects with a button Model changes no longer rebuild the viewer objects automatically. The objects built from the model are marked out of date (grey italic in the object list), and a new button builds them again from the current model on a background thread. Each object keeps its name, visibility and viewer settings. Cross sections, block models and topography are also updated. Also keep the source information when the properties panel adds an object again, and save opacity changes. * feat: filter viewer objects by value range or by unit Add a Filter (Threshold) section to the object properties panel. For the unit ids of a block model, cross section or coloured topography, it shows a check box for each unit (with its name and colour); other arrays use a value range, with an option to invert it. The full mesh stays stored, so the filter can be changed or cleared, and it stays when the object is updated from the model. The objects coloured by stratigraphy now store the unit names and colours, and the viewer gets replace_mesh_object to add an object again with the same settings.
1 parent 30730f7 commit 37fe3cc

10 files changed

Lines changed: 1170 additions & 278 deletions

‎loopstructural/gui/visualisation/cross_section_utils.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,30 @@ def build_line_extrusion_mesh(
6969
yy = np.tile(xy[:, 1][:, None], (1, z_resolution))
7070
zz = np.tile(z[None, :], (resolution, 1))
7171
return pv.StructuredGrid(xx, yy, zz)
72+
73+
74+
def build_block_model_mesh(origin, maximum, ncells) -> pv.ImageData:
75+
"""Build a regular voxel grid (block model) that fills a bounding box.
76+
77+
Parameters
78+
----------
79+
origin, maximum : array_like
80+
(3,) opposite corners of the box, in the model's coordinate system.
81+
ncells : array_like
82+
(3,) number of cells (blocks) along x, y and z.
83+
84+
Returns
85+
-------
86+
pv.ImageData
87+
A grid with `prod(ncells)` cells. Callers evaluate the model at
88+
`mesh.cell_centers().points` and store the result as cell data.
89+
"""
90+
origin = np.asarray(origin, dtype=float)
91+
maximum = np.asarray(maximum, dtype=float)
92+
ncells = np.maximum(np.asarray(ncells, dtype=int), 1)
93+
spacing = (maximum - origin) / ncells
94+
return pv.ImageData(
95+
dimensions=tuple(int(n) for n in ncells + 1),
96+
spacing=tuple(float(s) for s in spacing),
97+
origin=tuple(float(o) for o in origin),
98+
)

‎loopstructural/gui/visualisation/feature_list_widget.py‎

Lines changed: 555 additions & 266 deletions
Large diffs are not rendered by default.

‎loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py‎

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
from pyvistaqt import QtInteractor
44
from qgis.PyQt.QtCore import pyqtSignal
55

6+
from .mesh_scalar_utils import threshold_mesh
7+
68

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

1014
def __init__(self, parent):
1115
super().__init__(parent=parent)
@@ -42,6 +46,9 @@ def add_mesh_object(
4246
source_feature: Optional[str] = None,
4347
source_type: Optional[str] = None,
4448
isovalue: Optional[float] = None,
49+
metadata: Optional[Dict[str, Any]] = None,
50+
out_of_date: bool = False,
51+
threshold: Optional[Dict[str, Any]] = None,
4552
**kwargs,
4653
) -> None:
4754
"""Add a mesh to the plotter.
@@ -73,6 +80,16 @@ def add_mesh_object(
7380
source_type : Optional[str]
7481
A short tag describing the kind of source (e.g. 'feature_surface',
7582
'fault_surface', 'bounding_box').
83+
metadata : Optional[dict]
84+
Extra values needed to build the mesh again from the model (for
85+
example the number of blocks of a block model).
86+
out_of_date : bool
87+
True if the mesh no longer matches the model.
88+
threshold : Optional[dict]
89+
Show only the part of the mesh whose values are in a range (see
90+
`mesh_scalar_utils.threshold_mesh`). The full mesh is still
91+
stored, so the filter can be changed or removed later. Raises
92+
ValueError if no cells are in the range.
7693
7794
Returns
7895
-------
@@ -113,8 +130,14 @@ def add_mesh_object(
113130
# merge any extra kwargs (allow caller to override default choices)
114131
add_kwargs.update(kwargs)
115132

133+
display_mesh = mesh
134+
if threshold:
135+
display_mesh = threshold_mesh(mesh, threshold)
136+
if display_mesh.n_cells == 0:
137+
raise ValueError("No cells are in the filter range")
138+
116139
# attempt to add to the underlying pyvista plotter
117-
actor = self.add_mesh(mesh, name=name, **add_kwargs)
140+
actor = self.add_mesh(display_mesh, name=name, **add_kwargs)
118141

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

159+
def get_source_metadata(self, name: str) -> Dict[str, Any]:
160+
"""Return the source values of an object as keyword arguments for
161+
`add_mesh_object`, so that an object removed and added again (for
162+
example to change its colour map) can still be built again from the
163+
model.
164+
"""
165+
entry = self.meshes.get(name)
166+
if not entry:
167+
return {}
168+
return {
169+
'source_feature': entry.get('source_feature'),
170+
'source_type': entry.get('source_type'),
171+
'isovalue': entry.get('isovalue'),
172+
'metadata': entry.get('metadata'),
173+
'out_of_date': bool(entry.get('out_of_date', False)),
174+
'threshold': entry.get('threshold'),
175+
}
176+
177+
def replace_mesh_object(self, name: str, mesh=None, overrides=None, **source_updates) -> None:
178+
"""Add the object `name` again, and keep its source values, viewer
179+
settings (colour map, opacity, colour picked by the user, ...) and
180+
visibility.
181+
182+
Parameters
183+
----------
184+
name : str
185+
Name of an object in the viewer.
186+
mesh : optional
187+
A new mesh for the object (e.g. built again from the model). If
188+
None, the current mesh is used.
189+
overrides : Optional[dict]
190+
Viewer settings that replace the stored ones (e.g. a new unit
191+
colour). A colour picked by the user still has priority.
192+
**source_updates
193+
Source values to change, e.g. `threshold=...` or
194+
`out_of_date=False`.
195+
196+
pyvista replaces the actor that has the same name, so if the new
197+
object cannot be added, the old object stays and the error is raised.
198+
"""
199+
entry = self.meshes[name]
200+
if mesh is None:
201+
mesh = entry['mesh']
202+
source = self.get_source_metadata(name)
203+
source.update(source_updates)
204+
kwargs = {
205+
key: value
206+
for key, value in (entry.get('kwargs') or {}).items()
207+
if key not in source and key != 'name'
208+
}
209+
kwargs.update(overrides or {})
210+
user_colour = entry.get('color')
211+
if user_colour is not None:
212+
kwargs['color'] = user_colour
213+
actor = entry.get('actor')
214+
visible = bool(getattr(actor, 'visibility', True))
215+
216+
try:
217+
self.add_mesh_object(mesh, name=name, **source, **kwargs)
218+
except Exception:
219+
# e.g. a scalar array selected in the properties panel that the
220+
# new mesh does not have; add it with the default colouring
221+
for key in ('scalars', 'cmap', 'clim', 'rgb'):
222+
kwargs.pop(key, None)
223+
self.add_mesh_object(mesh, name=name, **source, **kwargs)
224+
225+
new_entry = self.meshes[name]
226+
if user_colour is not None:
227+
new_entry['color'] = user_colour
228+
if not visible and new_entry.get('actor') is not None:
229+
new_entry['actor'].visibility = False
230+
231+
def set_out_of_date(self, names, out_of_date: bool = True) -> None:
232+
"""Mark the named objects as out of date (or up to date)."""
233+
changed = False
234+
for name in names:
235+
entry = self.meshes.get(name)
236+
if entry is not None and bool(entry.get('out_of_date')) != out_of_date:
237+
entry['out_of_date'] = out_of_date
238+
changed = True
239+
if changed:
240+
self.outOfDateChanged.emit()
241+
242+
def out_of_date_objects(self):
243+
"""Return the names of the objects that are out of date."""
244+
return [name for name, entry in self.meshes.items() if entry.get('out_of_date')]
245+
131246
def remove_object(self, name: str) -> None:
132247
"""Remove an object by name and clean up stored metadata.
133248
@@ -157,6 +272,8 @@ def remove_object(self, name: str) -> None:
157272
del self.meshes[name]
158273
except Exception:
159274
pass
275+
if entry.get('out_of_date'):
276+
self.outOfDateChanged.emit()
160277

161278
def set_object_visibility(self, name: str, visibility):
162279
"""Change the visibility of an object."""

‎loopstructural/gui/visualisation/mesh_scalar_utils.py‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,68 @@ def apply_colormap_lut(mapper, cmap, clim=None, nan_color=(0.6, 0.6, 0.6, 1.0)):
176176
pass
177177
except Exception:
178178
pass
179+
180+
181+
def filter_array_names(mesh):
182+
"""Return the names of the arrays of `mesh` that a threshold filter can
183+
use: the single-component point arrays, and the single-component cell
184+
arrays as `cell:<name>` (the same naming as `get_scalar_values`).
185+
"""
186+
names = []
187+
for prefix, data in (('', 'point_data'), ('cell:', 'cell_data')):
188+
arrays = getattr(mesh, data, None) or {}
189+
for key in sorted(arrays.keys()):
190+
values = np.asarray(arrays[key])
191+
if values.ndim == 1 and np.issubdtype(values.dtype, np.number):
192+
names.append(f"{prefix}{key}")
193+
return names
194+
195+
196+
def threshold_mesh(mesh, threshold):
197+
"""Return the part of `mesh` whose values pass a filter.
198+
199+
`threshold` is a dict with `scalars` (the array name, `cell:<name>` for
200+
a cell array) and one of:
201+
202+
- `min`, `max`: keep the values in this range (the limits are
203+
included); with `invert`, keep the values outside the range
204+
- `values`: keep only these values (e.g. the ids of some units)
205+
206+
For a point array, a cell is kept only if all its points pass. Raises
207+
KeyError if the array is not on the mesh.
208+
"""
209+
name = threshold['scalars']
210+
preference = 'point'
211+
if name.startswith('cell:'):
212+
name = name.split(':', 1)[1]
213+
preference = 'cell'
214+
data = mesh.cell_data if preference == 'cell' else mesh.point_data
215+
if name not in data:
216+
raise KeyError(f"The object has no {preference} array '{name}'")
217+
218+
if 'values' in threshold:
219+
# threshold a 0/1 mask on a shallow copy, so the result has the same
220+
# type as a range filter and the mesh of the caller does not change
221+
mask = np.isin(np.asarray(data[name]), list(threshold['values'])).astype(float)
222+
masked = mesh.copy(deep=False)
223+
mask_data = masked.cell_data if preference == 'cell' else masked.point_data
224+
mask_data['_filter_mask'] = mask
225+
result = masked.threshold(
226+
value=(0.5, 1.5),
227+
scalars='_filter_mask',
228+
preference=preference,
229+
all_scalars=preference == 'point',
230+
)
231+
for arrays in (result.cell_data, result.point_data):
232+
if '_filter_mask' in arrays:
233+
del arrays['_filter_mask']
234+
return result
235+
236+
low, high = sorted((float(threshold['min']), float(threshold['max'])))
237+
return mesh.threshold(
238+
value=(low, high),
239+
scalars=name,
240+
preference=preference,
241+
invert=bool(threshold.get('invert', False)),
242+
all_scalars=preference == 'point',
243+
)

‎loopstructural/gui/visualisation/object_list_widget.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ def __init__(self, parent=None, *, viewer=None, properties_widget=None):
3535
self.setLayout(self.mainLayout)
3636
self.viewer = viewer
3737
self.viewer.objectAdded.connect(self.update_object_list)
38+
self.viewer.outOfDateChanged.connect(self._on_out_of_date_changed)
3839
self.treeWidget.installEventFilter(self)
3940
self.treeWidget.itemSelectionChanged.connect(self.on_object_selected)
4041
self.treeWidget.itemDoubleClicked.connect(self.onDoubleClick)
@@ -94,6 +95,9 @@ def update_object_list(self, new_object):
9495
mesh = meshes[mesh_name]
9596
self.add_mesh_item(mesh_name, mesh)
9697

98+
def _on_out_of_date_changed(self):
99+
self.update_object_list(None)
100+
97101
def add_mesh_item(self, mesh_name, mesh):
98102
"""Add a top-level tree item for a mesh and populate children for
99103
point/cell data arrays.
@@ -139,7 +143,13 @@ def _on_vis(state, name=mesh_name, m=mesh):
139143
itemLayout = QHBoxLayout(itemWidget)
140144
itemLayout.setContentsMargins(0, 0, 0, 0)
141145
itemLayout.addWidget(visibilityCheckbox)
142-
itemLayout.addWidget(QLabel(mesh_name))
146+
nameLabel = QLabel(mesh_name)
147+
if isinstance(mesh, dict) and mesh.get('out_of_date'):
148+
# the label text is the object name used elsewhere, so show the
149+
# state with the style and tooltip only
150+
nameLabel.setStyleSheet("color: gray; font-style: italic;")
151+
nameLabel.setToolTip("Out of date: the model changed after this object was added")
152+
itemLayout.addWidget(nameLabel)
143153
itemWidget.setLayout(itemLayout)
144154

145155
self.treeWidget.setItemWidget(top, 0, itemWidget)

0 commit comments

Comments
 (0)