2059 lines
88 KiB
Python
2059 lines
88 KiB
Python
#!/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:
|
|
- <LSDX version="4.0">
|
|
- <Project>
|
|
- <Elements>
|
|
- <Element type="registration" uuid="{...}" parents=""> # Wurzelelement
|
|
- <Element type="cluster" uuid="{...}" parents="{registration_uuid}" name="ClusterName">
|
|
- <Element type="scan" uuid="{...}" parents="{cluster_uuid}" name="1">
|
|
- <References>
|
|
- <FilePath type="lsd">1.lsd</FilePath>
|
|
- <FilePath type="preview">Previews/1.png</FilePath>
|
|
|
|
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 '<!DOCTYPE LSDX>' not in content:
|
|
content = content.replace(
|
|
"<?xml version='1.0' encoding='utf-8'?>",
|
|
"<?xml version='1.0' encoding='utf-8'?>\n<!DOCTYPE LSDX>"
|
|
)
|
|
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 '<!DOCTYPE LSDX>' not in content:
|
|
content = content.replace(
|
|
"<?xml version='1.0' encoding='utf-8'?>",
|
|
"<?xml version='1.0' encoding='utf-8'?>\n<!DOCTYPE LSDX>"
|
|
)
|
|
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 '<!DOCTYPE LSDX>' not in content:
|
|
content = content.replace(
|
|
"<?xml version='1.0' encoding='utf-8'?>",
|
|
"<?xml version='1.0' encoding='utf-8'?>\n<!DOCTYPE LSDX>"
|
|
)
|
|
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()
|