11import json
22from collections import defaultdict
3+ from functools import partial
34from pathlib import Path
45from typing import Optional
56
@@ -119,6 +120,13 @@ def __init__(self, *, project=None, mapCanvas=None, logger=None):
119120 self ._model_crs = None
120121 self ._use_project_crs = True
121122 self .model_crs_callback = None
123+ # layer id -> (layer, slots) for each input layer whose data this
124+ # manager listens to; see `refresh_layer_watchers`.
125+ self ._watched_layers = {}
126+ # ids of the watched layers whose data changed after the model
127+ # data was last read from them
128+ self ._changed_layer_ids = set ()
129+ self ._layer_data_changed_callbacks = []
122130
123131 def onSaveProject (self ):
124132 """Save project data."""
@@ -433,6 +441,7 @@ def set_basal_contacts(self, basal_contacts, unitname_field=None, use_z_coordina
433441 self .update_stratigraphy ()
434442 if self .basal_contacts_callback :
435443 self .basal_contacts_callback (** self ._basal_contacts )
444+ self .refresh_layer_watchers ()
436445
437446 def calculate_unique_basal_units (self ):
438447 if (
@@ -806,6 +815,156 @@ def get_input_layers(self):
806815 layers [role ] = layer
807816 return layers
808817
818+ def _manual_foliation_layer_rows (self ):
819+ """Yield `(layer, layer_data)` for each input layer of the foliations
820+ the user added (`GeologicalModelManager.manual_foliations`).
821+
822+ A foliation loaded from a state file has no QGIS layer object, so
823+ its layer is found again by name.
824+ """
825+ if self ._model_manager is None :
826+ return
827+ for spec in self ._model_manager .manual_foliations .values ():
828+ for key , layer_data in spec ['data' ].items ():
829+ if layer_data .get ('processed' ):
830+ continue
831+ layer = layer_data .get ('layer' )
832+ if layer is None :
833+ layer = self .find_layer_by_name (layer_data .get ('layer_name' , key ))
834+ if layer is not None :
835+ yield layer , layer_data
836+
837+ def _layers_to_watch (self ):
838+ """Return {layer id: layer} for every layer the model reads data from."""
839+ layers = list (self .get_input_layers ().values ())
840+ for entries in self .feature_data .values ():
841+ for entry in entries .values ():
842+ if not entry .get ('processed' ) and entry .get ('layer' ) is not None :
843+ layers .append (entry ['layer' ])
844+ layers .extend (layer for layer , _ in self ._manual_foliation_layer_rows ())
845+ watched = {}
846+ for layer in layers :
847+ try :
848+ if isinstance (layer , QgsVectorLayer ) and layer .isValid ():
849+ watched [layer .id ()] = layer
850+ except RuntimeError :
851+ # underlying C++ layer was deleted
852+ continue
853+ return watched
854+
855+ def refresh_layer_watchers (self ):
856+ """Listen for data changes on each layer the model reads data from,
857+ and stop listening to the layers it no longer uses.
858+
859+ An edit in the edit buffer, a commit, a new data source or a reload
860+ flags the layer as changed, see `get_changed_layers`. A change
861+ written straight to the data provider (not through the layer) emits
862+ none of these signals.
863+ """
864+ wanted = self ._layers_to_watch ()
865+ for layer_id in list (self ._watched_layers ):
866+ if layer_id not in wanted :
867+ self ._unwatch_layer (layer_id )
868+ for layer_id , layer in wanted .items ():
869+ if layer_id in self ._watched_layers :
870+ continue
871+ on_changed = partial (self ._on_layer_data_changed , layer_id )
872+ on_deleted = partial (self ._unwatch_layer , layer_id )
873+ for signal in self ._layer_change_signals (layer ):
874+ signal .connect (on_changed )
875+ layer .willBeDeleted .connect (on_deleted )
876+ self ._watched_layers [layer_id ] = (layer , on_changed , on_deleted )
877+
878+ @staticmethod
879+ def _layer_change_signals (layer ):
880+ return (
881+ layer .layerModified ,
882+ layer .afterCommitChanges ,
883+ layer .dataSourceChanged ,
884+ layer .dataChanged ,
885+ )
886+
887+ def _unwatch_layer (self , layer_id ):
888+ layer , on_changed , on_deleted = self ._watched_layers .pop (layer_id )
889+ if layer_id in self ._changed_layer_ids :
890+ self ._changed_layer_ids .discard (layer_id )
891+ self ._notify_layer_data_changed ()
892+ try :
893+ for signal in self ._layer_change_signals (layer ):
894+ signal .disconnect (on_changed )
895+ layer .willBeDeleted .disconnect (on_deleted )
896+ except (RuntimeError , TypeError ):
897+ # the layer was deleted, or the slot was not connected
898+ pass
899+
900+ def _on_layer_data_changed (self , layer_id ):
901+ self ._changed_layer_ids .add (layer_id )
902+ self ._notify_layer_data_changed ()
903+
904+ def _notify_layer_data_changed (self ):
905+ for callback in self ._layer_data_changed_callbacks :
906+ try :
907+ callback ()
908+ except Exception as e :
909+ self .logger (message = f"Error in layer data changed callback: { e } " , log_level = 2 )
910+
911+ def add_layer_data_changed_callback (self , callback ):
912+ """Call `callback()` when a watched layer changes, and when the
913+ changed layers are read again."""
914+ self ._layer_data_changed_callbacks .append (callback )
915+
916+ def get_changed_layers (self ):
917+ """Return the names of the input layers whose data changed after the
918+ model data was last read from them."""
919+ return sorted (self ._watched_layers [i ][0 ].name () for i in self ._changed_layer_ids )
920+
921+ def reload_changed_layers (self ):
922+ """Read the model data again from the input layers that changed.
923+
924+ This only updates the data that the model manager holds (the
925+ stratigraphy, the faults and the data of the foliations the user
926+ added). Initialize Model uses this data, and
927+ `GeologicalModelManager.refresh_feature_data` puts it into the
928+ existing features.
929+ """
930+ changed = set (self ._changed_layer_ids )
931+ if not changed :
932+ return
933+
934+ def is_changed (config ):
935+ layer = config .get ('layer' ) if config else None
936+ try :
937+ return layer is not None and layer .id () in changed
938+ except RuntimeError :
939+ return False
940+
941+ if is_changed (self ._basal_contacts ):
942+ self .calculate_unique_basal_units ()
943+ if is_changed (self ._basal_contacts ) or is_changed (self ._structural_orientations ):
944+ self .update_stratigraphy ()
945+ if is_changed (self ._fault_traces ):
946+ self .update_faults ()
947+ model_crs = self .get_model_crs ()
948+ for layer , layer_data in self ._manual_foliation_layer_rows ():
949+ if layer .id () in changed :
950+ layer_data ['df' ] = qgsLayerToGeoDataFrame (layer , target_crs = model_crs )
951+ self ._changed_layer_ids .clear ()
952+ self ._notify_layer_data_changed ()
953+
954+ def refresh_model_data (self ):
955+ """Read the changed input layers again and put their data into the
956+ features already in the model, without Initialize Model.
957+
958+ Returns
959+ -------
960+ dict
961+ See `GeologicalModelManager.refresh_feature_data`.
962+ """
963+ if self ._model_manager is None :
964+ raise RuntimeError ("Model manager is not set." )
965+ self .reload_changed_layers ()
966+ return self ._model_manager .refresh_feature_data ()
967+
809968 def get_layers_outside_bounding_box (self ):
810969 """Check which input layers do not overlap the bounding box in XY.
811970
@@ -897,6 +1056,7 @@ def set_fault_trace_layer(
8971056 self .update_faults ()
8981057 if self .fault_traces_callback :
8991058 self .fault_traces_callback (** self ._fault_traces )
1059+ self .refresh_layer_watchers ()
9001060
9011061 def get_fault_traces (self ) -> Optional [FaultTracesConfig ]:
9021062 """Get the fault traces."""
@@ -922,6 +1082,7 @@ def set_structural_orientations(
9221082 if self .structural_orientations_callback :
9231083 self .structural_orientations_callback (** self ._structural_orientations )
9241084 self .update_stratigraphy ()
1085+ self .refresh_layer_watchers ()
9251086
9261087 def get_structural_orientations (self ) -> Optional [StructuralOrientationsConfig ]:
9271088 """Get the structural orientations."""
@@ -1140,6 +1301,9 @@ def reset(self):
11401301 self ._model_manager .update_bounding_box (self ._bounding_box )
11411302 self ._model_manager .set_dem_function (self .dem_function )
11421303
1304+ self ._changed_layer_ids .clear ()
1305+ self .refresh_layer_watchers ()
1306+
11431307 self .logger (message = "Application state reset." , log_level = 3 )
11441308
11451309 def save_state (self , filepath ):
@@ -1163,6 +1327,7 @@ def save_state(self, filepath):
11631327 model_path = path .parent / f"{ path .name } .model"
11641328 self ._model_manager .save_model (str (model_path ))
11651329 state ['model_file' ] = model_path .name
1330+ state ['manual_foliations' ] = self ._model_manager .manual_foliations_to_dict ()
11661331
11671332 with open (path , 'w' ) as f :
11681333 json .dump (state , f , indent = 2 )
@@ -1191,6 +1356,13 @@ def load_state(self, filepath):
11911356 if model_filename and self ._model_manager is not None :
11921357 model_path = path .parent / model_filename
11931358 self ._model_manager .load_model (str (model_path ))
1359+ if self ._model_manager is not None :
1360+ # the pickled model already has these features; this lets
1361+ # Initialize Model build them again
1362+ self ._model_manager .manual_foliations_from_dict (state .get ('manual_foliations' , {}))
1363+ # the data was just read from the layers
1364+ self ._changed_layer_ids .clear ()
1365+ self .refresh_layer_watchers ()
11941366
11951367 self .logger (message = f"Loaded application state from '{ path } '." , log_level = 3 )
11961368
@@ -1466,6 +1638,7 @@ def update_feature_data(self, feature_name: str, feature_data: dict):
14661638 raise ValueError ("feature_data must be a dictionary." )
14671639 self .feature_data [feature_name ][feature_data ['layer_name' ]] = feature_data
14681640 self .logger (message = f"Updated feature data for '{ feature_name } '." )
1641+ self .refresh_layer_watchers ()
14691642
14701643 def set_widget_settings (self , widget_name : str , settings : dict ):
14711644 """Store widget settings for persistence."""
@@ -1505,5 +1678,6 @@ def add_foliation_to_model(
15051678 restrict_to_stratigraphic_domain = restrict_to_stratigraphic_domain ,
15061679 )
15071680 self .logger (message = f"Added foliation '{ foliation_name } ' to the model." )
1681+ self .refresh_layer_watchers ()
15081682 else :
15091683 raise RuntimeError ("Model manager is not set." )
0 commit comments