22"""
33/***************************************************************************
44 ThRasE
5-
5+
66 A powerful and fast thematic raster editor Qgis plugin
77 -------------------
88 copyright : (C) 2019-2025 by Xavier Corredor Llano, SMByC
1818 * *
1919 ***************************************************************************/
2020"""
21-
21+ import itertools
2222import os
2323
2424from qgis .PyQt .QtGui import QColor
@@ -61,7 +61,7 @@ def __init__(self, idx, group_idx, center_x, center_y, px_size_x, px_size_y, mem
6161 feature = QgsFeature (memory_layer .fields ())
6262 feature .setGeometry (geom )
6363 feature .setAttributes ([self .idx , self .group_idx , center_x , center_y ])
64-
64+
6565 # add feature to memory layer
6666 memory_layer .dataProvider ().addFeature (feature )
6767 self .feature_id = feature .id ()
@@ -89,7 +89,7 @@ def show(self):
8989 # display tile borders without fill by applying filter to memory layer
9090 if not self .tiles :
9191 return
92-
92+
9393 # set filter to show only tiles from this group
9494 filter_expr = f'"group_idx" = { self .idx } '
9595 self .memory_layer .setSubsetString (filter_expr )
@@ -108,11 +108,11 @@ def center(self):
108108 tiles_extent = self .tiles_extent ()
109109 if tiles_extent .isEmpty ():
110110 return
111-
111+
112112 # get the center point of the group
113113 center_x = tiles_extent .center ().x ()
114114 center_y = tiles_extent .center ().y ()
115-
115+
116116 # center all active views on this point without changing scale
117117 for view_widget in ThRasEDialog .view_widgets :
118118 if not view_widget .is_active :
@@ -135,11 +135,11 @@ def __init__(self, layer_to_edit):
135135 self .current_group = None
136136 self .tiles_color = QColor ("#ff00ff" )
137137 self .enabled = True
138-
138+
139139 # create memory vector layer for tiles
140140 self .memory_layer = None
141141 self .create_memory_layer ()
142-
142+
143143 # single renderer for both display modes
144144 self .renderer = None
145145 self .setup_renderer ()
@@ -148,11 +148,11 @@ def create_memory_layer(self):
148148 """Create memory vector layer to store tile geometries."""
149149 crs = self .layer_to_edit .qgs_layer .crs ()
150150 self .memory_layer = QgsVectorLayer (
151- f"Polygon?crs={ crs .authid ()} " ,
152- "ThRasE Registry" ,
151+ f"Polygon?crs={ crs .authid ()} " ,
152+ "ThRasE Registry" ,
153153 "memory"
154154 )
155-
155+
156156 # add fields
157157 provider = self .memory_layer .dataProvider ()
158158 provider .addAttributes ([
@@ -162,19 +162,19 @@ def create_memory_layer(self):
162162 QgsField ("center_y" , QVariant .Double ),
163163 ])
164164 self .memory_layer .updateFields ()
165-
165+
166166 # set initial state (hidden by default)
167167 self .memory_layer .setSubsetString ("FALSE" ) # hide all initially
168168
169169 def setup_renderer (self , color = None ):
170170 """Setup single renderer for draw the registry tiles.
171-
171+
172172 Args:
173173 color: QColor to use for border. If None, uses the default color
174174 """
175175 if color is None :
176176 color = self .tiles_color
177-
177+
178178 # Simple border renderer - no fill, 0.3 border width
179179 border_symbol = QgsFillSymbol .createSimple ({
180180 'color' : 'transparent' ,
@@ -184,7 +184,7 @@ def setup_renderer(self, color=None):
184184 'outline_style' : 'solid'
185185 })
186186 self .renderer = QgsSingleSymbolRenderer (border_symbol )
187-
187+
188188 def update_registry_layer_in_canvases (self ):
189189 """Refresh render layers in all active canvases to update registry layer visibility."""
190190 from ThRasE .gui .main_dialog import ThRasEDialog
@@ -196,7 +196,7 @@ def delete(self):
196196 self .clear ()
197197 self .groups = []
198198 self .current_group = None
199-
199+
200200 # clear memory layer
201201 if self .memory_layer :
202202 self .memory_layer .dataProvider ().truncate ()
@@ -207,7 +207,7 @@ def clear(self):
207207 if self .memory_layer :
208208 self .memory_layer .setSubsetString ("FALSE" )
209209 self .memory_layer .triggerRepaint ()
210-
210+
211211 def refresh_all_canvases (self ):
212212 """Refresh all active view widget canvases."""
213213 from ThRasE .gui .main_dialog import ThRasEDialog
@@ -219,13 +219,13 @@ def show_all(self):
219219 """Display all tiles with border."""
220220 if not self .groups or not self .memory_layer :
221221 return
222-
222+
223223 # ensure layer is in canvases
224224 self .update_registry_layer_in_canvases ()
225-
225+
226226 # apply renderer
227227 self .memory_layer .setRenderer (self .renderer .clone ())
228-
228+
229229 # show all features (remove filter)
230230 self .memory_layer .setSubsetString ("" )
231231 self .memory_layer .triggerRepaint ()
@@ -238,12 +238,12 @@ def clear_show_all(self):
238238
239239 # check if registry widget is visible and enabled, and if we have a current group
240240 from ThRasE .thrase import ThRasE
241- registry_visible = (ThRasE .dialog and
242- ThRasE .dialog .registry_widget and
243- ThRasE .dialog .registry_widget .isVisible () and
244- self .enabled and
241+ registry_visible = (ThRasE .dialog and
242+ ThRasE .dialog .registry_widget and
243+ ThRasE .dialog .registry_widget .isVisible () and
244+ self .enabled and
245245 self .current_group )
246-
246+
247247 if registry_visible :
248248 # restore current group display
249249 self .memory_layer .setRenderer (self .renderer .clone ())
@@ -252,92 +252,178 @@ def clear_show_all(self):
252252 else :
253253 # hide all features
254254 self .memory_layer .setSubsetString ("FALSE" )
255-
255+
256256 self .memory_layer .triggerRepaint ()
257257 self .refresh_all_canvases ()
258258
259259 def update_color (self ):
260260 """Update the border color for current display."""
261261 # recreate renderer with current color
262262 self .setup_renderer (self .tiles_color )
263-
263+
264264 # if currently displaying something, update renderer
265265 if self .memory_layer and self .memory_layer .subsetString () != "FALSE" :
266266 self .memory_layer .setRenderer (self .renderer .clone ())
267267 self .memory_layer .triggerRepaint ()
268268 self .refresh_all_canvases ()
269269
270- def update (self ):
271- # clear previous
272- self .delete ()
273-
274- # recreate memory layer
275- self .create_memory_layer ()
276- self .setup_renderer ()
270+ def update (self , force_rebuild = False ):
271+ """Update registry state after pixel edits."""
272+ grouped_logs = {gid : list (logs ) for gid , logs
273+ in itertools .groupby ((pl for pl in self .layer_to_edit .pixel_log_store .values ()
274+ if pl .group_id is not None ), key = lambda pl : pl .group_id )}
277275
278- # group PixelLogs by group_id
279- group_id_to_logs = {}
280- for pixel_log in self .layer_to_edit .pixel_log_store .values ():
281- if pixel_log .group_id is None :
282- # skip logs without group id
283- continue
284- group_id_to_logs .setdefault (pixel_log .group_id , []).append (pixel_log )
276+ if not grouped_logs :
277+ if self .groups :
278+ self .delete ()
279+ return False
285280
286- if not group_id_to_logs :
281+ if force_rebuild :
282+ return self .add_registry_groups (grouped_logs , reset = True )
283+
284+ existing_ids = {group .group_id for group in self .groups }
285+ current_ids = set (grouped_logs .keys ())
286+
287+ removed_ids = existing_ids - current_ids
288+ new_ids = current_ids - existing_ids
289+
290+ changed = False
291+
292+ if removed_ids and self .remove_registry_groups (removed_ids ):
293+ changed = True
294+
295+ if new_ids and self .add_registry_groups (grouped_logs , group_ids = new_ids , reset = False ):
296+ changed = True
297+
298+ return changed or bool (self .groups )
299+
300+ def remove_registry_groups (self , group_ids ):
301+ """Remove registry groups to the memory layer."""
302+ if not self .memory_layer or not self .groups or not group_ids :
287303 return False
288304
289- # sort groups by edit_date
290- grouped = []
291- for gid , logs in group_id_to_logs .items ():
292- # sort pixels inside by edit_date
305+ target_ids = set (group_ids )
306+ to_remove = [group for group in self .groups if group .group_id in target_ids ]
307+ if not to_remove :
308+ return False
309+
310+ provider = self .memory_layer .dataProvider ()
311+ feature_ids = []
312+ for group in to_remove :
313+ feature_ids .extend (tile .feature_id for tile in group .tiles )
314+
315+ remaining_groups = [group for group in self .groups if group .group_id not in target_ids ]
316+ previous_current_id = self .current_group .group_id if self .current_group else None
317+ subset_before = self .memory_layer .subsetString ()
318+
319+ self .memory_layer .startEditing ()
320+ if feature_ids :
321+ provider .deleteFeatures (feature_ids )
322+
323+ attr_updates = {}
324+ group_idx_field = self .memory_layer .fields ().indexOf ("group_idx" )
325+ for new_idx , group in enumerate (remaining_groups , start = 1 ):
326+ if group .idx == new_idx :
327+ continue
328+ group .idx = new_idx
329+ for tile in group .tiles :
330+ tile .group_idx = new_idx
331+ if group_idx_field >= 0 :
332+ attr_updates .setdefault (tile .feature_id , {})[group_idx_field ] = new_idx
333+
334+ if attr_updates :
335+ provider .changeAttributeValues (attr_updates )
336+
337+ self .memory_layer .commitChanges ()
338+ self .memory_layer .updateExtents ()
339+
340+ self .groups = remaining_groups
341+
342+ if previous_current_id and previous_current_id not in target_ids :
343+ self .current_group = next ((g for g in remaining_groups if g .group_id == previous_current_id ), None )
344+ elif remaining_groups :
345+ self .current_group = remaining_groups [0 ]
346+ else :
347+ self .current_group = None
348+
349+ subset_after = subset_before
350+ if subset_before .startswith ('"group_idx"' ):
351+ if self .current_group :
352+ subset_after = f'"group_idx" = { self .current_group .idx } '
353+ else :
354+ subset_after = "FALSE"
355+ elif subset_before == "" and not self .groups :
356+ subset_after = "FALSE"
357+
358+ self .memory_layer .setSubsetString (subset_after )
359+ self .memory_layer .triggerRepaint ()
360+ self .refresh_all_canvases ()
361+
362+ return True
363+
364+ def add_registry_groups (self , group_id_to_logs , group_ids = None , reset = False ):
365+ """Add registry tile groups to the registry memory layer."""
366+ if reset :
367+ self .delete ()
368+ self .create_memory_layer ()
369+ self .setup_renderer ()
370+ next_idx = 1
371+ else :
372+ next_idx = len (self .groups ) + 1
373+
374+ entries = []
375+ iterable = (
376+ ((gid , group_id_to_logs .get (gid )) for gid in group_ids )
377+ if group_ids is not None else group_id_to_logs .items ()
378+ )
379+ for gid , logs in iterable :
380+ if not logs :
381+ continue
293382 logs_sorted = sorted (logs , key = lambda pl : pl .edit_date )
294383 first_date = logs_sorted [0 ].edit_date
295- grouped .append ((gid , first_date , logs_sorted ))
384+ entries .append ((gid , first_date , logs_sorted ))
385+
386+ if not entries :
387+ return False
296388
297- grouped .sort (key = lambda item : item [1 ])
389+ entries .sort (key = lambda item : item [1 ])
298390
299- # build RegistryTileGroup list
300391 psx = self .layer_to_edit .qgs_layer .rasterUnitsPerPixelX ()
301392 psy = self .layer_to_edit .qgs_layer .rasterUnitsPerPixelY ()
302393
303- # start editing mode for batch feature addition
304394 self .memory_layer .startEditing ()
305395
306- idx_group = 1
307- for gid , fdate , logs_sorted in grouped :
396+ for gid , fdate , logs_sorted in entries :
308397 tiles = []
309398 for idx , pl in enumerate (logs_sorted , start = 1 ):
310399 cx = pl .pixel .x ()
311400 cy = pl .pixel .y ()
312- tiles .append (RegistryTile (idx , idx_group , cx , cy , psx , psy , self .memory_layer ))
313- self .groups .append (RegistryTileGroup (idx_group , gid , fdate , tiles , self .memory_layer , self ))
314- idx_group += 1
401+ tiles .append (RegistryTile (idx , next_idx , cx , cy , psx , psy , self .memory_layer ))
402+ self .groups .append (RegistryTileGroup (next_idx , gid , fdate , tiles , self .memory_layer , self ))
403+ next_idx += 1
315404
316- # commit changes to memory layer
317405 self .memory_layer .commitChanges ()
318406 self .memory_layer .updateExtents ()
319407
320- # init current group
321- self .current_group = self .groups [0 ] if self .groups else None
322- if self .current_group is None :
323- return False
408+ if reset :
409+ self .current_group = self .groups [0 ] if self .groups else None
324410
325411 return True
326412
327413 def set_current_group (self , idx_group ):
328414 from ThRasE .thrase import ThRasE
329-
415+
330416 self .current_group = next ((g for g in self .groups if g .idx == idx_group ), None )
331417 if not self .current_group :
332418 return
333-
419+
334420 # ensure layer is in canvases
335421 self .update_registry_layer_in_canvases ()
336-
422+
337423 # apply renderer for current group
338424 if self .memory_layer :
339425 self .memory_layer .setRenderer (self .renderer .clone ())
340-
426+
341427 if ThRasE .dialog .registry_widget .autoCenter .isChecked ():
342428 self .current_group .center ()
343429 self .current_group .show ()
@@ -359,7 +445,7 @@ def export_registry(self, output_file_path):
359445 if ext not in [".gpkg" , ".shp" , ".geojson" ]:
360446 output_file_path = path + ".gpkg"
361447 ext = ".gpkg"
362-
448+
363449 driver_name = {".gpkg" : "GPKG" , ".shp" : "ESRI Shapefile" , ".geojson" : "GeoJSON" }.get (ext , "GPKG" )
364450
365451 # create mapping from UUID group_id
@@ -383,7 +469,7 @@ def export_registry(self, output_file_path):
383469 for pl in self .layer_to_edit .pixel_log_store .values ():
384470 cx = pl .pixel .x ()
385471 cy = pl .pixel .y ()
386-
472+
387473 # create geometry
388474 rect = QgsRectangle (cx - half_psx , cy - half_psy , cx + half_psx , cy + half_psy )
389475 geom = QgsGeometry .fromRect (rect )
0 commit comments