commit ed8f314d35f4a5b5dca614ae288f5deebcd44985
parent 186288b4ebca621edac6402597fe630273ab27be
Author: Anders Damsgaard <anders@adamsgaard.dk>
Date: Thu, 28 May 2026 18:44:36 +0200
feat(qgis): hide layers below DOI by default
Diffstat:
3 files changed, 188 insertions(+), 25 deletions(-)
diff --git a/tem_loader/core.py b/tem_loader/core.py
@@ -194,7 +194,29 @@ def count_valid_layers(row, res_cols, thick_cols):
return count
-def process_xyz(path, mask_below_doi=True, below_doi_opacity=BELOW_DOI_OPACITY):
+def _clip_depth_to_doi(depth_top, depth_bottom, doi, clip_below_doi):
+ if not clip_below_doi:
+ return depth_bottom, False
+ if doi is None:
+ return depth_bottom, False
+ try:
+ if math.isnan(doi):
+ return depth_bottom, False
+ except TypeError:
+ return depth_bottom, False
+ if depth_top >= doi:
+ return depth_bottom, True
+ if depth_bottom > doi:
+ return doi, False
+ return depth_bottom, False
+
+
+def process_xyz(
+ path,
+ mask_below_doi=True,
+ below_doi_opacity=BELOW_DOI_OPACITY,
+ clip_below_doi=True,
+):
below_doi_opacity = validate_opacity(below_doi_opacity)
points = []
@@ -255,6 +277,7 @@ def process_xyz(path, mask_below_doi=True, below_doi_opacity=BELOW_DOI_OPACITY):
layers,
mask_below_doi,
below_doi_opacity,
+ clip_below_doi,
)
continue
@@ -333,11 +356,18 @@ def process_xyz(path, mask_below_doi=True, below_doi_opacity=BELOW_DOI_OPACITY):
depth_top = cum_depth
depth_bottom = cum_depth + thick
+ cum_depth = depth_bottom
+ clipped_bottom, skip = _clip_depth_to_doi(
+ depth_top, depth_bottom, doi, clip_below_doi,
+ )
+ if skip:
+ continue
+ depth_bottom = clipped_bottom
+
depth_mid = (depth_top + depth_bottom) / 2
z_top = z - depth_top
z_bot = z - depth_bottom
z_mid = (z_top + z_bot) / 2
- cum_depth = depth_bottom
layer_wkt = f'LINESTRING Z ({x} {y} {z_top}, {x} {y} {z_bot})'
layers.append({
@@ -376,6 +406,7 @@ def _append_atem_sci_row(
layers,
mask_below_doi=True,
below_doi_opacity=BELOW_DOI_OPACITY,
+ clip_below_doi=True,
):
x = float(row['E'])
y = float(row['N'])
@@ -405,9 +436,16 @@ def _append_atem_sci_row(
else:
break
z_top = z_tops[i]
- z_mid = (z_top + z_bot) / 2
depth_top = z - z_top
depth_bottom = z - z_bot
+ clipped_bottom, skip = _clip_depth_to_doi(
+ depth_top, depth_bottom, doi, clip_below_doi,
+ )
+ if skip:
+ continue
+ depth_bottom = clipped_bottom
+ z_bot = z - depth_bottom
+ z_mid = (z_top + z_bot) / 2
depth_mid = (depth_top + depth_bottom) / 2
layer_rows.append({
'X': x,
diff --git a/tem_loader/tem_loader.py b/tem_loader/tem_loader.py
@@ -220,8 +220,13 @@ class _ImportOptionsDialog(QDialog):
super().__init__(parent)
self.setWindowTitle('TEM Loader Options')
+ self._clip_checkbox = QCheckBox(
+ 'Hide layers below depth of interest (DOI, 2D and 3D views)'
+ )
+ self._clip_checkbox.setChecked(True)
+
self._mask_checkbox = QCheckBox(
- 'Mask out layers below depth of interest (DOI)'
+ 'Make layers below DOI partially transparent (2D view only)'
)
self._mask_checkbox.setChecked(True)
@@ -229,8 +234,10 @@ class _ImportOptionsDialog(QDialog):
self._opacity_spinbox.setRange(0, 100)
self._opacity_spinbox.setSuffix('%')
self._opacity_spinbox.setValue(core.BELOW_DOI_OPACITY)
- self._opacity_spinbox.setEnabled(self._mask_checkbox.isChecked())
- self._mask_checkbox.toggled.connect(self._opacity_spinbox.setEnabled)
+
+ self._clip_checkbox.toggled.connect(self._update_below_doi_controls)
+ self._mask_checkbox.toggled.connect(self._update_below_doi_controls)
+ self._update_below_doi_controls()
self._dem_checkbox = QCheckBox(
'Adjust vertical position to digital elevation model'
@@ -255,6 +262,7 @@ class _ImportOptionsDialog(QDialog):
buttons.rejected.connect(self.reject)
layout = QVBoxLayout()
+ layout.addWidget(self._clip_checkbox)
layout.addWidget(self._mask_checkbox)
layout.addLayout(opacity_form)
layout.addWidget(self._dem_checkbox)
@@ -262,11 +270,19 @@ class _ImportOptionsDialog(QDialog):
layout.addWidget(buttons)
self.setLayout(layout)
+ def _update_below_doi_controls(self, *_args):
+ clipping = self._clip_checkbox.isChecked()
+ self._mask_checkbox.setEnabled(not clipping)
+ self._opacity_spinbox.setEnabled(
+ not clipping and self._mask_checkbox.isChecked()
+ )
+
def options(self):
elevation_raster_layer = None
if self._dem_checkbox.isChecked():
elevation_raster_layer = self._dem_raster_combo.currentLayer()
return {
+ 'clip_below_doi': self._clip_checkbox.isChecked(),
'mask_below_doi': self._mask_checkbox.isChecked(),
'below_doi_opacity': self._opacity_spinbox.value(),
'elevation_raster_layer': elevation_raster_layer,
@@ -341,6 +357,7 @@ class TEMLoaderPlugin:
def _load_xyz(
self,
filepath,
+ clip_below_doi=True,
mask_below_doi=True,
below_doi_opacity=core.BELOW_DOI_OPACITY,
elevation_raster_layer=None,
@@ -349,6 +366,7 @@ class TEMLoaderPlugin:
filepath,
mask_below_doi=mask_below_doi,
below_doi_opacity=below_doi_opacity,
+ clip_below_doi=clip_below_doi,
)
project = QgsProject.instance()
diff --git a/test/test_core.py b/test/test_core.py
@@ -43,7 +43,7 @@ class MetadataTests(unittest.TestCase):
class ProcessXYZTests(unittest.TestCase):
def test_temimage_4_0_4_6_fixture(self):
path = FIXTURE_DIR / "profiler_temimager_4_0_4_6.xyz"
- points, doi_points, layers = process_xyz(path)
+ points, doi_points, layers = process_xyz(path, clip_below_doi=False)
self.assertEqual(len(points), 166)
self.assertEqual(len(doi_points), 166)
@@ -54,7 +54,7 @@ class ProcessXYZTests(unittest.TestCase):
def test_temimage_4_0_7_8_fixture(self):
path = FIXTURE_DIR / "profiler_temimager_4_0_7_8.xyz"
- points, doi_points, layers = process_xyz(path)
+ points, doi_points, layers = process_xyz(path, clip_below_doi=False)
self.assertEqual(len(points), 44)
self.assertEqual(len(doi_points), 44)
@@ -64,7 +64,7 @@ class ProcessXYZTests(unittest.TestCase):
def test_stem_temimage_4_0_4_6_fixture(self):
path = FIXTURE_DIR / "stem_temimager_4_0_4_6.xyz"
- points, doi_points, layers = process_xyz(path)
+ points, doi_points, layers = process_xyz(path, clip_below_doi=False)
self.assertEqual(len(points), 14)
self.assertEqual(len(doi_points), 14)
@@ -74,7 +74,7 @@ class ProcessXYZTests(unittest.TestCase):
def test_aarhus_workbench_fixture(self):
path = FIXTURE_DIR / "stem_workbench_2026_1.xyz"
- points, doi_points, layers = process_xyz(path)
+ points, doi_points, layers = process_xyz(path, clip_below_doi=False)
self.assertEqual(len(points), 158)
self.assertEqual(len(doi_points), 158)
@@ -89,7 +89,7 @@ class ProcessXYZTests(unittest.TestCase):
def test_aarhus_workbench_2024_2_0_0_stem_40x40_fixture(self):
path = FIXTURE_DIR / "stem_40x40_workbench_2024_2_0_0.xyz"
- points, doi_points, layers = process_xyz(path)
+ points, doi_points, layers = process_xyz(path, clip_below_doi=False)
self.assertEqual(len(points), 20)
self.assertEqual(len(doi_points), 20)
@@ -105,7 +105,7 @@ class ProcessXYZTests(unittest.TestCase):
def test_aarhus_workbench_2024_2_0_0_stem_80x80_fixture(self):
path = FIXTURE_DIR / "stem_80x80_workbench_2024_2_0_0.xyz"
- points, doi_points, layers = process_xyz(path)
+ points, doi_points, layers = process_xyz(path, clip_below_doi=False)
self.assertEqual(len(points), 3)
self.assertEqual(len(doi_points), 3)
@@ -121,7 +121,7 @@ class ProcessXYZTests(unittest.TestCase):
def test_aarhus_workbench_2024_2_0_0_stem_profiler_fixture(self):
path = FIXTURE_DIR / "stem_profiler_workbench_2024_2_0_0.xyz"
- points, doi_points, layers = process_xyz(path)
+ points, doi_points, layers = process_xyz(path, clip_below_doi=False)
self.assertEqual(len(points), 274)
self.assertEqual(len(doi_points), 274)
@@ -152,7 +152,7 @@ class ProcessXYZTests(unittest.TestCase):
def test_atem_sci_workbench_fixture(self):
path = FIXTURE_DIR / "atem_sci_workbench.xyz"
- points, doi_points, layers = process_xyz(path)
+ points, doi_points, layers = process_xyz(path, clip_below_doi=False)
self.assertEqual(len(points), 49)
self.assertEqual(len(doi_points), 49)
@@ -177,11 +177,12 @@ class ProcessXYZTests(unittest.TestCase):
def test_process_xyz_accepts_default_masking_options(self):
path = FIXTURE_DIR / "profiler_temimager_4_0_4_6.xyz"
- _, _, default_layers = process_xyz(path)
+ _, _, default_layers = process_xyz(path, clip_below_doi=False)
_, _, option_layers = process_xyz(
path,
mask_below_doi=True,
below_doi_opacity=BELOW_DOI_OPACITY,
+ clip_below_doi=False,
)
self.assertEqual(len(option_layers), len(default_layers))
@@ -198,13 +199,17 @@ class ProcessXYZTests(unittest.TestCase):
def test_process_xyz_can_disable_below_doi_mask(self):
path = FIXTURE_DIR / "profiler_temimager_4_0_4_6.xyz"
- _, _, layers = process_xyz(path, mask_below_doi=False)
+ _, _, layers = process_xyz(
+ path, mask_below_doi=False, clip_below_doi=False,
+ )
self.assertTrue(all(row["Opacity"] == ABOVE_DOI_OPACITY for row in layers))
def test_atem_sci_process_xyz_uses_custom_below_doi_opacity(self):
path = FIXTURE_DIR / "atem_sci_workbench.xyz"
- _, _, layers = process_xyz(path, below_doi_opacity=25)
+ _, _, layers = process_xyz(
+ path, below_doi_opacity=25, clip_below_doi=False,
+ )
opacities = {row["Opacity"] for row in layers}
self.assertIn(ABOVE_DOI_OPACITY, opacities)
@@ -245,7 +250,7 @@ class ProcessXYZTests(unittest.TestCase):
"1 1 0 0 100 15 0 3 10 20 30 10 10 10\n"
)
- _, _, layers = process_xyz(path)
+ _, _, layers = process_xyz(path, clip_below_doi=False)
self.assertEqual(layers[0]["Opacity"], ABOVE_DOI_OPACITY)
self.assertEqual(layers[1]["DepthTop"], 10.0)
@@ -257,7 +262,7 @@ class ProcessXYZTests(unittest.TestCase):
# TEMImage fixture has DOI, so opacity depends on midpoint depth vs DOI.
# Each sounding has its own DOI; we verify the first sounding's layers.
path = FIXTURE_DIR / "profiler_temimager_4_0_4_6.xyz"
- points, doi_points, layers = process_xyz(path)
+ points, doi_points, layers = process_xyz(path, clip_below_doi=False)
self.assertTrue(all("Opacity" in row for row in layers))
# Every layer's opacity is one of the two valid values
self.assertTrue(
@@ -276,6 +281,84 @@ class ProcessXYZTests(unittest.TestCase):
f"Layer {layer['Layer']} midpoint depth {depth_mid} vs DOI {first_doi}",
)
+ def test_clip_below_doi_drops_layers_fully_below_doi(self):
+ with TemporaryDirectory() as tmp:
+ path = Path(tmp) / "clip.xyz"
+ path.write_text(
+ "/ Line StationNo X Y Z DOI DataResidual NumLayers "
+ "Res_001 Res_002 Res_003 Thick_001 Thick_002 Thick_003\n"
+ "1 1 0 0 100 15 0 3 10 20 30 10 10 10\n"
+ )
+
+ _, _, layers = process_xyz(path, clip_below_doi=True)
+
+ self.assertEqual(len(layers), 2)
+ self.assertEqual(layers[0]["DepthTop"], 0.0)
+ self.assertEqual(layers[0]["DepthBottom"], 10.0)
+ self.assertEqual(layers[1]["DepthTop"], 10.0)
+ self.assertEqual(layers[1]["DepthBottom"], 15.0)
+ self.assertEqual(layers[1]["ZBottom"], 85.0)
+ self.assertIn(
+ "LINESTRING Z (0.0 0.0 90.0, 0.0 0.0 85.0)",
+ layers[1]["Geometry"],
+ )
+
+ def test_clip_below_doi_keeps_layer_when_doi_at_layer_bottom(self):
+ with TemporaryDirectory() as tmp:
+ path = Path(tmp) / "boundary.xyz"
+ path.write_text(
+ "/ Line StationNo X Y Z DOI DataResidual NumLayers "
+ "Res_001 Res_002 Thick_001 Thick_002\n"
+ "1 1 0 0 100 10 0 2 10 20 10 10\n"
+ )
+
+ _, _, layers = process_xyz(path, clip_below_doi=True)
+
+ self.assertEqual(len(layers), 1)
+ self.assertEqual(layers[0]["DepthTop"], 0.0)
+ self.assertEqual(layers[0]["DepthBottom"], 10.0)
+
+ def test_clip_below_doi_is_noop_when_disabled(self):
+ with TemporaryDirectory() as tmp:
+ path = Path(tmp) / "unclipped.xyz"
+ path.write_text(
+ "/ Line StationNo X Y Z DOI DataResidual NumLayers "
+ "Res_001 Res_002 Res_003 Thick_001 Thick_002 Thick_003\n"
+ "1 1 0 0 100 15 0 3 10 20 30 10 10 10\n"
+ )
+
+ _, _, layers = process_xyz(path, clip_below_doi=False)
+
+ self.assertEqual(len(layers), 3)
+ self.assertEqual(layers[-1]["DepthBottom"], 30.0)
+
+ def test_clip_below_doi_default_is_enabled(self):
+ with TemporaryDirectory() as tmp:
+ path = Path(tmp) / "default.xyz"
+ path.write_text(
+ "/ Line StationNo X Y Z DOI DataResidual NumLayers "
+ "Res_001 Res_002 Res_003 Thick_001 Thick_002 Thick_003\n"
+ "1 1 0 0 100 15 0 3 10 20 30 10 10 10\n"
+ )
+
+ _, _, layers = process_xyz(path)
+
+ self.assertEqual(len(layers), 2)
+
+ def test_clip_below_doi_skips_sci_format_safely(self):
+ path = FIXTURE_DIR / "sci_workbench_2026_1.xyz"
+ _, _, layers_default = process_xyz(path)
+ _, _, layers_unclipped = process_xyz(path, clip_below_doi=False)
+
+ self.assertEqual(len(layers_default), len(layers_unclipped))
+
+ def test_clip_below_doi_clips_atem_sci_layers(self):
+ path = FIXTURE_DIR / "atem_sci_workbench.xyz"
+ _, _, unclipped = process_xyz(path, clip_below_doi=False)
+ _, _, clipped = process_xyz(path, clip_below_doi=True)
+
+ self.assertLess(len(clipped), len(unclipped))
+
def test_sci_fixture_layers_all_above_opacity(self):
# SCI format has no DOI, so all layers get ABOVE_DOI_OPACITY
path = FIXTURE_DIR / "sci_workbench_2026_1.xyz"
@@ -494,6 +577,7 @@ class PluginTests(unittest.TestCase):
def __init__(self, text):
self.text = text
self._checked = False
+ self.enabled = True
self.toggled = FakeSignal()
def setChecked(self, checked):
@@ -503,6 +587,9 @@ class PluginTests(unittest.TestCase):
if changed:
self.toggled.emit(checked)
+ def setEnabled(self, enabled):
+ self.enabled = bool(enabled)
+
def isChecked(self):
return self._checked
@@ -881,6 +968,7 @@ class PluginTests(unittest.TestCase):
iface.mainWindow.return_value = object()
dialog = Mock()
dialog.options.return_value = {
+ "clip_below_doi": True,
"mask_below_doi": True,
"below_doi_opacity": 35,
"elevation_raster_layer": None,
@@ -914,6 +1002,7 @@ class PluginTests(unittest.TestCase):
dialog = Mock()
raster_layer = object()
dialog.options.return_value = {
+ "clip_below_doi": True,
"mask_below_doi": True,
"below_doi_opacity": 35,
"elevation_raster_layer": raster_layer,
@@ -929,6 +1018,7 @@ class PluginTests(unittest.TestCase):
module._exec_dialog.assert_called_once_with(dialog)
plugin._load_xyz.assert_called_once_with(
Path("/tmp/model.xyz"),
+ clip_below_doi=True,
mask_below_doi=True,
below_doi_opacity=35,
elevation_raster_layer=raster_layer,
@@ -957,8 +1047,13 @@ class PluginTests(unittest.TestCase):
self.assertEqual(dialog.title, "TEM Loader Options")
self.assertEqual(
+ dialog._clip_checkbox.text,
+ "Hide layers below depth of interest (DOI, 2D and 3D views)",
+ )
+ self.assertTrue(dialog._clip_checkbox.isChecked())
+ self.assertEqual(
dialog._mask_checkbox.text,
- "Mask out layers below depth of interest (DOI)",
+ "Make layers below DOI partially transparent (2D view only)",
)
self.assertEqual(
dialog._dem_checkbox.text,
@@ -968,26 +1063,33 @@ class PluginTests(unittest.TestCase):
self.assertEqual(dialog._opacity_spinbox.minimum, 0)
self.assertEqual(dialog._opacity_spinbox.maximum, 100)
self.assertEqual(dialog._opacity_spinbox.suffix, "%")
- self.assertTrue(dialog._opacity_spinbox.enabled)
- self.assertIs(dialog.layout.items[0], dialog._mask_checkbox)
+ self.assertFalse(dialog._mask_checkbox.enabled)
+ self.assertFalse(dialog._opacity_spinbox.enabled)
+ self.assertIs(dialog.layout.items[0], dialog._clip_checkbox)
+ self.assertIs(dialog.layout.items[1], dialog._mask_checkbox)
self.assertEqual(
- dialog.layout.items[1].rows,
+ dialog.layout.items[2].rows,
[("Opacity", dialog._opacity_spinbox)],
)
- self.assertIs(dialog.layout.items[2], dialog._dem_checkbox)
+ self.assertIs(dialog.layout.items[3], dialog._dem_checkbox)
self.assertEqual(
- dialog.layout.items[3].rows,
+ dialog.layout.items[4].rows,
[("Elevation raster", dialog._dem_raster_combo)],
)
self.assertEqual(
dialog.options(),
{
+ "clip_below_doi": True,
"mask_below_doi": True,
"below_doi_opacity": module.core.BELOW_DOI_OPACITY,
"elevation_raster_layer": None,
},
)
+ dialog._clip_checkbox.setChecked(False)
+ self.assertTrue(dialog._mask_checkbox.enabled)
+ self.assertTrue(dialog._opacity_spinbox.enabled)
+
dialog._opacity_spinbox.setValue(35)
dialog._mask_checkbox.setChecked(False)
@@ -995,6 +1097,7 @@ class PluginTests(unittest.TestCase):
self.assertEqual(
dialog.options(),
{
+ "clip_below_doi": False,
"mask_below_doi": False,
"below_doi_opacity": 35,
"elevation_raster_layer": None,
@@ -1056,6 +1159,7 @@ class PluginTests(unittest.TestCase):
iface.mainWindow.return_value = object()
dialog = Mock()
dialog.options.return_value = {
+ "clip_below_doi": True,
"mask_below_doi": True,
"below_doi_opacity": 35,
"elevation_raster_layer": None,
@@ -1070,6 +1174,7 @@ class PluginTests(unittest.TestCase):
self.assertFalse(hasattr(module.QDialog, "Accepted"))
plugin._load_xyz.assert_called_once_with(
Path("/tmp/model.xyz"),
+ clip_below_doi=True,
mask_below_doi=True,
below_doi_opacity=35,
elevation_raster_layer=None,
@@ -1638,6 +1743,7 @@ class PluginTests(unittest.TestCase):
Path("/tmp/model.xyz"),
mask_below_doi=False,
below_doi_opacity=35,
+ clip_below_doi=True,
)
self.assertFalse(module.core.write_csv.called)
self.assertEqual(
@@ -1742,6 +1848,7 @@ class PluginTests(unittest.TestCase):
Path("/tmp/model.xyz"),
mask_below_doi=False,
below_doi_opacity=35,
+ clip_below_doi=True,
)
self.assertEqual(
module.QgsVectorFileWriter.created[0].features[0].geometry.wkt,