BERATools 0.3.8__tar.gz → 0.3.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. {beratools-0.3.8 → beratools-0.3.9}/PKG-INFO +1 -1
  2. {beratools-0.3.8 → beratools-0.3.9}/beratools/__init__.py +1 -1
  3. {beratools-0.3.8 → beratools-0.3.9}/beratools/core/algo_seed_line_correction.py +1 -3
  4. {beratools-0.3.8 → beratools-0.3.9}/beratools/gui/bt_data.py +23 -0
  5. {beratools-0.3.8 → beratools-0.3.9}/beratools/gui/bt_gui_main.py +44 -12
  6. {beratools-0.3.8 → beratools-0.3.9}/beratools/gui/tool_widgets.py +107 -33
  7. {beratools-0.3.8 → beratools-0.3.9}/beratools/tools/check_seed_line.py +50 -25
  8. {beratools-0.3.8 → beratools-0.3.9}/.gitignore +0 -0
  9. {beratools-0.3.8 → beratools-0.3.9}/LICENSE +0 -0
  10. {beratools-0.3.8 → beratools-0.3.9}/README.md +0 -0
  11. {beratools-0.3.8 → beratools-0.3.9}/beratools/cli/__init__.py +0 -0
  12. {beratools-0.3.8 → beratools-0.3.9}/beratools/cli/entry.py +0 -0
  13. {beratools-0.3.8 → beratools-0.3.9}/beratools/core/__init__.py +0 -0
  14. {beratools-0.3.8 → beratools-0.3.9}/beratools/core/algo_canopy_footprint_absolute.py +0 -0
  15. {beratools-0.3.8 → beratools-0.3.9}/beratools/core/algo_centerline.py +0 -0
  16. {beratools-0.3.8 → beratools-0.3.9}/beratools/core/algo_check_seed_line.py +0 -0
  17. {beratools-0.3.8 → beratools-0.3.9}/beratools/core/algo_common.py +0 -0
  18. {beratools-0.3.8 → beratools-0.3.9}/beratools/core/algo_cost.py +0 -0
  19. {beratools-0.3.8 → beratools-0.3.9}/beratools/core/algo_dijkstra.py +0 -0
  20. {beratools-0.3.8 → beratools-0.3.9}/beratools/core/algo_line_grouping.py +0 -0
  21. {beratools-0.3.8 → beratools-0.3.9}/beratools/core/algo_merge_lines.py +0 -0
  22. {beratools-0.3.8 → beratools-0.3.9}/beratools/core/algo_split_with_lines.py +0 -0
  23. {beratools-0.3.8 → beratools-0.3.9}/beratools/core/algo_vertex_optimization.py +0 -0
  24. {beratools-0.3.8 → beratools-0.3.9}/beratools/core/algo_vertex_preclean.py +0 -0
  25. {beratools-0.3.8 → beratools-0.3.9}/beratools/core/canopy_threshold_relative.py +0 -0
  26. {beratools-0.3.8 → beratools-0.3.9}/beratools/core/constants.py +0 -0
  27. {beratools-0.3.8 → beratools-0.3.9}/beratools/core/line_footprint_functions.py +0 -0
  28. {beratools-0.3.8 → beratools-0.3.9}/beratools/core/logger.py +0 -0
  29. {beratools-0.3.8 → beratools-0.3.9}/beratools/core/tool_base.py +0 -0
  30. {beratools-0.3.8 → beratools-0.3.9}/beratools/external/__init__.py +0 -0
  31. {beratools-0.3.8 → beratools-0.3.9}/beratools/external/polygon_centerline/LICENSE.md +0 -0
  32. {beratools-0.3.8 → beratools-0.3.9}/beratools/external/polygon_centerline/UPSTREAM.md +0 -0
  33. {beratools-0.3.8 → beratools-0.3.9}/beratools/external/polygon_centerline/__init__.py +0 -0
  34. {beratools-0.3.8 → beratools-0.3.9}/beratools/external/polygon_centerline/_src.py +0 -0
  35. {beratools-0.3.8 → beratools-0.3.9}/beratools/external/polygon_centerline/exceptions.py +0 -0
  36. {beratools-0.3.8 → beratools-0.3.9}/beratools/gui/assets/BERALogo.ico +0 -0
  37. {beratools-0.3.8 → beratools-0.3.9}/beratools/gui/assets/BERALogo.png +0 -0
  38. {beratools-0.3.8 → beratools-0.3.9}/beratools/gui/assets/BERA_GUI_Main.png +0 -0
  39. {beratools-0.3.8 → beratools-0.3.9}/beratools/gui/assets/BERA_Splash.gif +0 -0
  40. {beratools-0.3.8 → beratools-0.3.9}/beratools/gui/assets/BERA_WizardImage.png +0 -0
  41. {beratools-0.3.8 → beratools-0.3.9}/beratools/gui/assets/beratools.json +0 -0
  42. {beratools-0.3.8 → beratools-0.3.9}/beratools/gui/assets/closed.gif +0 -0
  43. {beratools-0.3.8 → beratools-0.3.9}/beratools/gui/assets/closed.png +0 -0
  44. {beratools-0.3.8 → beratools-0.3.9}/beratools/gui/assets/gui.json +0 -0
  45. {beratools-0.3.8 → beratools-0.3.9}/beratools/gui/assets/open.gif +0 -0
  46. {beratools-0.3.8 → beratools-0.3.9}/beratools/gui/assets/open.png +0 -0
  47. {beratools-0.3.8 → beratools-0.3.9}/beratools/gui/assets/tool.gif +0 -0
  48. {beratools-0.3.8 → beratools-0.3.9}/beratools/gui/assets/tool.png +0 -0
  49. {beratools-0.3.8 → beratools-0.3.9}/beratools/gui/geometry_types.py +0 -0
  50. {beratools-0.3.8 → beratools-0.3.9}/beratools/gui/main.py +0 -0
  51. {beratools-0.3.8 → beratools-0.3.9}/beratools/tools/__init__.py +0 -0
  52. {beratools-0.3.8 → beratools-0.3.9}/beratools/tools/canopy_footprint_absolute.py +0 -0
  53. {beratools-0.3.8 → beratools-0.3.9}/beratools/tools/centerline.py +0 -0
  54. {beratools-0.3.8 → beratools-0.3.9}/beratools/tools/common.py +0 -0
  55. {beratools-0.3.8 → beratools-0.3.9}/beratools/tools/ground_footprint.py +0 -0
  56. {beratools-0.3.8 → beratools-0.3.9}/beratools/tools/line_footprint_relative.py +0 -0
  57. {beratools-0.3.8 → beratools-0.3.9}/beratools/tools/tool_template.py +0 -0
  58. {beratools-0.3.8 → beratools-0.3.9}/beratools/tools/vertex_optimization.py +0 -0
  59. {beratools-0.3.8 → beratools-0.3.9}/beratools/utility/env_checks.py +0 -0
  60. {beratools-0.3.8 → beratools-0.3.9}/beratools/utility/spatial_common.py +0 -0
  61. {beratools-0.3.8 → beratools-0.3.9}/beratools/utility/tool_args.py +0 -0
  62. {beratools-0.3.8 → beratools-0.3.9}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: BERATools
3
- Version: 0.3.8
3
+ Version: 0.3.9
4
4
  Summary: An advanced forest line feature analysis platform
5
5
  Project-URL: Homepage, https://github.com/appliedgrg/beratools
6
6
  Author-email: AppliedGRG <appliedgrg@gmail.com>, Richard Zeng <richardqzeng@gmail.com>
@@ -1,6 +1,6 @@
1
1
  __author__ = """Applied Geospatial Research Group"""
2
2
  __email__ = 'appliedgrg@gmail.com'
3
- __version__ = '0.3.8'
3
+ __version__ = '0.3.9'
4
4
  __license__ = "GPL-3.0-or-later"
5
5
  __copyright__ = "Copyright (c) AppliedGRG"
6
6
  __status__ = "Pre-Alpha"
@@ -593,9 +593,7 @@ class SeedLineCorrection:
593
593
  return self.get_optimized_lines()
594
594
 
595
595
  def compute(self):
596
- compute_processes = self.processes
597
- if self.optimize_internal_vertices and (compute_processes is None or int(compute_processes) <= 0):
598
- compute_processes = 1
596
+ compute_processes = self.processes if self.processes is not None else 0
599
597
 
600
598
  vertex_grp = bt_base.execute_multiprocessing(
601
599
  algo_common.process_single_item,
@@ -426,6 +426,7 @@ class BTData(object):
426
426
  self.show_advanced = BT_SHOW_ADVANCED_OPTIONS
427
427
  self.selected_cpu_cores = -1
428
428
  self.recent_tool = None
429
+ self.last_browse_dir = None
429
430
  self.ascii_art = ""
430
431
  self.get_working_dir()
431
432
  self.get_user_folder()
@@ -457,6 +458,10 @@ class BTData(object):
457
458
  self.settings = self.settings_manager.settings
458
459
  gui_settings = self.settings.get("gui_parameters", {})
459
460
  self.recent_tool = gui_settings.get("recent_tool", None)
461
+ if "last_browse_dir" in gui_settings and gui_settings["last_browse_dir"] is not None:
462
+ saved_dir = Path(gui_settings["last_browse_dir"]).expanduser()
463
+ if saved_dir.exists() and saved_dir.is_dir():
464
+ self.last_browse_dir = saved_dir.as_posix()
460
465
  self.load_gui_data()
461
466
  self.get_tool_history()
462
467
 
@@ -655,6 +660,24 @@ class BTData(object):
655
660
  if not api_result:
656
661
  self.recent_tool = None
657
662
 
663
+ if "last_browse_dir" in gui_settings and gui_settings["last_browse_dir"] is not None:
664
+ saved_dir = Path(gui_settings["last_browse_dir"]).expanduser()
665
+ if saved_dir.exists() and saved_dir.is_dir():
666
+ self.last_browse_dir = saved_dir.as_posix()
667
+
668
+ def set_last_browse_dir(self, path_str):
669
+ if not path_str:
670
+ return
671
+
672
+ browse_dir = Path(path_str).expanduser()
673
+ if browse_dir.exists() and browse_dir.is_dir():
674
+ new_browse_dir = browse_dir.as_posix()
675
+ self.save_setting("last_browse_dir", new_browse_dir)
676
+ self.last_browse_dir = new_browse_dir
677
+
678
+ def get_last_browse_dir(self):
679
+ return self.last_browse_dir
680
+
658
681
  def load_gui_data(self):
659
682
  gui_settings = {}
660
683
  if not self.gui_setting_file.exists():
@@ -17,6 +17,7 @@ import json
17
17
  import os
18
18
  import platform
19
19
  import shlex
20
+ import signal
20
21
  import sys
21
22
  import webbrowser
22
23
  from pathlib import Path
@@ -211,7 +212,6 @@ class ClickSlider(QtWidgets.QSlider):
211
212
  if event.button() == QtCore.Qt.LeftButton:
212
213
  val = self.pixel_pos_to_range_value(event.pos())
213
214
  self.setValue(val)
214
- self.sliderMoved.emit(val)
215
215
 
216
216
  def pixel_pos_to_range_value(self, pos):
217
217
  opt = QtWidgets.QStyleOptionSlider()
@@ -262,12 +262,13 @@ class BTSlider(QtWidgets.QWidget):
262
262
  layout.addWidget(self.slider)
263
263
  self.setLayout(layout)
264
264
 
265
- self.slider.sliderMoved.connect(self.slider_moved)
265
+ self.slider.valueChanged.connect(self.slider_moved)
266
266
 
267
267
  def slider_moved(self, value):
268
- bt.set_selected_cpu_cores(value)
269
- QtWidgets.QToolTip.showText(QtGui.QCursor.pos(), f"{value}")
270
- self.label.setText(self.generate_label_text())
268
+ actual_value = int(self.slider.value())
269
+ bt.set_selected_cpu_cores(actual_value)
270
+ QtWidgets.QToolTip.showText(QtGui.QCursor.pos(), f"{actual_value}")
271
+ self.label.setText(self.generate_label_text(actual_value))
271
272
 
272
273
  def generate_label_text(self, value=None):
273
274
  if not value:
@@ -362,7 +363,6 @@ class MainWindow(QtWidgets.QMainWindow):
362
363
  self.action_bera_tools_guide = None
363
364
  self.action_about_bera_tools = None
364
365
  self._create_help_menu()
365
- self.working_dir = bt.work_dir
366
366
  self.tool_api = None
367
367
  self.tool_name = "Centerline"
368
368
  self.recent_tool = bt.recent_tool
@@ -435,7 +435,13 @@ class MainWindow(QtWidgets.QMainWindow):
435
435
 
436
436
  # ToolWidgets
437
437
  tool_args = bt.get_bera_tool_args(self.tool_name)
438
- self.tool_widget = ToolWidgets(self.recent_tool, tool_args, bt.show_advanced)
438
+ self.tool_widget = ToolWidgets(self.recent_tool, tool_args, bt.show_advanced, bt_data_obj=bt)
439
+ self.tool_scroll_area = QtWidgets.QScrollArea()
440
+ self.tool_scroll_area.setWidgetResizable(True)
441
+ self.tool_scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded)
442
+ self.tool_scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded)
443
+ self.tool_scroll_area.setFrameShape(QtWidgets.QFrame.NoFrame)
444
+ self.tool_scroll_area.setWidget(self.tool_widget)
439
445
 
440
446
  # bottom buttons
441
447
  slider = BTSlider(bt.selected_cpu_cores, bt.max_cpu_cores)
@@ -460,7 +466,7 @@ class MainWindow(QtWidgets.QMainWindow):
460
466
 
461
467
  self.top_right_layout = QtWidgets.QVBoxLayout()
462
468
  self.top_right_layout.addLayout(self.btn_layout_top)
463
- self.top_right_layout.addWidget(self.tool_widget)
469
+ self.top_right_layout.addWidget(self.tool_scroll_area)
464
470
  self.top_right_layout.addLayout(btn_layout_bottom)
465
471
  tool_widget_grp = QtWidgets.QGroupBox("Tool")
466
472
  tool_widget_grp.setLayout(self.top_right_layout)
@@ -469,6 +475,8 @@ class MainWindow(QtWidgets.QMainWindow):
469
475
  self.text_edit = QtWidgets.QPlainTextEdit()
470
476
  self.text_edit.setFont(QtGui.QFont("Consolas", 9))
471
477
  self.text_edit.setReadOnly(True)
478
+ self.text_edit.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
479
+ self.text_edit.customContextMenuRequested.connect(self._show_log_context_menu)
472
480
  self.print_about()
473
481
 
474
482
  # progress bar
@@ -549,10 +557,11 @@ class MainWindow(QtWidgets.QMainWindow):
549
557
  self.btn_layout_top.itemAt(0).widget().setText(self.tool_name)
550
558
 
551
559
  # update tool widget
552
- self.tool_widget = ToolWidgets(self.tool_name, tool_args, bt.show_advanced)
553
- widget = self.top_right_layout.itemAt(1).widget()
554
- self.top_right_layout.removeWidget(widget)
555
- self.top_right_layout.insertWidget(1, self.tool_widget)
560
+ self.tool_widget = ToolWidgets(self.tool_name, tool_args, bt.show_advanced, bt_data_obj=bt)
561
+ old_widget = self.tool_scroll_area.takeWidget()
562
+ if old_widget is not None:
563
+ old_widget.deleteLater()
564
+ self.tool_scroll_area.setWidget(self.tool_widget)
556
565
  self.top_right_layout.update()
557
566
 
558
567
  def save_tool_parameter(self):
@@ -629,6 +638,17 @@ class MainWindow(QtWidgets.QMainWindow):
629
638
  self.text_edit.insertPlainText(text)
630
639
  self.text_edit.moveCursor(QtGui.QTextCursor.End)
631
640
 
641
+ def clear_log_messages(self):
642
+ self.text_edit.clear()
643
+
644
+ def _show_log_context_menu(self, pos):
645
+ menu = self.text_edit.createStandardContextMenu()
646
+ menu.addSeparator()
647
+ clear_action = menu.addAction("Clear Messages")
648
+ action = menu.exec_(self.text_edit.mapToGlobal(pos))
649
+ if action == clear_action:
650
+ self.clear_log_messages()
651
+
632
652
  def print_line_to_output(self, text, tag=None):
633
653
  self.text_edit.moveCursor(QtGui.QTextCursor.End)
634
654
  self.text_edit.insertPlainText(text + "\n")
@@ -652,6 +672,8 @@ class MainWindow(QtWidgets.QMainWindow):
652
672
 
653
673
  self.tool_widget.adjustSize()
654
674
  self.tool_widget.updateGeometry()
675
+ self.tool_scroll_area.widget().adjustSize()
676
+ self.tool_scroll_area.viewport().update()
655
677
  self.top_right_layout.activate()
656
678
  self.top_right_layout.update()
657
679
 
@@ -807,4 +829,14 @@ def runner():
807
829
  pass
808
830
 
809
831
  QtCore.QTimer.singleShot(100, signal_ready)
832
+
833
+ # Allow Ctrl+C in the terminal to quit the application.
834
+ # Qt overrides Python's SIGINT handler; use a custom handler that
835
+ # calls app.quit() and a timer to let Python process signals.
836
+ signal.signal(signal.SIGINT, lambda *_: app.quit())
837
+ timer = QtCore.QTimer(app)
838
+ timer.start(500)
839
+ timer.timeout.connect(lambda: None)
840
+ app.aboutToQuit.connect(timer.stop)
841
+
810
842
  app.exec_()
@@ -30,6 +30,8 @@ from beratools.gui.geometry_types import (
30
30
  )
31
31
 
32
32
  BT_LABEL_MIN_WIDTH = 130
33
+ BT_WIDGET_MIN_HEIGHT = 36
34
+ BT_NUMERIC_BOX_WIDTH = 160
33
35
 
34
36
 
35
37
  class ToolWidgets(QtWidgets.QWidget):
@@ -37,12 +39,13 @@ class ToolWidgets(QtWidgets.QWidget):
37
39
 
38
40
  signal_save_tool_params = QtCore.pyqtSignal(object)
39
41
 
40
- def __init__(self, tool_name, tool_args, show_advanced, parent=None):
42
+ def __init__(self, tool_name, tool_args, show_advanced, bt_data_obj=None, parent=None):
41
43
  super(ToolWidgets, self).__init__(parent)
42
44
 
43
45
  self.tool_name = tool_name
44
46
  self.show_advanced = show_advanced
45
47
  self.current_tool_api = ""
48
+ self.bt_data = bt_data_obj
46
49
  self.widget_list = []
47
50
  self.ui_widget_list = []
48
51
  self.widgets_by_variable = {}
@@ -52,8 +55,14 @@ class ToolWidgets(QtWidgets.QWidget):
52
55
 
53
56
  self.create_widgets(tool_args)
54
57
  layout = QtWidgets.QVBoxLayout()
58
+ layout.setAlignment(QtCore.Qt.AlignTop)
59
+ layout.setSpacing(8)
55
60
 
56
61
  for item in self.ui_widget_list:
62
+ item_layout = self._get_widget_layout(item)
63
+ if item_layout is not None:
64
+ m = item_layout.contentsMargins()
65
+ item_layout.setContentsMargins(m.left(), 0, m.right(), 0)
57
66
  layout.addWidget(item)
58
67
 
59
68
  self.save_button = QtWidgets.QPushButton("Save Parameters")
@@ -92,7 +101,7 @@ class ToolWidgets(QtWidgets.QWidget):
92
101
  widget = None
93
102
 
94
103
  if "ExistingFile" in pt or "NewFile" in pt or "Directory" in pt:
95
- widget = FileSelector(json_str, None)
104
+ widget = FileSelector(json_str, None, bt_data_obj=self.bt_data)
96
105
  elif "FileList" in pt:
97
106
  widget = MultiFileSelector(json_str, None)
98
107
  elif "Boolean" in pt:
@@ -135,6 +144,7 @@ class ToolWidgets(QtWidgets.QWidget):
135
144
 
136
145
  # hide optional widgets
137
146
  if widget:
147
+ widget.setMinimumHeight(BT_WIDGET_MIN_HEIGHT)
138
148
  self._set_widget_tooltip(widget)
139
149
  widget.depends_on = p.get("depends_on")
140
150
  widget.unit = p.get("unit", "")
@@ -214,7 +224,6 @@ class ToolWidgets(QtWidgets.QWidget):
214
224
  self.ui_widget_list = [widget for widget in self.widget_list if widget]
215
225
  self._depends_on_specs = {}
216
226
  self._inline_controllers = {}
217
- inline_controller_widgets = []
218
227
 
219
228
  for widget in self.widget_list:
220
229
  if not widget:
@@ -266,43 +275,61 @@ class ToolWidgets(QtWidgets.QWidget):
266
275
  )
267
276
  elif self._attach_inline_widget(controller_widgets[0], widget):
268
277
  self._inline_controllers[controller_vars[0]] = widget.variable
269
- inline_controller_widgets.append(controller_widgets[0])
270
278
  if widget in self.ui_widget_list:
271
279
  self.ui_widget_list.remove(widget)
272
-
273
280
  self._depends_on_specs[widget.variable] = depends_on
274
281
  for controller_widget in controller_widgets:
275
282
  self._connect_widget_change_signal(controller_widget, self._update_dependency_states)
276
283
 
277
- self._align_inline_controller_checkboxes(inline_controller_widgets)
284
+ self._align_all_labels()
278
285
  self._update_dependency_states()
279
286
 
280
- @staticmethod
281
- def _align_inline_controller_checkboxes(controller_widgets):
282
- unique_widgets = []
283
- seen_ids = set()
284
- for widget in controller_widgets:
285
- widget_id = id(widget)
286
- if widget_id in seen_ids:
287
- continue
288
- seen_ids.add(widget_id)
289
- unique_widgets.append(widget)
290
-
291
- if not unique_widgets:
292
- return
287
+ def _align_all_labels(self):
288
+ """Set every widget's label (QLabel or QCheckBox) to the same fixed width
289
+ so that all input controls start at the same horizontal position.
290
+ Inline widgets are skipped — their label is hidden and alignment is
291
+ governed by the controller checkbox they are embedded in."""
292
+ inlined_vars = set(self._inline_controllers.values())
293
+ anchor_width = BT_LABEL_MIN_WIDTH
294
+ checkbox_indicator_overhead = 0
293
295
 
294
- max_width = 0
295
- for controller in unique_widgets:
296
- if isinstance(controller, BooleanInput):
297
- max_width = max(max_width, controller.checkbox.sizeHint().width())
296
+ # First pass: measure natural widths of all labels (skip inlined widgets)
297
+ for widget in self.widget_list:
298
+ if not widget or widget.variable in inlined_vars:
299
+ continue
300
+ label = getattr(widget, "label", None)
301
+ if label is None:
302
+ continue
298
303
 
299
- if max_width <= 0:
300
- return
304
+ if isinstance(widget, BooleanInput):
305
+ text_width = widget.checkbox.fontMetrics().horizontalAdvance(
306
+ widget.checkbox.text()
307
+ )
308
+ if not checkbox_indicator_overhead:
309
+ style = widget.checkbox.style()
310
+ opt = QtWidgets.QStyleOptionButton()
311
+ opt.initFrom(widget.checkbox)
312
+ checkbox_indicator_overhead = (
313
+ style.pixelMetric(
314
+ QtWidgets.QStyle.PM_IndicatorWidth, opt, widget.checkbox
315
+ )
316
+ + style.pixelMetric(
317
+ QtWidgets.QStyle.PM_CheckBoxLabelSpacing, opt, widget.checkbox
318
+ )
319
+ )
320
+ anchor_width = max(anchor_width, text_width + checkbox_indicator_overhead + 8)
321
+ elif isinstance(label, QtWidgets.QLabel):
322
+ anchor_width = max(anchor_width, label.sizeHint().width())
301
323
 
302
- aligned_width = max_width + 8
303
- for controller in unique_widgets:
304
- if isinstance(controller, BooleanInput):
305
- controller.checkbox.setMinimumWidth(aligned_width)
324
+ # Second pass: apply the uniform width (skip inlined widgets)
325
+ for widget in self.widget_list:
326
+ if not widget or widget.variable in inlined_vars:
327
+ continue
328
+ label = getattr(widget, "label", None)
329
+ if label is None:
330
+ continue
331
+ label.setMinimumWidth(anchor_width)
332
+ label.setMaximumWidth(anchor_width)
306
333
 
307
334
  def _attach_inline_widget(self, controller_widget, dependent_widget):
308
335
  controller_layout = self._get_widget_layout(controller_widget)
@@ -310,12 +337,15 @@ class ToolWidgets(QtWidgets.QWidget):
310
337
  return False
311
338
 
312
339
  dependent_widget.set_inline_mode(getattr(dependent_widget, "unit", ""))
340
+ controller_layout.setAlignment(QtCore.Qt.AlignVCenter)
341
+ # Remove trailing stretch so the inline widget can expand to fill available space
313
342
  insert_index = controller_layout.count()
314
343
  if insert_index > 0:
315
344
  last_item = controller_layout.itemAt(insert_index - 1)
316
345
  if last_item and last_item.spacerItem() is not None:
346
+ controller_layout.removeItem(last_item)
317
347
  insert_index -= 1
318
- controller_layout.insertWidget(insert_index, dependent_widget)
348
+ controller_layout.insertWidget(insert_index, dependent_widget, 1)
319
349
  return True
320
350
 
321
351
  @staticmethod
@@ -452,8 +482,9 @@ class FileSelector(QtWidgets.QWidget):
452
482
  return True
453
483
  return False
454
484
 
455
- def __init__(self, json_str, parent=None):
485
+ def __init__(self, json_str, parent=None, bt_data_obj=None):
456
486
  super(FileSelector, self).__init__(parent)
487
+ self.bt_data = bt_data_obj
457
488
  self.selected_layer = ""
458
489
  self.parse_params(json_str)
459
490
  self.initialize_values()
@@ -774,8 +805,10 @@ class FileSelector(QtWidgets.QWidget):
774
805
  dialog.setViewMode(QtWidgets.QFileDialog.Detail)
775
806
  # Handle both string and dict values (for vector files)
776
807
  path_value = self.value.get("path", "") if isinstance(self.value, dict) else self.value
777
- if path_value:
778
- dialog.setDirectory(str(Path(path_value).parent))
808
+ start_dir, start_dir_source = self._resolve_dialog_start_directory(path_value)
809
+ if start_dir:
810
+ dialog.setDirectory(str(start_dir))
811
+ if path_value and start_dir_source == "path_value":
779
812
  dialog.selectFile(Path(path_value).name)
780
813
  dialog.setNameFilter(file_types)
781
814
  if "ExistingFile" in self.parameter_type:
@@ -784,6 +817,36 @@ class FileSelector(QtWidgets.QWidget):
784
817
  dialog.setFileMode(QtWidgets.QFileDialog.AnyFile)
785
818
  return dialog
786
819
 
820
+ def _resolve_dialog_start_directory(self, path_value):
821
+ if self.bt_data:
822
+ last_browse_dir = self.bt_data.get_last_browse_dir()
823
+ if last_browse_dir:
824
+ browse_dir = Path(last_browse_dir).expanduser()
825
+ if browse_dir.exists() and browse_dir.is_dir():
826
+ return browse_dir, "last_browse"
827
+
828
+ if path_value:
829
+ current_path = Path(path_value).expanduser()
830
+ if current_path.exists():
831
+ if current_path.is_dir():
832
+ return current_path, "path_value"
833
+ return current_path.parent, "path_value"
834
+
835
+ parent_dir = current_path.parent
836
+ if parent_dir.exists() and parent_dir.is_dir():
837
+ return parent_dir, "path_value"
838
+
839
+ return Path.home(), "home"
840
+
841
+ def _save_last_browse_dir(self, selected_path):
842
+ if not self.bt_data:
843
+ return
844
+
845
+ selected = Path(selected_path).expanduser()
846
+ browse_dir = selected if selected.is_dir() else selected.parent
847
+ if browse_dir.exists() and browse_dir.is_dir():
848
+ self.bt_data.set_last_browse_dir(str(browse_dir))
849
+
787
850
  def process_selected_file(self, result, dialog):
788
851
  """Handle file name modification, extension adding, and GeoPackage logic."""
789
852
  base_name = str(Path(result).with_suffix(""))
@@ -829,6 +892,7 @@ class FileSelector(QtWidgets.QWidget):
829
892
  return
830
893
  result = file_names[0]
831
894
  result = self.process_selected_file(result, dialog)
895
+ self._save_last_browse_dir(result)
832
896
  self.handle_gpkg_selection(result)
833
897
  except Exception as e:
834
898
  print(e)
@@ -1066,13 +1130,17 @@ class OptionsInput(QtWidgets.QWidget):
1066
1130
  self.combobox.addItems(self.option_list)
1067
1131
  self.combobox.setCurrentIndex(default_index)
1068
1132
 
1133
+ self.combobox.setFixedWidth(BT_NUMERIC_BOX_WIDTH)
1069
1134
  self.layout = QtWidgets.QHBoxLayout()
1135
+ self.layout.setAlignment(QtCore.Qt.AlignLeft)
1070
1136
  self.layout.addWidget(self.label)
1071
1137
  self.layout.addWidget(self.combobox)
1072
1138
  self.setLayout(self.layout)
1073
1139
 
1074
1140
  def set_inline_mode(self, unit=""):
1075
1141
  self.label.setVisible(False)
1142
+ self.label.setMinimumWidth(0)
1143
+ self.label.setMaximumWidth(0)
1076
1144
  self.layout.setContentsMargins(0, 0, 0, 0)
1077
1145
 
1078
1146
  def selection_change(self, i):
@@ -1146,7 +1214,10 @@ class NumericInput(QtWidgets.QWidget):
1146
1214
  except (ValueError, TypeError):
1147
1215
  self.data_input.setValue(0)
1148
1216
  self.data_input.valueChanged.connect(self.update_value)
1217
+ self.data_input.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed)
1218
+ self.data_input.setFixedWidth(BT_NUMERIC_BOX_WIDTH)
1149
1219
  self.layout = QtWidgets.QHBoxLayout()
1220
+ self.layout.setAlignment(QtCore.Qt.AlignLeft)
1150
1221
  self.layout.addWidget(self.label)
1151
1222
  self.layout.addWidget(self.data_input)
1152
1223
  self.layout.addWidget(self.unit_label)
@@ -1157,7 +1228,10 @@ class NumericInput(QtWidgets.QWidget):
1157
1228
 
1158
1229
  def set_inline_mode(self, unit=""):
1159
1230
  self.label.setVisible(False)
1231
+ self.label.setMinimumWidth(0)
1232
+ self.label.setMaximumWidth(0)
1160
1233
  self.layout.setContentsMargins(0, 0, 0, 0)
1234
+ self.layout.setAlignment(QtCore.Qt.AlignLeft)
1161
1235
  if unit:
1162
1236
  self.unit = unit
1163
1237
  self.unit_label.setText(unit)
@@ -521,12 +521,14 @@ def _choose_anchor(endpoint_a, endpoint_b):
521
521
  line_id_b = _parse_line_id(endpoint_b.get("line_id"))
522
522
 
523
523
  score_a = (
524
+ -int(endpoint_a.get("degree", 0)),
524
525
  -endpoint_a["line_length"],
525
526
  0 if line_id_a is not None else 1,
526
527
  line_id_a if line_id_a is not None else float("inf"),
527
528
  endpoint_a["discovery_order"],
528
529
  )
529
530
  score_b = (
531
+ -int(endpoint_b.get("degree", 0)),
530
532
  -endpoint_b["line_length"],
531
533
  0 if line_id_b is not None else 1,
532
534
  line_id_b if line_id_b is not None else float("inf"),
@@ -584,7 +586,7 @@ def _snap_close_endpoints(gdf, tolerance):
584
586
  if len(endpoints) < 2:
585
587
  return gdf
586
588
 
587
- candidate_pairs = []
589
+ neighbor_graph = {idx: set() for idx in range(len(endpoints))}
588
590
  for i in range(len(endpoints)):
589
591
  for j in range(i + 1, len(endpoints)):
590
592
  ep_i = endpoints[i]
@@ -605,34 +607,55 @@ def _snap_close_endpoints(gdf, tolerance):
605
607
  if dist <= close_threshold:
606
608
  continue
607
609
  if dist <= snap_threshold:
608
- candidate_pairs.append((dist, ep_i, ep_j))
610
+ neighbor_graph[i].add(j)
611
+ neighbor_graph[j].add(i)
609
612
 
610
- candidate_pairs.sort(
611
- key=lambda x: (
612
- x[0],
613
- min(x[1]["discovery_order"], x[2]["discovery_order"]),
614
- max(x[1]["discovery_order"], x[2]["discovery_order"]),
615
- )
616
- )
613
+ if not any(neighbor_graph.values()):
614
+ return gdf
617
615
 
618
- updates = {}
619
- moved_keys = set()
620
- anchor_locked_keys = set()
621
- for _, ep_i, ep_j in candidate_pairs:
622
- anchor, mover = _choose_anchor(ep_i, ep_j)
623
- anchor_key = (anchor["gdf_index"], anchor["endpoint_idx"])
624
- mover_key = (mover["gdf_index"], mover["endpoint_idx"])
625
-
626
- if mover_key in moved_keys:
627
- continue
628
- if mover_key in anchor_locked_keys:
629
- continue
630
- if anchor_key in moved_keys:
616
+ for idx, endpoint in enumerate(endpoints):
617
+ endpoint["degree"] = len(neighbor_graph[idx])
618
+
619
+ visited = set()
620
+ components = []
621
+ active_nodes = [idx for idx, neighbors in neighbor_graph.items() if neighbors]
622
+ for node in active_nodes:
623
+ if node in visited:
631
624
  continue
625
+ stack = [node]
626
+ component = []
627
+ while stack:
628
+ curr = stack.pop()
629
+ if curr in visited:
630
+ continue
631
+ visited.add(curr)
632
+ component.append(curr)
633
+ for neighbor in neighbor_graph[curr]:
634
+ if neighbor not in visited:
635
+ stack.append(neighbor)
636
+
637
+ if len(component) > 1:
638
+ components.append(component)
639
+
640
+ if not components:
641
+ return gdf
632
642
 
633
- updates[mover_key] = tuple(anchor["point"].coords[0])
634
- moved_keys.add(mover_key)
635
- anchor_locked_keys.add(anchor_key)
643
+ updates = {}
644
+ for component in components:
645
+ anchor_endpoint = endpoints[component[0]]
646
+ for idx in component[1:]:
647
+ candidate = endpoints[idx]
648
+ anchor_endpoint, _ = _choose_anchor(anchor_endpoint, candidate)
649
+
650
+ anchor_coord = tuple(anchor_endpoint["point"].coords[0])
651
+ anchor_key = (anchor_endpoint["gdf_index"], anchor_endpoint["endpoint_idx"])
652
+
653
+ for idx in component:
654
+ endpoint = endpoints[idx]
655
+ endpoint_key = (endpoint["gdf_index"], endpoint["endpoint_idx"])
656
+ if endpoint_key == anchor_key:
657
+ continue
658
+ updates[endpoint_key] = anchor_coord
636
659
 
637
660
  if not updates:
638
661
  return gdf
@@ -1038,6 +1061,8 @@ def check_seed_line(
1038
1061
  raise ValueError("Input line and raster CRS do not match.")
1039
1062
 
1040
1063
  gdf = gpd.read_file(in_file, layer=in_layer)
1064
+ if config.clip_to_chm_footprint and in_raster and not _seedlines_within_chm_footprint(gdf, in_raster):
1065
+ raise ValueError("Input line(s) do not overlap input raster.")
1041
1066
  if "fid" in gdf.columns:
1042
1067
  gdf = gdf.rename(columns={"fid": "orig_fid"})
1043
1068
  input_count = len(gdf)
File without changes
File without changes
File without changes
File without changes