#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ PointCab Project Renamer v4.2 A GUI tool for renaming scans in PointCab projects with full scan names. Now with Batch Renamer and Project Merger support! LSDX-Struktur: - - - - # Wurzelelement - - - - 1.lsd - Previews/1.png Author: DeepAgent Date: 2026-01-14 """ import os import re import shutil import logging import tkinter as tk from tkinter import ttk, filedialog, messagebox, scrolledtext from datetime import datetime import xml.etree.ElementTree as ET from pathlib import Path import uuid import copy class PointCabRenamer: """Main application class for PointCab project renaming (Single Project Mode).""" def __init__(self, root, return_callback=None): self.root = root self.return_callback = return_callback self.root.title("PointCab Projekt Umbenenner - Einzelprojekt") self.root.geometry("850x750") self.root.minsize(700, 600) # Variables self.root_dir = tk.StringVar() self.pointcloud_dir = None self.lsdx_file = None self.preview_dir = None self.changes = [] self.logger = None self.cleanup_strings = [] # Load cleanup configuration self.load_cleanup_config() self.setup_ui() def load_cleanup_config(self): """Load cleanup strings from cluster_cleanup.txt.""" config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cluster_cleanup.txt") self.cleanup_strings = [] if os.path.exists(config_path): try: # Use utf-8-sig to handle BOM from Windows editors with open(config_path, 'r', encoding='utf-8-sig') as f: for line in f: # Strip whitespace including BOM characters line = line.strip() # Skip empty lines and comments if not line: continue if line.startswith('#'): continue # Remove any remaining BOM or special chars line = line.lstrip('\ufeff') if line: self.cleanup_strings.append(line) print(f"Cleanup-Konfiguration geladen: {len(self.cleanup_strings)} Einträge") except Exception as e: print(f"Fehler beim Laden der Cleanup-Konfiguration: {e}") def clean_cluster_name(self, name): """Remove cleanup strings from the cluster name.""" cleaned_name = name removed_strings = [] for cleanup_str in self.cleanup_strings: if cleanup_str in cleaned_name: removed_strings.append(cleanup_str) cleaned_name = cleaned_name.replace(cleanup_str, '') return cleaned_name, removed_strings def setup_ui(self): """Setup the user interface.""" # Main frame with padding main_frame = ttk.Frame(self.root, padding="10") main_frame.pack(fill=tk.BOTH, expand=True) # Back button if we have a return callback if self.return_callback: back_frame = ttk.Frame(main_frame) back_frame.pack(fill=tk.X, pady=(0, 10)) ttk.Button(back_frame, text="← Zurück zum Hauptmenü", command=self.go_back).pack(side=tk.LEFT) # Directory selection frame dir_frame = ttk.LabelFrame(main_frame, text="Projektverzeichnis", padding="5") dir_frame.pack(fill=tk.X, pady=(0, 10)) ttk.Entry(dir_frame, textvariable=self.root_dir, width=60).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 5)) ttk.Button(dir_frame, text="Durchsuchen...", command=self.select_directory).pack(side=tk.LEFT) ttk.Button(dir_frame, text="Analysieren", command=self.analyze_project).pack(side=tk.LEFT, padx=(5, 0)) # Info frame self.info_frame = ttk.LabelFrame(main_frame, text="Projektinformationen", padding="5") self.info_frame.pack(fill=tk.X, pady=(0, 10)) self.info_label = ttk.Label(self.info_frame, text="Bitte wählen Sie ein Projektverzeichnis aus.", wraplength=800) self.info_label.pack(fill=tk.X) # Cleanup config info frame cleanup_frame = ttk.LabelFrame(main_frame, text="Clustername-Bereinigung", padding="5") cleanup_frame.pack(fill=tk.X, pady=(0, 10)) cleanup_info = f"Strings die aus dem Clusternamen entfernt werden: {', '.join(self.cleanup_strings) if self.cleanup_strings else '(keine)'}\n" cleanup_info += f"📁 Konfigurationsdatei: cluster_cleanup.txt (im Programmverzeichnis)" self.cleanup_label = ttk.Label(cleanup_frame, text=cleanup_info, wraplength=800, foreground="gray") self.cleanup_label.pack(fill=tk.X) ttk.Button(cleanup_frame, text="Konfiguration neu laden", command=self.reload_cleanup_config).pack(side=tk.LEFT, pady=(5, 0)) # Changes preview frame preview_frame = ttk.LabelFrame(main_frame, text="Vorschau der Änderungen", padding="5") preview_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) self.preview_text = scrolledtext.ScrolledText(preview_frame, wrap=tk.WORD, height=20, font=('Consolas', 9)) self.preview_text.pack(fill=tk.BOTH, expand=True) self.preview_text.config(state=tk.DISABLED) # Status frame status_frame = ttk.Frame(main_frame) status_frame.pack(fill=tk.X, pady=(0, 10)) self.status_label = ttk.Label(status_frame, text="Bereit", foreground="gray") self.status_label.pack(side=tk.LEFT) self.progress = ttk.Progressbar(status_frame, mode='indeterminate', length=200) self.progress.pack(side=tk.RIGHT) # Buttons frame button_frame = ttk.Frame(main_frame) button_frame.pack(fill=tk.X) self.execute_btn = ttk.Button(button_frame, text="Änderungen ausführen", command=self.execute_changes, state=tk.DISABLED) self.execute_btn.pack(side=tk.RIGHT) ttk.Button(button_frame, text="Beenden", command=self.root.quit).pack(side=tk.LEFT) def go_back(self): """Return to main menu.""" if self.return_callback: self.return_callback() def reload_cleanup_config(self): """Reload cleanup configuration and update display.""" self.load_cleanup_config() cleanup_info = f"Strings die aus dem Clusternamen entfernt werden: {', '.join(self.cleanup_strings) if self.cleanup_strings else '(keine)'}\n" cleanup_info += f"📁 Konfigurationsdatei: cluster_cleanup.txt (im Programmverzeichnis)" self.cleanup_label.config(text=cleanup_info) messagebox.showinfo("Info", f"Konfiguration neu geladen.\n{len(self.cleanup_strings)} Cleanup-Strings gefunden.") def select_directory(self): """Open directory selection dialog.""" directory = filedialog.askdirectory(title="Stammverzeichnis auswählen") if directory: self.root_dir.set(directory) self.analyze_project() def setup_logging(self, log_dir): """Setup logging to file.""" log_file = os.path.join(log_dir, "renamer.log") # Create logger self.logger = logging.getLogger('PointCabRenamer') self.logger.setLevel(logging.INFO) # Clear existing handlers self.logger.handlers.clear() # File handler fh = logging.FileHandler(log_file, encoding='utf-8') fh.setLevel(logging.INFO) formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') fh.setFormatter(formatter) self.logger.addHandler(fh) return log_file def find_pointcloud_folder(self, root_dir): """Find the PointCloud folder in the root directory.""" root_name = os.path.basename(root_dir) expected_name = f"{root_name}_PointCloud" # Check for expected folder name expected_path = os.path.join(root_dir, expected_name) if os.path.isdir(expected_path): return expected_path # Search for any folder ending with _PointCloud for item in os.listdir(root_dir): item_path = os.path.join(root_dir, item) if os.path.isdir(item_path) and item.endswith("_PointCloud"): return item_path return None def find_lsdx_file(self, pointcloud_dir): """Find the LSDX file in the PointCloud directory.""" for item in os.listdir(pointcloud_dir): if item.endswith(".lsdx"): return os.path.join(pointcloud_dir, item) return None def get_file_mapping(self, file_list, extension, scan_prefix): """Create mapping for files with full scan names.""" # Extract numbers from filenames files_with_nums = [] for f in file_list: match = re.match(r'^(\d+)\.' + extension + '$', f) if match: num = int(match.group(1)) files_with_nums.append((num, f)) if not files_with_nums: return {}, 2 files_with_nums.sort(key=lambda x: x[0]) max_num = max(num for num, _ in files_with_nums) # Determine padding width if max_num >= 100: width = 3 else: width = 2 # Create mapping with full scan name mapping = {} for num, old_name in files_with_nums: new_num_str = str(num).zfill(width) new_name = f"{scan_prefix}_{new_num_str}.{extension}" if old_name != new_name: mapping[old_name] = new_name return mapping, width def get_scan_name_mappings(self, lsdx_file, width, scan_prefix): """Get scan name mappings from LSDX file.""" mappings = [] try: tree = ET.parse(lsdx_file) root = tree.getroot() # Find all scan elements with name attribute for element in root.findall(".//Element[@type='scan']"): old_name = element.get('name') if old_name: # Try to extract number from name match = re.match(r'^(\d+)$', old_name) if match: num = int(match.group(1)) new_num_str = str(num).zfill(width) new_name = f"{scan_prefix}_{new_num_str}" if old_name != new_name: mappings.append((old_name, new_name)) except Exception as e: print(f"Fehler beim Lesen der Scan-Namen: {e}") return mappings def analyze_project(self): """Analyze the selected project directory.""" root_dir = self.root_dir.get() if not root_dir or not os.path.isdir(root_dir): messagebox.showerror("Fehler", "Bitte wählen Sie ein gültiges Verzeichnis aus.") return self.update_status("Analysiere Projekt...") self.progress.start() self.changes = [] try: # Setup logging log_file = self.setup_logging(root_dir) self.logger.info(f"Analyse gestartet für: {root_dir}") # Find PointCloud folder self.pointcloud_dir = self.find_pointcloud_folder(root_dir) if not self.pointcloud_dir: messagebox.showerror("Fehler", "PointCloud-Ordner nicht gefunden!") self.progress.stop() self.update_status("Fehler: PointCloud-Ordner nicht gefunden") return # Find LSDX file self.lsdx_file = self.find_lsdx_file(self.pointcloud_dir) if not self.lsdx_file: messagebox.showerror("Fehler", "LSDX-Datei nicht gefunden!") self.progress.stop() self.update_status("Fehler: LSDX-Datei nicht gefunden") return # Find Previews folder self.preview_dir = os.path.join(self.pointcloud_dir, "Previews") if not os.path.isdir(self.preview_dir): self.preview_dir = None # Get root name and clean cluster name root_name = os.path.basename(root_dir) cluster_name, removed_strings = self.clean_cluster_name(root_name) scan_prefix = root_name # Update info info_text = f"Stammverzeichnis: {root_name}\n" info_text += f"PointCloud-Ordner: {os.path.basename(self.pointcloud_dir)}\n" info_text += f"LSDX-Datei: {os.path.basename(self.lsdx_file)}\n" info_text += f"Preview-Ordner: {'Gefunden' if self.preview_dir else 'Nicht gefunden'}\n" info_text += f"Clustername: {root_name} → {cluster_name}" if removed_strings: info_text += f" (entfernt: {', '.join(removed_strings)})" info_text += f"\nScan-Namen-Präfix: {scan_prefix} (unverändert)" self.info_label.config(text=info_text) # Collect LSD files lsd_files = [f for f in os.listdir(self.pointcloud_dir) if f.endswith('.lsd')] # Get mapping for LSD files lsd_mapping, lsd_width = self.get_file_mapping(lsd_files, 'lsd', scan_prefix) # Collect PNG files if preview folder exists png_mapping = {} png_width = lsd_width if self.preview_dir: png_files = [f for f in os.listdir(self.preview_dir) if f.endswith('.png')] png_mapping, png_width = self.get_file_mapping(png_files, 'png', scan_prefix) # Get scan name mappings scan_name_mappings = self.get_scan_name_mappings(self.lsdx_file, lsd_width, scan_prefix) # Build changes list preview_text = "=== GEPLANTE ÄNDERUNGEN ===\n\n" # LSDX Backup timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") backup_name = f"{os.path.basename(self.lsdx_file)}.backup_{timestamp}" preview_text += f"📁 BACKUP der LSDX-Datei:\n" preview_text += f" → {backup_name}\n\n" self.changes.append(('backup', self.lsdx_file, os.path.join(self.pointcloud_dir, backup_name))) # LSD file renames if lsd_mapping: preview_text += f"📄 LSD-Dateien umbenennen ({len(lsd_mapping)} Dateien):\n" for old, new in sorted(lsd_mapping.items(), key=lambda x: x[0]): preview_text += f" {old} → {new}\n" self.changes.append(('rename_lsd', os.path.join(self.pointcloud_dir, old), os.path.join(self.pointcloud_dir, new))) preview_text += "\n" else: preview_text += "📄 LSD-Dateien: Keine Umbenennung erforderlich\n\n" # PNG file renames if self.preview_dir and png_mapping: preview_text += f"🖼️ PNG-Dateien umbenennen ({len(png_mapping)} Dateien):\n" for old, new in sorted(png_mapping.items(), key=lambda x: x[0]): preview_text += f" {old} → {new}\n" self.changes.append(('rename_png', os.path.join(self.preview_dir, old), os.path.join(self.preview_dir, new))) preview_text += "\n" elif self.preview_dir: preview_text += "🖼️ PNG-Dateien: Keine Umbenennung erforderlich\n\n" else: preview_text += "🖼️ PNG-Dateien: Preview-Ordner nicht vorhanden\n\n" # Scan name changes if scan_name_mappings: preview_text += f"🏷️ Scan-Namen in LSDX aktualisieren ({len(scan_name_mappings)} Scans):\n" for old_name, new_name in scan_name_mappings[:10]: preview_text += f" name=\"{old_name}\" → name=\"{new_name}\"\n" if len(scan_name_mappings) > 10: preview_text += f" ... und {len(scan_name_mappings) - 10} weitere\n" preview_text += "\n" else: preview_text += "🏷️ Scan-Namen: Keine Änderungen erforderlich\n\n" # LSDX updates preview_text += "📝 LSDX-Datei aktualisieren:\n" preview_text += f" - Clustername: '{root_name}' → '{cluster_name}'" if removed_strings: preview_text += f" (entfernt: {', '.join(removed_strings)})" preview_text += "\n" preview_text += f" - Dateinamen in FilePath-Elementen mit vollständigem Scan-Namen\n" preview_text += f" - Beispiel: 1.lsd → {scan_prefix}_01.lsd\n" preview_text += f" - Scan-Namen mit Präfix '{scan_prefix}_' versehen\n" self.changes.append(('update_lsdx', self.lsdx_file, { 'cluster_name': cluster_name, 'scan_prefix': scan_prefix, 'original_name': root_name, 'removed_strings': removed_strings, 'lsd_width': lsd_width, 'png_width': png_width if self.preview_dir else lsd_width })) preview_text += f"\n=== ZUSAMMENFASSUNG ===\n" preview_text += f"Gesamtänderungen: {len(self.changes)}\n" if removed_strings: preview_text += f"Bereinigte Strings: {', '.join(removed_strings)}\n" preview_text += f"Log-Datei: {log_file}\n" # Update preview self.preview_text.config(state=tk.NORMAL) self.preview_text.delete(1.0, tk.END) self.preview_text.insert(tk.END, preview_text) self.preview_text.config(state=tk.DISABLED) # Enable execute button if there are changes if self.changes: self.execute_btn.config(state=tk.NORMAL) self.logger.info(f"Analyse abgeschlossen: {len(self.changes)} Änderungen geplant") self.logger.info(f"Clustername: {root_name} → {cluster_name}") self.logger.info(f"Scan-Namen-Präfix: {scan_prefix}") if removed_strings: self.logger.info(f"Entfernte Strings: {', '.join(removed_strings)}") self.update_status(f"Analyse abgeschlossen: {len(self.changes)} Änderungen geplant") except Exception as e: messagebox.showerror("Fehler", f"Fehler bei der Analyse:\n{str(e)}") if self.logger: self.logger.error(f"Analysefehler: {str(e)}") self.update_status("Fehler bei der Analyse") finally: self.progress.stop() def execute_changes(self): """Execute all planned changes.""" if not self.changes: messagebox.showinfo("Info", "Keine Änderungen zum Ausführen.") return # Confirmation dialog result = messagebox.askyesno( "Bestätigung", f"Möchten Sie wirklich {len(self.changes)} Änderungen ausführen?\n\n" "Ein Backup der LSDX-Datei wird automatisch erstellt." ) if not result: return self.update_status("Führe Änderungen aus...") self.progress.start() self.execute_btn.config(state=tk.DISABLED) try: self.logger.info("Starte Ausführung der Änderungen") # First pass: backup and collect rename operations renames_lsd = [] renames_png = [] lsdx_update = None for change in self.changes: if change[0] == 'backup': # Create backup _, src, dst = change shutil.copy2(src, dst) self.logger.info(f"Backup erstellt: {dst}") elif change[0] == 'rename_lsd': renames_lsd.append((change[1], change[2])) elif change[0] == 'rename_png': renames_png.append((change[1], change[2])) elif change[0] == 'update_lsdx': lsdx_update = change # Execute renames with temporary names to avoid conflicts def safe_rename(rename_list, file_type): """Rename files using temporary names to avoid conflicts.""" temp_suffix = f"_temp_{datetime.now().strftime('%Y%m%d%H%M%S')}" # First: rename to temporary names temp_mappings = [] for src, dst in rename_list: if os.path.exists(src): temp_name = src + temp_suffix os.rename(src, temp_name) temp_mappings.append((temp_name, dst)) self.logger.info(f"{file_type}: {os.path.basename(src)} → temp") # Second: rename from temporary to final names for temp_name, dst in temp_mappings: os.rename(temp_name, dst) self.logger.info(f"{file_type}: temp → {os.path.basename(dst)}") safe_rename(renames_lsd, "LSD") safe_rename(renames_png, "PNG") # Update LSDX file if lsdx_update: self.update_lsdx_file(lsdx_update[1], lsdx_update[2]) self.logger.info("Alle Änderungen erfolgreich ausgeführt") self.update_status("Alle Änderungen erfolgreich ausgeführt!") messagebox.showinfo("Erfolg", "Alle Änderungen wurden erfolgreich ausgeführt!") # Clear changes self.changes = [] self.preview_text.config(state=tk.NORMAL) self.preview_text.delete(1.0, tk.END) self.preview_text.insert(tk.END, "Alle Änderungen wurden erfolgreich ausgeführt.\n\nSie können nun ein neues Projekt auswählen.") self.preview_text.config(state=tk.DISABLED) except Exception as e: messagebox.showerror("Fehler", f"Fehler bei der Ausführung:\n{str(e)}") self.logger.error(f"Ausführungsfehler: {str(e)}") self.update_status("Fehler bei der Ausführung") self.execute_btn.config(state=tk.NORMAL) finally: self.progress.stop() def update_lsdx_file(self, lsdx_path, params): """Update the LSDX file with new filenames, cluster name, and scan names.""" cluster_name = params['cluster_name'] scan_prefix = params.get('scan_prefix', params.get('original_name', cluster_name)) original_name = params.get('original_name', cluster_name) removed_strings = params.get('removed_strings', []) lsd_width = params['lsd_width'] png_width = params['png_width'] # Parse XML tree = ET.parse(lsdx_path) root = tree.getroot() # Update cluster name for element in root.findall(".//Element[@type='cluster']"): old_cluster = element.get('name') element.set('name', cluster_name) self.logger.info(f"Clustername: '{old_cluster}' → '{cluster_name}'") if removed_strings: self.logger.info(f" Entfernte Strings: {', '.join(removed_strings)}") # Update scan names with prefix for element in root.findall(".//Element[@type='scan']"): old_name = element.get('name') if old_name: match = re.match(r'^(\d+)$', old_name) if match: num = int(match.group(1)) new_num_str = str(num).zfill(lsd_width) new_name = f"{scan_prefix}_{new_num_str}" element.set('name', new_name) self.logger.info(f"Scan-Name: '{old_name}' → '{new_name}'") # Update FilePath elements for filepath in root.findall(".//FilePath"): file_type = filepath.get('type') text = filepath.text if file_type == 'lsd' and text: match = re.match(r'^(\d+)\.lsd$', text) if match: num = int(match.group(1)) new_num_str = str(num).zfill(lsd_width) new_name = f"{scan_prefix}_{new_num_str}.lsd" filepath.text = new_name self.logger.info(f"LSDX FilePath: {text} → {new_name}") elif file_type == 'preview' and text: match = re.match(r'^Previews/(\d+)\.png$', text) if match: num = int(match.group(1)) new_num_str = str(num).zfill(png_width) new_name = f"Previews/{scan_prefix}_{new_num_str}.png" filepath.text = new_name self.logger.info(f"LSDX FilePath: {text} → {new_name}") # Write back tree.write(lsdx_path, encoding='utf-8', xml_declaration=True) # Add DOCTYPE back with open(lsdx_path, 'r', encoding='utf-8') as f: content = f.read() if '' not in content: content = content.replace( "", "\n" ) with open(lsdx_path, 'w', encoding='utf-8') as f: f.write(content) self.logger.info("LSDX-Datei erfolgreich aktualisiert") def update_status(self, message): """Update the status label.""" self.status_label.config(text=message) self.root.update_idletasks() class BatchRenamer: """Batch Renamer for processing multiple PointCab projects.""" def __init__(self, root, return_callback=None): self.root = root self.return_callback = return_callback self.root.title("PointCab Batch Renamer") self.root.geometry("900x700") self.root.minsize(800, 600) # Variables self.main_dir = tk.StringVar() self.projects = [] self.cleanup_strings = [] self.batch_logger = None self.processing = False # Load cleanup configuration self.load_cleanup_config() self.setup_ui() def load_cleanup_config(self): """Load cleanup strings from cluster_cleanup.txt.""" config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cluster_cleanup.txt") self.cleanup_strings = [] if os.path.exists(config_path): try: # Use utf-8-sig to handle BOM from Windows editors with open(config_path, 'r', encoding='utf-8-sig') as f: for line in f: # Strip whitespace including BOM characters line = line.strip() # Skip empty lines and comments if not line: continue if line.startswith('#'): continue # Remove any remaining BOM or special chars line = line.lstrip('\ufeff') if line: self.cleanup_strings.append(line) print(f"Cleanup-Konfiguration geladen: {len(self.cleanup_strings)} Einträge") except Exception as e: print(f"Fehler beim Laden der Cleanup-Konfiguration: {e}") def clean_cluster_name(self, name): """Remove cleanup strings from the cluster name.""" cleaned_name = name removed_strings = [] for cleanup_str in self.cleanup_strings: if cleanup_str in cleaned_name: removed_strings.append(cleanup_str) cleaned_name = cleaned_name.replace(cleanup_str, '') return cleaned_name, removed_strings def setup_ui(self): """Setup the batch renamer UI.""" main_frame = ttk.Frame(self.root, padding="10") main_frame.pack(fill=tk.BOTH, expand=True) # Back button if self.return_callback: back_frame = ttk.Frame(main_frame) back_frame.pack(fill=tk.X, pady=(0, 10)) ttk.Button(back_frame, text="← Zurück zum Hauptmenü", command=self.go_back).pack(side=tk.LEFT) # Directory selection dir_frame = ttk.LabelFrame(main_frame, text="Hauptverzeichnis auswählen", padding="5") dir_frame.pack(fill=tk.X, pady=(0, 10)) ttk.Entry(dir_frame, textvariable=self.main_dir, width=60).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 5)) ttk.Button(dir_frame, text="Durchsuchen...", command=self.select_directory).pack(side=tk.LEFT) ttk.Button(dir_frame, text="Projekte suchen", command=self.scan_for_projects).pack(side=tk.LEFT, padx=(5, 0)) # Projects list projects_frame = ttk.LabelFrame(main_frame, text="Gefundene PointCab-Projekte", padding="5") projects_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) # Treeview for projects columns = ('status', 'project', 'cluster', 'scans') self.projects_tree = ttk.Treeview(projects_frame, columns=columns, show='headings', height=10) self.projects_tree.heading('status', text='Status') self.projects_tree.heading('project', text='Projekt') self.projects_tree.heading('cluster', text='Clustername') self.projects_tree.heading('scans', text='Scans') self.projects_tree.column('status', width=100) self.projects_tree.column('project', width=250) self.projects_tree.column('cluster', width=150) self.projects_tree.column('scans', width=80) scrollbar = ttk.Scrollbar(projects_frame, orient=tk.VERTICAL, command=self.projects_tree.yview) self.projects_tree.configure(yscrollcommand=scrollbar.set) self.projects_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) # Info label self.info_label = ttk.Label(main_frame, text="Wählen Sie ein Hauptverzeichnis und klicken Sie auf 'Projekte suchen'.") self.info_label.pack(fill=tk.X, pady=(0, 10)) # Progress section progress_frame = ttk.LabelFrame(main_frame, text="Fortschritt", padding="5") progress_frame.pack(fill=tk.X, pady=(0, 10)) self.progress_var = tk.DoubleVar() self.progress_bar = ttk.Progressbar(progress_frame, variable=self.progress_var, maximum=100) self.progress_bar.pack(fill=tk.X, pady=(0, 5)) self.progress_label = ttk.Label(progress_frame, text="") self.progress_label.pack(fill=tk.X) # Log area log_frame = ttk.LabelFrame(main_frame, text="Verarbeitungslog", padding="5") log_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) self.log_text = scrolledtext.ScrolledText(log_frame, wrap=tk.WORD, height=10, font=('Consolas', 9)) self.log_text.pack(fill=tk.BOTH, expand=True) self.log_text.config(state=tk.DISABLED) # Buttons button_frame = ttk.Frame(main_frame) button_frame.pack(fill=tk.X) self.process_btn = ttk.Button(button_frame, text="Alle verarbeiten", command=self.process_all, state=tk.DISABLED) self.process_btn.pack(side=tk.RIGHT) ttk.Button(button_frame, text="Beenden", command=self.root.quit).pack(side=tk.LEFT) def go_back(self): """Return to main menu.""" if self.processing: messagebox.showwarning("Warnung", "Bitte warten Sie, bis die Verarbeitung abgeschlossen ist.") return if self.return_callback: self.return_callback() def select_directory(self): """Open directory selection dialog.""" directory = filedialog.askdirectory(title="Hauptverzeichnis auswählen") if directory: self.main_dir.set(directory) def scan_for_projects(self): """Scan the main directory for PointCab projects.""" main_dir = self.main_dir.get() if not main_dir or not os.path.isdir(main_dir): messagebox.showerror("Fehler", "Bitte wählen Sie ein gültiges Hauptverzeichnis aus.") return self.projects = [] self.projects_tree.delete(*self.projects_tree.get_children()) # Scan for subdirectories with _PointCloud folder for item in os.listdir(main_dir): item_path = os.path.join(main_dir, item) if os.path.isdir(item_path): pointcloud_folder = self.find_pointcloud_folder(item_path) if pointcloud_folder: lsdx_file = self.find_lsdx_file(pointcloud_folder) if lsdx_file: scan_count = self.count_lsd_files(pointcloud_folder) cluster_name, _ = self.clean_cluster_name(item) self.projects.append({ 'path': item_path, 'name': item, 'pointcloud': pointcloud_folder, 'lsdx': lsdx_file, 'cluster_name': cluster_name, 'scan_count': scan_count, 'status': 'Ausstehend' }) self.projects_tree.insert('', tk.END, values=( 'Ausstehend', item, cluster_name, scan_count )) if self.projects: self.info_label.config(text=f"{len(self.projects)} PointCab-Projekte gefunden.") self.process_btn.config(state=tk.NORMAL) else: self.info_label.config(text="Keine PointCab-Projekte gefunden.") self.process_btn.config(state=tk.DISABLED) def find_pointcloud_folder(self, root_dir): """Find the PointCloud folder in the root directory.""" root_name = os.path.basename(root_dir) expected_name = f"{root_name}_PointCloud" expected_path = os.path.join(root_dir, expected_name) if os.path.isdir(expected_path): return expected_path for item in os.listdir(root_dir): item_path = os.path.join(root_dir, item) if os.path.isdir(item_path) and item.endswith("_PointCloud"): return item_path return None def find_lsdx_file(self, pointcloud_dir): """Find the LSDX file in the PointCloud directory.""" for item in os.listdir(pointcloud_dir): if item.endswith(".lsdx"): return os.path.join(pointcloud_dir, item) return None def count_lsd_files(self, pointcloud_dir): """Count LSD files in directory.""" return len([f for f in os.listdir(pointcloud_dir) if f.endswith('.lsd')]) def setup_batch_logging(self, main_dir): """Setup batch summary logging.""" log_file = os.path.join(main_dir, "batch_summary.log") self.batch_logger = logging.getLogger('BatchRenamer') self.batch_logger.setLevel(logging.INFO) self.batch_logger.handlers.clear() fh = logging.FileHandler(log_file, encoding='utf-8') fh.setLevel(logging.INFO) formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') fh.setFormatter(formatter) self.batch_logger.addHandler(fh) return log_file def log_message(self, message, level='info'): """Add message to log display and batch log.""" self.log_text.config(state=tk.NORMAL) timestamp = datetime.now().strftime("%H:%M:%S") self.log_text.insert(tk.END, f"[{timestamp}] {message}\n") self.log_text.see(tk.END) self.log_text.config(state=tk.DISABLED) if self.batch_logger: if level == 'error': self.batch_logger.error(message) else: self.batch_logger.info(message) self.root.update_idletasks() def update_project_status(self, index, status): """Update project status in treeview.""" item_id = self.projects_tree.get_children()[index] values = list(self.projects_tree.item(item_id)['values']) values[0] = status self.projects_tree.item(item_id, values=values) self.root.update_idletasks() def process_all(self): """Process all found projects.""" if not self.projects: messagebox.showinfo("Info", "Keine Projekte zum Verarbeiten.") return result = messagebox.askyesno( "Bestätigung", f"Möchten Sie wirklich {len(self.projects)} Projekte verarbeiten?\n\n" "Ein Backup der LSDX-Dateien wird automatisch erstellt." ) if not result: return self.processing = True self.process_btn.config(state=tk.DISABLED) # Clear log self.log_text.config(state=tk.NORMAL) self.log_text.delete(1.0, tk.END) self.log_text.config(state=tk.DISABLED) # Setup batch logging batch_log = self.setup_batch_logging(self.main_dir.get()) self.log_message(f"=== Batch-Verarbeitung gestartet ===") self.log_message(f"Hauptverzeichnis: {self.main_dir.get()}") self.log_message(f"Anzahl Projekte: {len(self.projects)}") self.log_message(f"Batch-Log: {batch_log}") self.log_message("") success_count = 0 error_count = 0 for i, project in enumerate(self.projects): self.progress_var.set((i / len(self.projects)) * 100) self.progress_label.config(text=f"Verarbeite {i+1}/{len(self.projects)}: {project['name']}") self.update_project_status(i, 'Verarbeite...') self.log_message(f"--- Projekt: {project['name']} ---") try: self.process_single_project(project) self.update_project_status(i, '✓ Erfolgreich') self.log_message(f"✓ {project['name']} erfolgreich verarbeitet") success_count += 1 except Exception as e: self.update_project_status(i, '✗ Fehler') self.log_message(f"✗ Fehler bei {project['name']}: {str(e)}", 'error') error_count += 1 self.log_message("") self.progress_var.set(100) self.progress_label.config(text="Fertig") # Summary self.log_message("=== ZUSAMMENFASSUNG ===") self.log_message(f"Gesamt: {len(self.projects)}") self.log_message(f"Erfolgreich: {success_count}") self.log_message(f"Fehlgeschlagen: {error_count}") self.processing = False messagebox.showinfo( "Batch-Verarbeitung abgeschlossen", f"Verarbeitung abgeschlossen!\n\n" f"Erfolgreich: {success_count}\n" f"Fehlgeschlagen: {error_count}\n\n" f"Details siehe: {batch_log}" ) def process_single_project(self, project): """Process a single project without GUI interaction.""" root_dir = project['path'] pointcloud_dir = project['pointcloud'] lsdx_file = project['lsdx'] # Setup project-specific logging project_logger = logging.getLogger(f'Project_{project["name"]}') project_logger.setLevel(logging.INFO) project_logger.handlers.clear() log_file = os.path.join(root_dir, "renamer.log") fh = logging.FileHandler(log_file, encoding='utf-8') fh.setLevel(logging.INFO) formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') fh.setFormatter(formatter) project_logger.addHandler(fh) project_logger.info(f"Batch-Verarbeitung gestartet für: {root_dir}") # Get names root_name = os.path.basename(root_dir) cluster_name, removed_strings = self.clean_cluster_name(root_name) scan_prefix = root_name project_logger.info(f"Clustername: {root_name} → {cluster_name}") project_logger.info(f"Scan-Namen-Präfix: {scan_prefix}") if removed_strings: project_logger.info(f"Entfernte Strings: {', '.join(removed_strings)}") # Find preview dir preview_dir = os.path.join(pointcloud_dir, "Previews") if not os.path.isdir(preview_dir): preview_dir = None # Get LSD files and mapping lsd_files = [f for f in os.listdir(pointcloud_dir) if f.endswith('.lsd')] lsd_mapping, lsd_width = self.get_file_mapping(lsd_files, 'lsd', scan_prefix) # Get PNG files and mapping png_mapping = {} png_width = lsd_width if preview_dir: png_files = [f for f in os.listdir(preview_dir) if f.endswith('.png')] png_mapping, png_width = self.get_file_mapping(png_files, 'png', scan_prefix) # Create backup timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") backup_name = f"{os.path.basename(lsdx_file)}.backup_{timestamp}" backup_path = os.path.join(pointcloud_dir, backup_name) shutil.copy2(lsdx_file, backup_path) project_logger.info(f"Backup erstellt: {backup_name}") # Rename LSD files if lsd_mapping: self.safe_rename(pointcloud_dir, lsd_mapping, project_logger, "LSD") # Rename PNG files if preview_dir and png_mapping: self.safe_rename(preview_dir, png_mapping, project_logger, "PNG") # Update LSDX file self.update_lsdx_file(lsdx_file, { 'cluster_name': cluster_name, 'scan_prefix': scan_prefix, 'original_name': root_name, 'removed_strings': removed_strings, 'lsd_width': lsd_width, 'png_width': png_width }, project_logger) project_logger.info("Verarbeitung abgeschlossen") def get_file_mapping(self, file_list, extension, scan_prefix): """Create mapping for files with full scan names.""" files_with_nums = [] for f in file_list: match = re.match(r'^(\d+)\.' + extension + '$', f) if match: num = int(match.group(1)) files_with_nums.append((num, f)) if not files_with_nums: return {}, 2 files_with_nums.sort(key=lambda x: x[0]) max_num = max(num for num, _ in files_with_nums) width = 3 if max_num >= 100 else 2 mapping = {} for num, old_name in files_with_nums: new_num_str = str(num).zfill(width) new_name = f"{scan_prefix}_{new_num_str}.{extension}" if old_name != new_name: mapping[old_name] = new_name return mapping, width def safe_rename(self, directory, mapping, logger, file_type): """Rename files using temporary names to avoid conflicts.""" temp_suffix = f"_temp_{datetime.now().strftime('%Y%m%d%H%M%S')}" temp_mappings = [] for old_name, new_name in mapping.items(): src = os.path.join(directory, old_name) if os.path.exists(src): temp_name = src + temp_suffix os.rename(src, temp_name) temp_mappings.append((temp_name, os.path.join(directory, new_name))) logger.info(f"{file_type}: {old_name} → temp") for temp_name, dst in temp_mappings: os.rename(temp_name, dst) logger.info(f"{file_type}: temp → {os.path.basename(dst)}") def update_lsdx_file(self, lsdx_path, params, logger): """Update the LSDX file with new filenames, cluster name, and scan names.""" cluster_name = params['cluster_name'] scan_prefix = params.get('scan_prefix', params.get('original_name', cluster_name)) removed_strings = params.get('removed_strings', []) lsd_width = params['lsd_width'] png_width = params['png_width'] tree = ET.parse(lsdx_path) root = tree.getroot() # Update cluster name for element in root.findall(".//Element[@type='cluster']"): old_cluster = element.get('name') element.set('name', cluster_name) logger.info(f"Clustername: '{old_cluster}' → '{cluster_name}'") if removed_strings: logger.info(f" Entfernte Strings: {', '.join(removed_strings)}") # Update scan names with prefix for element in root.findall(".//Element[@type='scan']"): old_name = element.get('name') if old_name: match = re.match(r'^(\d+)$', old_name) if match: num = int(match.group(1)) new_num_str = str(num).zfill(lsd_width) new_name = f"{scan_prefix}_{new_num_str}" element.set('name', new_name) logger.info(f"Scan-Name: '{old_name}' → '{new_name}'") # Update FilePath elements for filepath in root.findall(".//FilePath"): file_type = filepath.get('type') text = filepath.text if file_type == 'lsd' and text: match = re.match(r'^(\d+)\.lsd$', text) if match: num = int(match.group(1)) new_num_str = str(num).zfill(lsd_width) new_name = f"{scan_prefix}_{new_num_str}.lsd" filepath.text = new_name logger.info(f"LSDX FilePath: {text} → {new_name}") elif file_type == 'preview' and text: match = re.match(r'^Previews/(\d+)\.png$', text) if match: num = int(match.group(1)) new_num_str = str(num).zfill(png_width) new_name = f"Previews/{scan_prefix}_{new_num_str}.png" filepath.text = new_name logger.info(f"LSDX FilePath: {text} → {new_name}") tree.write(lsdx_path, encoding='utf-8', xml_declaration=True) # Add DOCTYPE back with open(lsdx_path, 'r', encoding='utf-8') as f: content = f.read() if '' not in content: content = content.replace( "", "\n" ) with open(lsdx_path, 'w', encoding='utf-8') as f: f.write(content) logger.info("LSDX-Datei erfolgreich aktualisiert") class ProjectMerger: """Project Merger for combining multiple PointCab projects into one.""" def __init__(self, root, return_callback=None): self.root = root self.return_callback = return_callback self.root.title("PointCab Projektmerger") self.root.geometry("1000x800") self.root.minsize(900, 700) # Variables self.target_dir = tk.StringVar() self.source_dir = tk.StringVar() self.merge_mode = tk.StringVar(value="single") self.target_project = None self.source_projects = [] self.merge_logger = None self.processing = False self.setup_ui() def setup_ui(self): """Setup the project merger UI.""" main_frame = ttk.Frame(self.root, padding="10") main_frame.pack(fill=tk.BOTH, expand=True) # Back button if self.return_callback: back_frame = ttk.Frame(main_frame) back_frame.pack(fill=tk.X, pady=(0, 10)) ttk.Button(back_frame, text="← Zurück zum Hauptmenü", command=self.go_back).pack(side=tk.LEFT) # Target project selection target_frame = ttk.LabelFrame(main_frame, text="🎯 Stammprojekt (Ziel)", padding="5") target_frame.pack(fill=tk.X, pady=(0, 10)) target_entry_frame = ttk.Frame(target_frame) target_entry_frame.pack(fill=tk.X) ttk.Entry(target_entry_frame, textvariable=self.target_dir, width=60).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 5)) ttk.Button(target_entry_frame, text="Durchsuchen...", command=self.select_target).pack(side=tk.LEFT) ttk.Button(target_entry_frame, text="Analysieren", command=self.analyze_target).pack(side=tk.LEFT, padx=(5, 0)) self.target_info = ttk.Label(target_frame, text="Bitte Stammprojekt auswählen", foreground="gray") self.target_info.pack(fill=tk.X, pady=(5, 0)) # Mode selection mode_frame = ttk.LabelFrame(main_frame, text="Merge-Modus", padding="5") mode_frame.pack(fill=tk.X, pady=(0, 10)) ttk.Radiobutton(mode_frame, text="📄 Einzelprojekt hinzufügen", variable=self.merge_mode, value="single", command=self.on_mode_change).pack(side=tk.LEFT, padx=(0, 20)) ttk.Radiobutton(mode_frame, text="📂 Batch-Merge (mehrere Projekte)", variable=self.merge_mode, value="batch", command=self.on_mode_change).pack(side=tk.LEFT) # Source project selection source_frame = ttk.LabelFrame(main_frame, text="📁 Quellprojekt(e)", padding="5") source_frame.pack(fill=tk.X, pady=(0, 10)) source_entry_frame = ttk.Frame(source_frame) source_entry_frame.pack(fill=tk.X) ttk.Entry(source_entry_frame, textvariable=self.source_dir, width=60).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 5)) ttk.Button(source_entry_frame, text="Durchsuchen...", command=self.select_source).pack(side=tk.LEFT) ttk.Button(source_entry_frame, text="Projekte suchen", command=self.scan_source).pack(side=tk.LEFT, padx=(5, 0)) self.source_mode_label = ttk.Label(source_frame, text="Wählen Sie ein Quellprojekt aus", foreground="gray") self.source_mode_label.pack(fill=tk.X, pady=(5, 0)) # Source projects list projects_frame = ttk.LabelFrame(main_frame, text="Zu mergende Projekte", padding="5") projects_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) columns = ('status', 'project', 'scans', 'lsd_files', 'png_files') self.projects_tree = ttk.Treeview(projects_frame, columns=columns, show='headings', height=8) self.projects_tree.heading('status', text='Status') self.projects_tree.heading('project', text='Projekt') self.projects_tree.heading('scans', text='Scans') self.projects_tree.heading('lsd_files', text='LSD-Dateien') self.projects_tree.heading('png_files', text='PNG-Dateien') self.projects_tree.column('status', width=100) self.projects_tree.column('project', width=250) self.projects_tree.column('scans', width=80) self.projects_tree.column('lsd_files', width=100) self.projects_tree.column('png_files', width=100) scrollbar = ttk.Scrollbar(projects_frame, orient=tk.VERTICAL, command=self.projects_tree.yview) self.projects_tree.configure(yscrollcommand=scrollbar.set) self.projects_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) # Preview frame preview_frame = ttk.LabelFrame(main_frame, text="Vorschau der Merge-Operationen", padding="5") preview_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) self.preview_text = scrolledtext.ScrolledText(preview_frame, wrap=tk.WORD, height=10, font=('Consolas', 9)) self.preview_text.pack(fill=tk.BOTH, expand=True) self.preview_text.config(state=tk.DISABLED) # Progress section progress_frame = ttk.LabelFrame(main_frame, text="Fortschritt", padding="5") progress_frame.pack(fill=tk.X, pady=(0, 10)) self.progress_var = tk.DoubleVar() self.progress_bar = ttk.Progressbar(progress_frame, variable=self.progress_var, maximum=100) self.progress_bar.pack(fill=tk.X, pady=(0, 5)) self.progress_label = ttk.Label(progress_frame, text="") self.progress_label.pack(fill=tk.X) # Buttons button_frame = ttk.Frame(main_frame) button_frame.pack(fill=tk.X) self.preview_btn = ttk.Button(button_frame, text="Vorschau", command=self.show_preview, state=tk.DISABLED) self.preview_btn.pack(side=tk.RIGHT, padx=(5, 0)) self.merge_btn = ttk.Button(button_frame, text="🔀 Merge durchführen", command=self.execute_merge, state=tk.DISABLED) self.merge_btn.pack(side=tk.RIGHT) ttk.Button(button_frame, text="Beenden", command=self.root.quit).pack(side=tk.LEFT) def go_back(self): """Return to main menu.""" if self.processing: messagebox.showwarning("Warnung", "Bitte warten Sie, bis der Merge abgeschlossen ist.") return if self.return_callback: self.return_callback() def on_mode_change(self): """Handle mode change.""" if self.merge_mode.get() == "single": self.source_mode_label.config(text="Wählen Sie ein einzelnes Quellprojekt aus") else: self.source_mode_label.config(text="Wählen Sie ein Hauptverzeichnis mit mehreren Quellprojekten aus") self.source_projects = [] self.projects_tree.delete(*self.projects_tree.get_children()) self.update_buttons() def select_target(self): """Select target project directory.""" directory = filedialog.askdirectory(title="Stammprojekt auswählen") if directory: self.target_dir.set(directory) self.analyze_target() def select_source(self): """Select source project/directory.""" directory = filedialog.askdirectory(title="Quellprojekt(e) auswählen") if directory: self.source_dir.set(directory) def find_pointcloud_folder(self, root_dir): """Find the PointCloud folder in the root directory.""" root_name = os.path.basename(root_dir) expected_name = f"{root_name}_PointCloud" expected_path = os.path.join(root_dir, expected_name) if os.path.isdir(expected_path): return expected_path for item in os.listdir(root_dir): item_path = os.path.join(root_dir, item) if os.path.isdir(item_path) and item.endswith("_PointCloud"): return item_path return None def find_lsdx_file(self, pointcloud_dir): """Find the LSDX file in the PointCloud directory.""" for item in os.listdir(pointcloud_dir): if item.endswith(".lsdx"): return os.path.join(pointcloud_dir, item) return None def count_files(self, directory, extension): """Count files with given extension.""" if not directory or not os.path.isdir(directory): return 0 return len([f for f in os.listdir(directory) if f.endswith(extension)]) def get_files(self, directory, extension): """Get list of files with given extension.""" if not directory or not os.path.isdir(directory): return [] return [f for f in os.listdir(directory) if f.endswith(extension)] def analyze_target(self): """Analyze the target project.""" target_dir = self.target_dir.get() if not target_dir or not os.path.isdir(target_dir): messagebox.showerror("Fehler", "Bitte wählen Sie ein gültiges Stammprojekt aus.") return pointcloud_dir = self.find_pointcloud_folder(target_dir) if not pointcloud_dir: messagebox.showerror("Fehler", "Kein PointCloud-Ordner im Stammprojekt gefunden!") self.target_project = None self.update_buttons() return lsdx_file = self.find_lsdx_file(pointcloud_dir) if not lsdx_file: messagebox.showerror("Fehler", "Keine LSDX-Datei im Stammprojekt gefunden!") self.target_project = None self.update_buttons() return preview_dir = os.path.join(pointcloud_dir, "Previews") if not os.path.isdir(preview_dir): preview_dir = None lsd_count = self.count_files(pointcloud_dir, '.lsd') png_count = self.count_files(preview_dir, '.png') if preview_dir else 0 # Count scans in LSDX scan_count = 0 try: tree = ET.parse(lsdx_file) root = tree.getroot() scan_count = len(root.findall(".//Element[@type='scan']")) except: pass self.target_project = { 'path': target_dir, 'name': os.path.basename(target_dir), 'pointcloud': pointcloud_dir, 'preview': preview_dir, 'lsdx': lsdx_file, 'scan_count': scan_count, 'lsd_count': lsd_count, 'png_count': png_count } info = f"✓ {self.target_project['name']} | Scans: {scan_count} | LSD: {lsd_count} | PNG: {png_count}" self.target_info.config(text=info, foreground="green") self.update_buttons() def scan_source(self): """Scan source directory for projects.""" source_dir = self.source_dir.get() if not source_dir or not os.path.isdir(source_dir): messagebox.showerror("Fehler", "Bitte wählen Sie ein gültiges Quellverzeichnis aus.") return self.source_projects = [] self.projects_tree.delete(*self.projects_tree.get_children()) if self.merge_mode.get() == "single": # Single project mode project = self.analyze_source_project(source_dir) if project: self.source_projects.append(project) self.projects_tree.insert('', tk.END, values=( 'Bereit', project['name'], project['scan_count'], project['lsd_count'], project['png_count'] )) else: messagebox.showerror("Fehler", "Kein gültiges PointCab-Projekt gefunden!") else: # Batch mode - scan subdirectories for item in sorted(os.listdir(source_dir)): item_path = os.path.join(source_dir, item) if os.path.isdir(item_path): # Skip if this is the target project if self.target_project and os.path.samefile(item_path, self.target_project['path']): continue project = self.analyze_source_project(item_path) if project: self.source_projects.append(project) self.projects_tree.insert('', tk.END, values=( 'Bereit', project['name'], project['scan_count'], project['lsd_count'], project['png_count'] )) if self.source_projects: self.source_mode_label.config(text=f"{len(self.source_projects)} Quellprojekt(e) gefunden", foreground="green") else: self.source_mode_label.config(text="Keine gültigen Quellprojekte gefunden", foreground="red") self.update_buttons() def analyze_source_project(self, project_dir): """Analyze a source project and return its info.""" pointcloud_dir = self.find_pointcloud_folder(project_dir) if not pointcloud_dir: return None lsdx_file = self.find_lsdx_file(pointcloud_dir) if not lsdx_file: return None preview_dir = os.path.join(pointcloud_dir, "Previews") if not os.path.isdir(preview_dir): preview_dir = None lsd_count = self.count_files(pointcloud_dir, '.lsd') png_count = self.count_files(preview_dir, '.png') if preview_dir else 0 # Count scans in LSDX scan_count = 0 try: tree = ET.parse(lsdx_file) root = tree.getroot() scan_count = len(root.findall(".//Element[@type='scan']")) except: pass return { 'path': project_dir, 'name': os.path.basename(project_dir), 'pointcloud': pointcloud_dir, 'preview': preview_dir, 'lsdx': lsdx_file, 'scan_count': scan_count, 'lsd_count': lsd_count, 'png_count': png_count, 'lsd_files': self.get_files(pointcloud_dir, '.lsd'), 'png_files': self.get_files(preview_dir, '.png') if preview_dir else [] } def update_buttons(self): """Update button states based on current selection.""" if self.target_project and self.source_projects: self.preview_btn.config(state=tk.NORMAL) self.merge_btn.config(state=tk.NORMAL) else: self.preview_btn.config(state=tk.DISABLED) self.merge_btn.config(state=tk.DISABLED) def get_conflict_resolved_name(self, filename, existing_files, suffix_counter): """Get a conflict-resolved filename.""" base, ext = os.path.splitext(filename) new_name = filename while new_name in existing_files: suffix_counter[0] += 1 new_name = f"{base}_merged_{suffix_counter[0]}{ext}" return new_name def show_preview(self): """Show preview of merge operations.""" if not self.target_project or not self.source_projects: return preview_text = "=== MERGE-VORSCHAU ===\n\n" preview_text += f"🎯 STAMMPROJEKT: {self.target_project['name']}\n" preview_text += f" Aktuelle Scans: {self.target_project['scan_count']}\n" preview_text += f" LSD-Dateien: {self.target_project['lsd_count']}\n" preview_text += f" PNG-Dateien: {self.target_project['png_count']}\n\n" # Get existing files in target existing_lsd = set(self.get_files(self.target_project['pointcloud'], '.lsd')) existing_png = set(self.get_files(self.target_project['preview'], '.png')) if self.target_project['preview'] else set() total_new_scans = 0 total_new_lsd = 0 total_new_png = 0 for project in self.source_projects: preview_text += f"📁 QUELLPROJEKT: {project['name']}\n" preview_text += f" Scans: {project['scan_count']}\n" # Check for LSD conflicts lsd_conflicts = 0 for lsd in project.get('lsd_files', []): if lsd in existing_lsd: lsd_conflicts += 1 preview_text += f" LSD-Dateien: {project['lsd_count']}" if lsd_conflicts > 0: preview_text += f" ({lsd_conflicts} Namenskonflikte → werden umbenannt)" preview_text += "\n" # Check for PNG conflicts png_conflicts = 0 for png in project.get('png_files', []): if png in existing_png: png_conflicts += 1 preview_text += f" PNG-Dateien: {project['png_count']}" if png_conflicts > 0: preview_text += f" ({png_conflicts} Namenskonflikte → werden umbenannt)" preview_text += "\n\n" total_new_scans += project['scan_count'] total_new_lsd += project['lsd_count'] total_new_png += project['png_count'] # Add to existing for next iteration for lsd in project.get('lsd_files', []): existing_lsd.add(lsd) for png in project.get('png_files', []): existing_png.add(png) preview_text += "=== ZUSAMMENFASSUNG ===\n" preview_text += f"Neue Scans: {total_new_scans}\n" preview_text += f"Neue LSD-Dateien: {total_new_lsd}\n" preview_text += f"Neue PNG-Dateien: {total_new_png}\n" preview_text += f"\nNach Merge:\n" preview_text += f" Scans gesamt: {self.target_project['scan_count'] + total_new_scans}\n" preview_text += f" LSD-Dateien: {self.target_project['lsd_count'] + total_new_lsd}\n" preview_text += f" PNG-Dateien: {self.target_project['png_count'] + total_new_png}\n" preview_text += "\n⚠️ HINWEIS:\n" preview_text += " - Ein Backup der Stammprojekt-LSDX wird erstellt\n" preview_text += " - Bei Namenskonflikten werden Dateien mit Suffix '_merged_N' umbenannt\n" preview_text += " - Alle Referenzen in der LSDX werden entsprechend aktualisiert\n" self.preview_text.config(state=tk.NORMAL) self.preview_text.delete(1.0, tk.END) self.preview_text.insert(tk.END, preview_text) self.preview_text.config(state=tk.DISABLED) def setup_merge_logging(self, target_dir): """Setup merge logging.""" timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") log_file = os.path.join(target_dir, f"merge_{timestamp}.log") self.merge_logger = logging.getLogger(f'ProjectMerger_{timestamp}') self.merge_logger.setLevel(logging.INFO) self.merge_logger.handlers.clear() fh = logging.FileHandler(log_file, encoding='utf-8') fh.setLevel(logging.INFO) formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') fh.setFormatter(formatter) self.merge_logger.addHandler(fh) return log_file def log_message(self, message, level='info'): """Log message.""" if self.merge_logger: if level == 'error': self.merge_logger.error(message) else: self.merge_logger.info(message) def update_project_status(self, index, status): """Update project status in treeview.""" if index < len(self.projects_tree.get_children()): item_id = self.projects_tree.get_children()[index] values = list(self.projects_tree.item(item_id)['values']) values[0] = status self.projects_tree.item(item_id, values=values) self.root.update_idletasks() def execute_merge(self): """Execute the merge operation.""" if not self.target_project or not self.source_projects: return result = messagebox.askyesno( "Bestätigung", f"Möchten Sie wirklich {len(self.source_projects)} Projekt(e) in das Stammprojekt mergen?\n\n" "Ein Backup der LSDX-Datei wird automatisch erstellt." ) if not result: return self.processing = True self.merge_btn.config(state=tk.DISABLED) self.preview_btn.config(state=tk.DISABLED) # Setup logging log_file = self.setup_merge_logging(self.target_project['path']) self.log_message("=== MERGE GESTARTET ===") self.log_message(f"Stammprojekt: {self.target_project['name']}") self.log_message(f"Quellprojekte: {len(self.source_projects)}") self.log_message(f"Log-Datei: {log_file}") # Update preview with log self.preview_text.config(state=tk.NORMAL) self.preview_text.delete(1.0, tk.END) self.preview_text.insert(tk.END, f"=== MERGE LÄUFT ===\n\nLog: {log_file}\n\n") try: # Create backup of target LSDX timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") backup_path = f"{self.target_project['lsdx']}.backup_{timestamp}" shutil.copy2(self.target_project['lsdx'], backup_path) self.log_message(f"Backup erstellt: {backup_path}") self.preview_text.insert(tk.END, f"✓ Backup erstellt\n") # Parse target LSDX target_tree = ET.parse(self.target_project['lsdx']) target_root = target_tree.getroot() target_elements = target_root.find('Elements') # Get existing files existing_lsd = set(self.get_files(self.target_project['pointcloud'], '.lsd')) existing_png = set(self.get_files(self.target_project['preview'], '.png')) if self.target_project['preview'] else set() # Track UUID mappings for parent references suffix_counter = [0] success_count = 0 error_count = 0 for i, project in enumerate(self.source_projects): self.progress_var.set((i / len(self.source_projects)) * 100) self.progress_label.config(text=f"Merging {i+1}/{len(self.source_projects)}: {project['name']}") self.update_project_status(i, 'Verarbeite...') self.root.update_idletasks() try: self.merge_single_project( project, target_elements, existing_lsd, existing_png, suffix_counter ) self.update_project_status(i, '✓ Erfolgreich') self.preview_text.insert(tk.END, f"✓ {project['name']} gemerged\n") success_count += 1 except Exception as e: self.update_project_status(i, '✗ Fehler') self.preview_text.insert(tk.END, f"✗ Fehler bei {project['name']}: {e}\n") self.log_message(f"Fehler bei {project['name']}: {e}", 'error') error_count += 1 self.preview_text.see(tk.END) self.root.update_idletasks() # Zähle finale Elemente vor dem Speichern final_clusters = len(target_elements.findall("Element[@type='cluster']")) final_scans = len(target_elements.findall("Element[@type='scan']")) self.log_message(f"") self.log_message(f"{'='*60}") self.log_message(f"FINALE LSDX STATISTIK") self.log_message(f"{'='*60}") self.log_message(f" Cluster gesamt: {final_clusters}") self.log_message(f" Scans gesamt: {final_scans}") # Save merged LSDX target_tree.write(self.target_project['lsdx'], encoding='utf-8', xml_declaration=True) # Add DOCTYPE back with open(self.target_project['lsdx'], 'r', encoding='utf-8') as f: content = f.read() if '' not in content: content = content.replace( "", "\n" ) with open(self.target_project['lsdx'], 'w', encoding='utf-8') as f: f.write(content) self.log_message("LSDX-Datei gespeichert") self.log_message(f"Pfad: {self.target_project['lsdx']}") self.progress_var.set(100) self.progress_label.config(text="Fertig") # Summary self.log_message("=== MERGE ABGESCHLOSSEN ===") self.log_message(f"Erfolgreich: {success_count}") self.log_message(f"Fehlgeschlagen: {error_count}") self.preview_text.insert(tk.END, f"\n=== ZUSAMMENFASSUNG ===\n") self.preview_text.insert(tk.END, f"Erfolgreich: {success_count}\n") self.preview_text.insert(tk.END, f"Fehlgeschlagen: {error_count}\n") self.preview_text.insert(tk.END, f"Log: {log_file}\n") self.preview_text.config(state=tk.DISABLED) messagebox.showinfo( "Merge abgeschlossen", f"Merge abgeschlossen!\n\n" f"Erfolgreich: {success_count}\n" f"Fehlgeschlagen: {error_count}\n\n" f"Details siehe: {log_file}" ) except Exception as e: self.log_message(f"Kritischer Fehler: {e}", 'error') messagebox.showerror("Fehler", f"Fehler beim Merge:\n{e}") finally: self.processing = False self.merge_btn.config(state=tk.NORMAL) self.preview_btn.config(state=tk.NORMAL) def merge_single_project(self, source_project, target_elements, existing_lsd, existing_png, suffix_counter): """ Merge a single source project into target. LSDX Struktur: - Registration (Wurzel, parents="") - Cluster (parents=registration_uuid, name="ClusterName") - Scan (parents=cluster_uuid, name="1", enthält FilePath-Referenzen) Merge-Logik: 1. Finde Target-Registration UUID 2. Für jeden Cluster im Quellprojekt: - Prüfe ob Cluster mit gleichem Namen im Ziel existiert - Wenn ja: Verwende existierende Cluster-UUID für Scans - Wenn nein: Füge neuen Cluster hinzu mit neuer UUID 3. Für jeden Scan: Füge hinzu mit korrekter Cluster-UUID als Parent """ self.log_message(f"") self.log_message(f"{'='*60}") self.log_message(f"MERGE START: {source_project['name']}") self.log_message(f"{'='*60}") # Parse source LSDX source_tree = ET.parse(source_project['lsdx']) source_root = source_tree.getroot() source_elements = source_root.find('Elements') if source_elements is None: self.log_message("FEHLER: Keine Elements in Quell-LSDX gefunden!") raise ValueError("Keine Elements in Quell-LSDX") # === SCHRITT 1: Finde Target-Registration UUID === target_registration_uuid = None for elem in target_elements.findall("Element[@type='registration']"): target_registration_uuid = elem.get('uuid') self.log_message(f"[TARGET] Registration UUID: {target_registration_uuid}") break if not target_registration_uuid: self.log_message("FEHLER: Keine Registration im Ziel gefunden!") raise ValueError("Keine Registration im Ziel-LSDX") # === SCHRITT 2: Sammle existierende Cluster im Target === target_clusters = {} # name -> uuid for elem in target_elements.findall("Element[@type='cluster']"): cluster_name = elem.get('name', '') cluster_uuid = elem.get('uuid') target_clusters[cluster_name] = cluster_uuid self.log_message(f"[TARGET] Existierender Cluster: '{cluster_name}' UUID={cluster_uuid}") self.log_message(f"[TARGET] Cluster gesamt: {len(target_clusters)}") # === SCHRITT 3: Analysiere Source-Struktur === source_registration_uuid = None source_clusters = {} # source_uuid -> {name, scans: []} source_scans = [] for elem in source_elements.findall("Element"): elem_type = elem.get('type') elem_uuid = elem.get('uuid') elem_name = elem.get('name', '') elem_parents = elem.get('parents', '') if elem_type == 'registration': source_registration_uuid = elem_uuid self.log_message(f"[SOURCE] Registration UUID: {elem_uuid}") elif elem_type == 'cluster': source_clusters[elem_uuid] = {'name': elem_name, 'parent': elem_parents, 'scans': []} self.log_message(f"[SOURCE] Cluster: '{elem_name}' UUID={elem_uuid} parent={elem_parents}") elif elem_type == 'scan': source_scans.append({'elem': elem, 'uuid': elem_uuid, 'name': elem_name, 'parent': elem_parents}) self.log_message(f"[SOURCE] Scan: '{elem_name}' UUID={elem_uuid} parent={elem_parents}") self.log_message(f"[SOURCE] Cluster: {len(source_clusters)}, Scans: {len(source_scans)}") # === SCHRITT 4: Ordne Scans den Clustern zu === for scan in source_scans: scan_parent = scan['parent'] if scan_parent in source_clusters: source_clusters[scan_parent]['scans'].append(scan) else: self.log_message(f"WARNUNG: Scan '{scan['name']}' hat unbekannten Parent: {scan_parent}") # === SCHRITT 5: Dateien kopieren === lsd_renames = {} # old_name -> new_name png_renames = {} # old_name -> new_name self.log_message(f"") self.log_message(f"--- Kopiere LSD-Dateien ---") for lsd_file in source_project.get('lsd_files', []): src_path = os.path.join(source_project['pointcloud'], lsd_file) new_name = self.get_conflict_resolved_name(lsd_file, existing_lsd, suffix_counter) dst_path = os.path.join(self.target_project['pointcloud'], new_name) if os.path.exists(src_path): shutil.copy2(src_path, dst_path) existing_lsd.add(new_name) if lsd_file != new_name: lsd_renames[lsd_file] = new_name self.log_message(f" LSD kopiert (umbenannt): {lsd_file} → {new_name}") else: self.log_message(f" LSD kopiert: {lsd_file}") self.log_message(f"--- Kopiere PNG-Dateien ---") if source_project['preview'] and self.target_project['preview']: if not os.path.exists(self.target_project['preview']): os.makedirs(self.target_project['preview']) for png_file in source_project.get('png_files', []): src_path = os.path.join(source_project['preview'], png_file) new_name = self.get_conflict_resolved_name(png_file, existing_png, suffix_counter) dst_path = os.path.join(self.target_project['preview'], new_name) if os.path.exists(src_path): shutil.copy2(src_path, dst_path) existing_png.add(new_name) if png_file != new_name: png_renames[png_file] = new_name self.log_message(f" PNG kopiert (umbenannt): {png_file} → {new_name}") else: self.log_message(f" PNG kopiert: {png_file}") # === SCHRITT 6: Cluster und Scans hinzufügen === self.log_message(f"") self.log_message(f"--- Füge Cluster und Scans hinzu ---") cluster_uuid_mapping = {} # source_cluster_uuid -> target_cluster_uuid clusters_added = 0 clusters_reused = 0 scans_added = 0 for source_cluster_uuid, cluster_info in source_clusters.items(): cluster_name = cluster_info['name'] # Prüfe ob Cluster mit gleichem Namen im Ziel existiert if cluster_name in target_clusters: # Cluster existiert bereits - verwende existierende UUID target_cluster_uuid = target_clusters[cluster_name] cluster_uuid_mapping[source_cluster_uuid] = target_cluster_uuid self.log_message(f" CLUSTER EXISTIERT: '{cluster_name}' → verwende UUID {target_cluster_uuid}") clusters_reused += 1 else: # Neuen Cluster hinzufügen new_cluster_uuid = "{" + str(uuid.uuid4()) + "}" cluster_uuid_mapping[source_cluster_uuid] = new_cluster_uuid # Finde das Cluster-Element im Source for elem in source_elements.findall("Element[@type='cluster']"): if elem.get('uuid') == source_cluster_uuid: new_cluster = copy.deepcopy(elem) new_cluster.set('uuid', new_cluster_uuid) new_cluster.set('parents', target_registration_uuid) # Parent = Target-Registration target_elements.append(new_cluster) # Merke für zukünftige Projekte target_clusters[cluster_name] = new_cluster_uuid self.log_message(f" CLUSTER HINZUGEFÜGT: '{cluster_name}' UUID={new_cluster_uuid} parent={target_registration_uuid}") clusters_added += 1 break # Füge Scans dieses Clusters hinzu target_cluster_uuid = cluster_uuid_mapping[source_cluster_uuid] for scan_info in cluster_info['scans']: scan_elem = scan_info['elem'] new_scan_uuid = "{" + str(uuid.uuid4()) + "}" new_scan = copy.deepcopy(scan_elem) new_scan.set('uuid', new_scan_uuid) new_scan.set('parents', target_cluster_uuid) # Parent = Cluster-UUID # Update FilePath references for filepath in new_scan.findall(".//FilePath"): file_type = filepath.get('type') text = filepath.text or '' if file_type == 'lsd' and text: if text in lsd_renames: old_text = text filepath.text = lsd_renames[text] self.log_message(f" FilePath lsd: {old_text} → {filepath.text}") elif file_type == 'preview' and text: if text.startswith("Previews/"): png_name = text[9:] if png_name in png_renames: old_text = text filepath.text = f"Previews/{png_renames[png_name]}" self.log_message(f" FilePath preview: {old_text} → {filepath.text}") target_elements.append(new_scan) self.log_message(f" SCAN HINZUGEFÜGT: '{scan_info['name']}' UUID={new_scan_uuid} parent={target_cluster_uuid}") scans_added += 1 # === ZUSAMMENFASSUNG === self.log_message(f"") self.log_message(f"--- MERGE ZUSAMMENFASSUNG ---") self.log_message(f" Cluster hinzugefügt: {clusters_added}") self.log_message(f" Cluster wiederverwendet: {clusters_reused}") self.log_message(f" Scans hinzugefügt: {scans_added}") self.log_message(f" LSD-Dateien kopiert: {len(source_project.get('lsd_files', []))}") self.log_message(f" PNG-Dateien kopiert: {len(source_project.get('png_files', []))}") self.log_message(f"{'='*60}") class MainMenu: """Main menu for selecting between Single Project, Batch mode, and Project Merger.""" def __init__(self, root): self.root = root self.root.title("PointCab Projekt Umbenenner v4.1") self.root.geometry("500x450") self.root.resizable(False, False) self.current_frame = None self.setup_menu() def setup_menu(self): """Setup the main menu.""" self.clear_window() self.root.geometry("500x450") self.root.resizable(False, False) main_frame = ttk.Frame(self.root, padding="30") main_frame.pack(fill=tk.BOTH, expand=True) # Title title_label = ttk.Label(main_frame, text="PointCab Projekt Umbenenner", font=('Helvetica', 18, 'bold')) title_label.pack(pady=(0, 5)) version_label = ttk.Label(main_frame, text="Version 4.1", font=('Helvetica', 10), foreground='gray') version_label.pack(pady=(0, 30)) # Description desc_label = ttk.Label( main_frame, text="Wählen Sie einen Modus:", font=('Helvetica', 11) ) desc_label.pack(pady=(0, 20)) # Buttons frame buttons_frame = ttk.Frame(main_frame) buttons_frame.pack(fill=tk.X, pady=10) # Style for big buttons style = ttk.Style() style.configure('Big.TButton', font=('Helvetica', 12), padding=15) # Single project button single_btn = ttk.Button( buttons_frame, text="📁 Einzelprojekt bearbeiten", style='Big.TButton', command=self.open_single_project ) single_btn.pack(fill=tk.X, pady=5) single_desc = ttk.Label( buttons_frame, text="Ein einzelnes PointCab-Projekt auswählen und verarbeiten", foreground='gray' ) single_desc.pack(pady=(0, 15)) # Batch button batch_btn = ttk.Button( buttons_frame, text="📂 Batch Renamer", style='Big.TButton', command=self.open_batch_renamer ) batch_btn.pack(fill=tk.X, pady=5) batch_desc = ttk.Label( buttons_frame, text="Mehrere Projekte in einem Hauptverzeichnis automatisch verarbeiten", foreground='gray' ) batch_desc.pack(pady=(0, 15)) # Merger button merger_btn = ttk.Button( buttons_frame, text="🔀 Projektmerger", style='Big.TButton', command=self.open_project_merger ) merger_btn.pack(fill=tk.X, pady=5) merger_desc = ttk.Label( buttons_frame, text="Mehrere Projekte in ein Stammprojekt zusammenführen", foreground='gray' ) merger_desc.pack(pady=(0, 15)) # Exit button ttk.Button(main_frame, text="Beenden", command=self.root.quit).pack(pady=(20, 0)) def clear_window(self): """Clear all widgets from window.""" for widget in self.root.winfo_children(): widget.destroy() def open_single_project(self): """Open single project mode.""" self.clear_window() self.root.geometry("850x750") self.root.resizable(True, True) PointCabRenamer(self.root, return_callback=self.setup_menu) def open_batch_renamer(self): """Open batch renamer mode.""" self.clear_window() self.root.geometry("900x700") self.root.resizable(True, True) BatchRenamer(self.root, return_callback=self.setup_menu) def open_project_merger(self): """Open project merger mode.""" self.clear_window() self.root.geometry("1000x800") self.root.resizable(True, True) ProjectMerger(self.root, return_callback=self.setup_menu) def main(): """Main entry point.""" root = tk.Tk() app = MainMenu(root) root.mainloop() if __name__ == "__main__": main()