Prototype > PreAlpha #67

Merged
Rusfort merged 54 commits from Dev into Stable 2025-05-15 09:10:54 +02:00
109 changed files with 622 additions and 10137 deletions
Showing only changes of commit 932b39fd01 - Show all commits

View File

@ -1,3 +0,0 @@
def invoke(self, context, event):
# Example: Open a dialog to select materials if not already selected
return context.window_manager.invoke_props_dialog(self)

View File

@ -1,6 +0,0 @@
# --- REMOVED Slots for Old Hierarchy and Rule Editor ---
# @Slot(QModelIndex)
# def _on_hierarchy_item_clicked(self, index: QModelIndex): ...
#
# @Slot(object)
# def _on_rule_updated(self, rule_object): ...

View File

@ -1,6 +0,0 @@
# Slot for prediction results (Updated for new format and coloring) - REMOVED
# @Slot(list)
# def on_prediction_results_ready(self, results: list):
# """Populates the preview table model with detailed prediction results."""
# # This is no longer needed as _on_rule_hierarchy_ready handles data loading for the new model.
# pass

View File

@ -1 +0,0 @@
# REMOVED Placeholder SourceRule creation

View File

@ -1,6 +0,0 @@
# --- REMOVED connections causing thread/handler cleanup ---
# self.prediction_handler.prediction_finished.connect(self.prediction_thread.quit)
# self.prediction_handler.prediction_finished.connect(self.prediction_handler.deleteLater)
# self.prediction_thread.finished.connect(self.prediction_thread.deleteLater)
# self.prediction_thread.finished.connect(self._reset_prediction_thread_references)
# --- END REMOVED ---

View File

@ -1,7 +0,0 @@
self.disable_preview_checkbox = QCheckBox("Disable Detailed Preview") # REMOVED - Moved to View Menu
self.disable_preview_checkbox.setToolTip("If checked, shows only the list of input assets instead of detailed file predictions.")
self.disable_preview_checkbox.setChecked(False) # Default is detailed preview enabled
self.disable_preview_checkbox.toggled.connect(self.update_preview) # Update preview when toggled
bottom_controls_layout.addWidget(self.disable_preview_checkbox)
bottom_controls_layout.addSpacing(20) # Add some space # REMOVED - No longer needed after checkbox removal

View File

@ -1,3 +0,0 @@
# REMOVED Old Preview Model Mode Setting and Table Configuration ---
# The Unified View does not have a simple/detailed mode toggle.
# The Prediction Handler is triggered regardless of view settings.

View File

@ -1,20 +0,0 @@
# --- REMOVED Old Processing Thread Setup ---
# if ProcessingHandler and self.processing_thread is None:
# self.processing_thread = QThread(self)
# self.processing_handler = ProcessingHandler()
# self.processing_handler.moveToThread(self.processing_thread)
# self.processing_handler.progress_updated.connect(self.update_progress_bar)
# self.processing_handler.file_status_updated.connect(self.update_file_status)
# self.processing_handler.processing_finished.connect(self.on_processing_finished)
# self.processing_handler.status_message.connect(self.show_status_message)
# self.processing_handler.processing_finished.connect(self.processing_thread.quit)
# self.processing_handler.processing_finished.connect(self.processing_handler.deleteLater)
# self.processing_thread.finished.connect(self.processing_thread.deleteLater)
# self.processing_thread.finished.connect(self._reset_processing_thread_references)
# log.debug("Processing thread and handler set up.")
# elif not ProcessingHandler:
# log.error("ProcessingHandler not available. Cannot set up processing thread.")
# if hasattr(self, 'start_button'):
# self.start_button.setEnabled(False)
# self.start_button.setToolTip("Error: Backend processing components failed to load.")
# --- END REMOVED ---

View File

@ -1,8 +0,0 @@
# --- REMOVED Old Processing Thread Reset ---
# @Slot()
# def _reset_processing_thread_references(self):
# # This might still be needed if processing is meant to be single-shot
# log.debug("Resetting processing thread and handler references.")
# self.processing_thread = None
# self.processing_handler = None
# --- END REMOVED ---

View File

@ -1,70 +0,0 @@
import logging
import subprocess
from pathlib import Path
log = logging.getLogger(__name__) # Assume logger is configured elsewhere
def run_blender_script(blender_exe_path: str, blend_file_path: str, python_script_path: str, asset_root_dir: str):
"""
Executes a Python script within Blender in the background.
Args:
blender_exe_path: Path to the Blender executable.
blend_file_path: Path to the .blend file to open.
python_script_path: Path to the Python script to execute within Blender.
asset_root_dir: Path to the processed asset library root directory (passed to the script).
Returns:
True if the script executed successfully (return code 0), False otherwise.
"""
log.info(f"Attempting to run Blender script: {Path(python_script_path).name} on {Path(blend_file_path).name}")
# Ensure paths are absolute strings for subprocess
blender_exe_path = str(Path(blender_exe_path).resolve())
blend_file_path = str(Path(blend_file_path).resolve())
python_script_path = str(Path(python_script_path).resolve())
asset_root_dir = str(Path(asset_root_dir).resolve())
# Construct the command arguments
# -b: Run in background (no UI)
# -S: Save the file after running the script
# --python: Execute the specified Python script
# --: Separator, arguments after this are passed to the Python script's sys.argv
command = [
blender_exe_path,
"-b", # Run in background
blend_file_path,
"--python", python_script_path,
"--", # Pass subsequent arguments to the script
asset_root_dir,
"-S" # Save the blend file after script execution
]
log.debug(f"Executing Blender command: {' '.join(command)}") # Log the command for debugging
try:
# Execute the command
# capture_output=True captures stdout and stderr
# text=True decodes stdout/stderr as text
# check=False prevents raising CalledProcessError on non-zero exit codes
result = subprocess.run(command, capture_output=True, text=True, check=False)
# Log results
log.info(f"Blender script '{Path(python_script_path).name}' finished with exit code: {result.returncode}")
if result.stdout:
log.debug(f"Blender stdout:\n{result.stdout.strip()}")
if result.stderr:
# Log stderr as warning or error depending on return code
if result.returncode != 0:
log.error(f"Blender stderr:\n{result.stderr.strip()}")
else:
log.warning(f"Blender stderr (Return Code 0):\n{result.stderr.strip()}") # Log stderr even on success as scripts might print warnings
return result.returncode == 0
except FileNotFoundError:
log.error(f"Blender executable not found at: {blender_exe_path}")
return False
except Exception as e:
log.exception(f"An unexpected error occurred while running Blender script '{Path(python_script_path).name}': {e}")
return False

View File

@ -1,346 +0,0 @@
# Deprecated/Old-Code/main_py_cli_main_entry_line_329.py
import argparse
import sys
import time
import os
import logging
from pathlib import Path
import shutil
# Assuming these imports are needed based on the original context
try:
import config as core_config_module
# Import functions from previously created files
# Note: These imports assume the files are in the same directory or Python path
from main_py_cli_run_processing_line_258 import run_processing
from main_py_cli_blender_script_runner_line_365 import run_blender_script
# setup_arg_parser and setup_logging are defined in the main script,
# so they might not be directly importable here without refactoring.
# This function is modified to accept them as arguments.
except ImportError as e:
print(f"Warning: Could not import necessary modules/functions: {e}")
core_config_module = None
run_processing = None
run_blender_script = None
log = logging.getLogger(__name__) # Assume logger is configured elsewhere
# Note: setup_arg_parser and setup_logging were originally defined in main.py
# This function now accepts them as arguments.
def main(setup_arg_parser_func, setup_logging_func):
"""Parses arguments, sets up logging, runs processing, and reports summary."""
parser = setup_arg_parser_func()
args = parser.parse_args()
# Setup logging based on verbosity argument *before* logging status messages
setup_logging_func(args.verbose)
start_time = time.time()
log.info("Asset Processor Script Started (CLI Mode)")
# --- Validate Input Paths ---
valid_inputs = []
for p_str in args.input_paths:
p = Path(p_str)
if p.exists():
suffix = p.suffix.lower()
# Original check included .rar, .7z - keeping for historical accuracy
if p.is_dir() or (p.is_file() and suffix in ['.zip', '.rar', '.7z']):
valid_inputs.append(p_str) # Store the original string path
else:
log.warning(f"Input is not a directory or a supported archive type (.zip, .rar, .7z), skipping: {p_str}")
else:
log.warning(f"Input path not found, skipping: {p_str}")
if not valid_inputs:
log.error("No valid input paths found. Exiting.")
sys.exit(1) # Exit with error code
# --- Determine Output Directory ---
output_dir_str = args.output_dir # Get value from args (might be None)
if not output_dir_str:
log.debug("Output directory not specified via -o, reading default from config.py.")
try:
if core_config_module is None:
raise RuntimeError("core_config_module not imported.")
output_dir_str = getattr(core_config_module, 'OUTPUT_BASE_DIR', None)
if not output_dir_str:
log.error("Output directory not specified with -o and OUTPUT_BASE_DIR not found or empty in config.py. Exiting.")
sys.exit(1)
log.info(f"Using default output directory from config.py: {output_dir_str}")
except Exception as e:
log.error(f"Could not read OUTPUT_BASE_DIR from config.py: {e}")
sys.exit(1)
# --- Resolve Output Path (Handles Relative Paths Explicitly) ---
output_path_obj: Path
if os.path.isabs(output_dir_str):
output_path_obj = Path(output_dir_str)
log.info(f"Using absolute output directory: {output_path_obj}")
else:
# Path() interprets relative paths against CWD by default
output_path_obj = Path(output_dir_str)
log.info(f"Using relative output directory '{output_dir_str}'. Resolved against CWD to: {output_path_obj.resolve()}")
# --- Validate and Setup Output Directory ---
try:
# Resolve to ensure we have an absolute path for consistency and creation
resolved_output_dir = output_path_obj.resolve()
log.info(f"Ensuring output directory exists: {resolved_output_dir}")
resolved_output_dir.mkdir(parents=True, exist_ok=True)
# Use the resolved absolute path string for the processor
output_dir_for_processor = str(resolved_output_dir)
except Exception as e:
log.error(f"Cannot create or access output directory '{resolved_output_dir}': {e}", exc_info=True)
sys.exit(1)
# --- Check Preset Existence (Basic Check) ---
# Assuming __file__ might not be reliable here, using relative path logic
try:
# Try relative to CWD first
preset_dir = Path("Presets")
if not preset_dir.is_dir():
# Try relative to script location if possible? Less reliable.
# Go up two levels from Deprecated/Old-Code
preset_dir = Path(__file__).parent.parent / "Presets"
preset_file = preset_dir / f"{args.preset}.json"
if not preset_file.is_file():
log.error(f"Preset file not found: {preset_file}")
log.error(f"Ensure a file named '{args.preset}.json' exists in the directory: {preset_dir.resolve()}")
sys.exit(1)
except NameError: # __file__ might not be defined
log.error("Could not determine preset directory path.")
sys.exit(1)
# --- Execute Processing via the new function ---
if run_processing is None:
log.error("run_processing function not available. Cannot execute processing.")
sys.exit(1)
processing_results = run_processing(
valid_inputs=valid_inputs,
preset_name=args.preset,
output_dir_for_processor=output_dir_for_processor,
overwrite=args.overwrite,
num_workers=args.workers,
verbose=args.verbose # Pass the verbose flag
)
# --- Report Summary ---
duration = time.time() - start_time
successful_processed_count = processing_results["processed"]
skipped_count = processing_results["skipped"]
failed_count = processing_results["failed"]
results_list = processing_results["results_list"]
log.info("=" * 40)
log.info("Processing Summary")
log.info(f" Duration: {duration:.2f} seconds")
log.info(f" Assets Attempted: {len(valid_inputs)}")
log.info(f" Successfully Processed: {successful_processed_count}")
log.info(f" Skipped (Already Existed): {skipped_count}")
log.info(f" Failed: {failed_count}")
if processing_results.get("pool_error"):
log.error(f" Process Pool Error: {processing_results['pool_error']}")
# Ensure failed count reflects pool error if it happened
if failed_count == 0 and successful_processed_count == 0 and skipped_count == 0:
failed_count = len(valid_inputs) # Assume all failed if pool died early
exit_code = 0
if failed_count > 0:
log.warning("Failures occurred:")
# Iterate through results to show specific errors for failed items
for input_path, status, err_msg in results_list:
if status == "failed":
log.warning(f" - {Path(input_path).name}: {err_msg}")
exit_code = 1 # Exit with error code if failures occurred
else:
# Consider skipped assets as a form of success for the overall run exit code
if successful_processed_count > 0 or skipped_count > 0:
log.info("All assets processed or skipped successfully.")
exit_code = 0 # Exit code 0 indicates success (including skips)
else:
# This case might happen if all inputs were invalid initially
log.warning("No assets were processed, skipped, or failed (check input validation logs).")
exit_code = 0 # Still exit 0 as the script itself didn't crash
# --- Blender Script Execution (Optional) ---
run_nodegroups = False # Flags were defined but never set to True in original code
run_materials = False
nodegroup_blend_path = None
materials_blend_path = None
blender_exe = None
# 1. Find Blender Executable
try:
if core_config_module is None:
raise RuntimeError("core_config_module not imported.")
blender_exe_config = getattr(core_config_module, 'BLENDER_EXECUTABLE_PATH', None)
if blender_exe_config:
# Check if the path in config exists
if Path(blender_exe_config).is_file():
blender_exe = str(Path(blender_exe_config).resolve())
log.info(f"Using Blender executable from config: {blender_exe}")
else:
# Try finding it in PATH if config path is invalid
log.warning(f"Blender path in config not found: '{blender_exe_config}'. Trying to find 'blender' in PATH.")
blender_exe = shutil.which("blender")
if blender_exe:
log.info(f"Found Blender executable in PATH: {blender_exe}")
else:
log.warning("Could not find 'blender' in system PATH.")
else:
# Try finding it in PATH if not set in config
log.info("BLENDER_EXECUTABLE_PATH not set in config. Trying to find 'blender' in PATH.")
blender_exe = shutil.which("blender")
if blender_exe:
log.info(f"Found Blender executable in PATH: {blender_exe}")
else:
log.warning("Could not find 'blender' in system PATH.")
if not blender_exe:
log.warning("Blender executable not found or configured. Skipping Blender script execution.")
except Exception as e:
log.error(f"Error checking Blender executable path: {e}")
blender_exe = None # Ensure it's None on error
# 2. Determine Blend File Paths if Blender Exe is available
if blender_exe:
# Nodegroup Blend Path
nodegroup_blend_arg = args.nodegroup_blend
if nodegroup_blend_arg:
p = Path(nodegroup_blend_arg)
if p.is_file() and p.suffix.lower() == '.blend':
nodegroup_blend_path = str(p.resolve())
log.info(f"Using nodegroup blend file from argument: {nodegroup_blend_path}")
else:
log.warning(f"Invalid nodegroup blend file path from argument: '{nodegroup_blend_arg}'. Ignoring.")
else:
if core_config_module is None:
log.warning("core_config_module not available to check default nodegroup path.")
else:
default_ng_path_str = getattr(core_config_module, 'DEFAULT_NODEGROUP_BLEND_PATH', None)
if default_ng_path_str:
p = Path(default_ng_path_str)
if p.is_file() and p.suffix.lower() == '.blend':
nodegroup_blend_path = str(p.resolve())
log.info(f"Using default nodegroup blend file from config: {nodegroup_blend_path}")
else:
log.warning(f"Invalid default nodegroup blend file path in config: '{default_ng_path_str}'. Ignoring.")
# Materials Blend Path
materials_blend_arg = args.materials_blend
if materials_blend_arg:
p = Path(materials_blend_arg)
if p.is_file() and p.suffix.lower() == '.blend':
materials_blend_path = str(p.resolve())
log.info(f"Using materials blend file from argument: {materials_blend_path}")
else:
log.warning(f"Invalid materials blend file path from argument: '{materials_blend_arg}'. Ignoring.")
else:
if core_config_module is None:
log.warning("core_config_module not available to check default materials path.")
else:
default_mat_path_str = getattr(core_config_module, 'DEFAULT_MATERIALS_BLEND_PATH', None)
if default_mat_path_str:
p = Path(default_mat_path_str)
if p.is_file() and p.suffix.lower() == '.blend':
materials_blend_path = str(p.resolve())
log.info(f"Using default materials blend file from config: {materials_blend_path}")
else:
log.warning(f"Invalid default materials blend file path in config: '{default_mat_path_str}'. Ignoring.")
# 3. Execute Scripts if Paths are Valid
if blender_exe:
# Determine script directory relative to this file's assumed location
try:
# Go up two levels from Deprecated/Old-Code
script_dir = Path(__file__).parent.parent / "blenderscripts"
except NameError:
script_dir = Path("blenderscripts") # Fallback if __file__ is not defined
nodegroup_script_path = script_dir / "create_nodegroups.py"
materials_script_path = script_dir / "create_materials.py"
asset_output_root = output_dir_for_processor # Use the resolved output dir
if run_blender_script is None:
log.error("run_blender_script function not available. Cannot execute Blender scripts.")
else:
# Check if nodegroup execution should run (based on original commented code, it wasn't explicitly triggered)
# if run_nodegroups: # This flag was never set to True
if nodegroup_blend_path: # Check if path exists instead
if nodegroup_script_path.is_file():
log.info("-" * 40)
log.info("Starting Blender Node Group Script Execution...")
success_ng = run_blender_script(
blender_exe_path=blender_exe,
blend_file_path=nodegroup_blend_path,
python_script_path=str(nodegroup_script_path),
asset_root_dir=asset_output_root
)
if not success_ng:
log.error("Blender node group script execution failed.")
# Optionally change exit code if Blender script fails?
# exit_code = 1
log.info("Finished Blender Node Group Script Execution.")
log.info("-" * 40)
else:
log.error(f"Node group script not found: {nodegroup_script_path}")
# Check if material execution should run (based on original commented code, it wasn't explicitly triggered)
# if run_materials: # This flag was never set to True
if materials_blend_path: # Check if path exists instead
if materials_script_path.is_file():
log.info("-" * 40)
log.info("Starting Blender Material Script Execution...")
success_mat = run_blender_script(
blender_exe_path=blender_exe,
blend_file_path=materials_blend_path,
python_script_path=str(materials_script_path),
asset_root_dir=asset_output_root
)
if not success_mat:
log.error("Blender material script execution failed.")
# Optionally change exit code if Blender script fails?
# exit_code = 1
log.info("Finished Blender Material Script Execution.")
log.info("-" * 40)
else:
log.error(f"Material script not found: {materials_script_path}")
# --- Final Exit ---
log.info("Asset Processor Script Finished.")
sys.exit(exit_code)
# Example of how this might be called if run standalone (requires providing the setup functions)
# if __name__ == "__main__":
# # Define dummy or actual setup functions here if needed for testing
# def dummy_setup_arg_parser():
# # Minimal parser for testing
# parser = argparse.ArgumentParser()
# parser.add_argument("input_paths", nargs='*', default=[])
# parser.add_argument("-p", "--preset")
# parser.add_argument("-o", "--output-dir")
# parser.add_argument("-w", "--workers", type=int, default=1)
# parser.add_argument("-v", "--verbose", action="store_true")
# parser.add_argument("--overwrite", action="store_true")
# parser.add_argument("--nodegroup-blend")
# parser.add_argument("--materials-blend")
# return parser
#
# def dummy_setup_logging(verbose):
# logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO,
# format='%(asctime)s [%(levelname)-8s] %(name)s: %(message)s')
#
# # Configure basic logging for the example run
# logging.basicConfig(level=logging.INFO)
#
# # Need to get setup_arg_parser and setup_logging from the original main.py somehow
# # This example won't run directly without them.
# # from main import setup_arg_parser, setup_logging # This would cause circular import if run directly
#
# # main(setup_arg_parser, setup_logging) # Call with the actual functions if available

View File

@ -1,123 +0,0 @@
import logging
from pathlib import Path
from concurrent.futures import ProcessPoolExecutor, as_completed
from typing import List, Dict, Tuple, Optional
# Assuming this import is needed based on the original context
try:
# Import the wrapper function from the file created in the previous step
# Note: This assumes the file is in the same directory or Python path
from main_py_cli_worker_wrapper_line_254 import process_single_asset_wrapper
except ImportError:
print("Warning: Could not import process_single_asset_wrapper. Ensure main_py_cli_worker_wrapper_line_254.py exists.")
# Define a dummy function if import fails
def process_single_asset_wrapper(*args, **kwargs) -> Tuple[str, str, Optional[str]]:
input_path = args[0] if args else "unknown_path"
return (input_path, "failed", "Dummy function: process_single_asset_wrapper not imported")
log = logging.getLogger(__name__) # Assume logger is configured elsewhere
def run_processing(
valid_inputs: List[str],
preset_name: str,
output_dir_for_processor: str,
overwrite: bool,
num_workers: int,
verbose: bool # Add verbose parameter here
) -> Dict:
"""
Executes the core asset processing logic using a process pool.
Args:
valid_inputs: List of validated input file/directory paths (strings).
preset_name: Name of the preset to use.
output_dir_for_processor: Absolute path string for the output base directory.
overwrite: Boolean flag to force reprocessing.
num_workers: Maximum number of worker processes.
verbose: Boolean flag for verbose logging.
Returns:
A dictionary containing processing results:
{
"processed": int,
"skipped": int,
"failed": int,
"results_list": List[Tuple[str, str, Optional[str]]] # (input_path, status, error_msg)
}
"""
log.info(f"Processing {len(valid_inputs)} asset(s) using preset '{preset_name}' with up to {num_workers} worker(s)...")
results_list = []
successful_processed_count = 0
skipped_count = 0
failed_count = 0
# Ensure at least one worker
num_workers = max(1, num_workers)
# Using ProcessPoolExecutor is generally good if AssetProcessor tasks are CPU-bound.
# If tasks are mostly I/O bound, ThreadPoolExecutor might be sufficient.
# Important: Ensure Configuration and AssetProcessor are "pickleable".
try:
with ProcessPoolExecutor(max_workers=num_workers) as executor:
# Create futures
futures = {}
log.debug(f"Submitting {len(valid_inputs)} tasks...")
# Removed the 1-second delay for potentially faster submission in non-CLI use
for i, input_path in enumerate(valid_inputs):
log.debug(f"Submitting task {i+1}/{len(valid_inputs)} for: {Path(input_path).name}")
future = executor.submit(
process_single_asset_wrapper, # Use the imported wrapper
input_path,
preset_name,
output_dir_for_processor,
overwrite,
verbose # Pass the verbose flag
)
futures[future] = input_path # Store future -> input_path mapping
# Process completed futures
for i, future in enumerate(as_completed(futures), 1):
input_path = futures[future]
asset_name = Path(input_path).name
log.info(f"--- [{i}/{len(valid_inputs)}] Worker finished attempt for: {asset_name} ---")
try:
# Get result tuple: (input_path_str, status_string, error_message_or_None)
result_tuple = future.result()
results_list.append(result_tuple)
input_path_res, status, err_msg = result_tuple
# Increment counters based on status
if status == "processed":
successful_processed_count += 1
elif status == "skipped":
skipped_count += 1
elif status == "failed":
failed_count += 1
else: # Should not happen, but log as warning/failure
log.warning(f"Unknown status '{status}' received for {asset_name}. Counting as failed.")
failed_count += 1
except Exception as e:
# Catch errors if the future itself fails (e.g., worker process crashed hard)
log.exception(f"Critical worker failure for {asset_name}: {e}")
results_list.append((input_path, "failed", f"Worker process crashed: {e}"))
failed_count += 1 # Count crashes as failures
except Exception as pool_exc:
log.exception(f"An error occurred with the process pool: {pool_exc}")
# Re-raise or handle as appropriate for the calling context (monitor.py)
# For now, log and return current counts
return {
"processed": successful_processed_count,
"skipped": skipped_count,
"failed": failed_count + (len(valid_inputs) - len(results_list)), # Count unprocessed as failed
"results_list": results_list,
"pool_error": str(pool_exc) # Add pool error info
}
return {
"processed": successful_processed_count,
"skipped": skipped_count,
"failed": failed_count,
"results_list": results_list
}

View File

@ -1,100 +0,0 @@
import os
import logging
from pathlib import Path
from typing import Tuple, Optional, List, Dict # Added List, Dict
# Assuming these imports are needed based on the original context
try:
from configuration import Configuration, ConfigurationError
from asset_processor import AssetProcessor, AssetProcessingError # Assuming this was the old processor
from rule_structure import SourceRule # Assuming this might be needed
except ImportError:
# Handle missing imports if this file is run standalone
print("Warning: Could not import necessary classes (Configuration, AssetProcessor, etc.).")
Configuration = None
AssetProcessor = None
ConfigurationError = Exception
AssetProcessingError = Exception
SourceRule = None # Define as None if not found
def process_single_asset_wrapper(input_path_str: str, preset_name: str, output_dir_str: str, overwrite: bool, verbose: bool, rules) -> Tuple[str, str, Optional[str]]:
"""
Wrapper function for processing a single input path (which might contain multiple assets)
in a separate process. Handles instantiation of Configuration and AssetProcessor,
passes the overwrite flag, catches errors, and interprets the multi-asset status dictionary.
Ensures logging is configured for the worker process.
Returns:
Tuple[str, str, Optional[str]]:
- input_path_str: The original input path processed.
- overall_status_string: A single status string summarizing the outcome
("processed", "skipped", "failed", "partial_success").
- error_message_or_None: An error message if failures occurred, potentially
listing failed assets.
"""
# Explicitly configure logging for this worker process
worker_log = logging.getLogger(f"Worker_{os.getpid()}") # Log with worker PID
if not logging.root.handlers:
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)-8s] %(name)s: %(message)s')
worker_log.setLevel(logging.DEBUG if verbose else logging.INFO)
if verbose:
logging.root.setLevel(logging.DEBUG)
input_path_obj = Path(input_path_str)
input_name = input_path_obj.name
try:
worker_log.info(f"Starting processing attempt for input: {input_name}")
# Ensure Configuration is available before using
if Configuration is None:
raise RuntimeError("Configuration class not imported.")
config = Configuration(preset_name)
output_base_path = Path(output_dir_str)
# Ensure AssetProcessor is available before using
if AssetProcessor is None:
raise RuntimeError("AssetProcessor class not imported.")
processor = AssetProcessor(input_path_obj, config, output_base_path, overwrite=overwrite)
# processor.process() now returns a Dict[str, List[str]]
status_dict = processor.process(rules=rules)
# --- Interpret the status dictionary ---
processed_assets = status_dict.get("processed", [])
skipped_assets = status_dict.get("skipped", [])
failed_assets = status_dict.get("failed", [])
overall_status_string = "failed" # Default
error_message = None
if failed_assets:
overall_status_string = "failed"
error_message = f"Failed assets within {input_name}: {', '.join(failed_assets)}"
worker_log.error(error_message) # Log the failure details
elif processed_assets:
overall_status_string = "processed"
# Check for partial success (mix of processed/skipped and failed should be caught above)
if skipped_assets:
worker_log.info(f"Input '{input_name}' processed with some assets skipped. Processed: {processed_assets}, Skipped: {skipped_assets}")
else:
worker_log.info(f"Input '{input_name}' processed successfully. Assets: {processed_assets}")
elif skipped_assets:
overall_status_string = "skipped"
worker_log.info(f"Input '{input_name}' skipped (all contained assets already exist). Assets: {skipped_assets}")
else:
# Should not happen if input contained files, but handle as failure.
worker_log.warning(f"Input '{input_name}' resulted in no processed, skipped, or failed assets. Reporting as failed.")
overall_status_string = "failed"
error_message = f"No assets processed, skipped, or failed within {input_name}."
return (input_path_str, overall_status_string, error_message)
except (ConfigurationError, AssetProcessingError) as e:
# Catch errors during processor setup or the process() call itself if it raises before returning dict
worker_log.error(f"Processing failed for input '{input_name}': {type(e).__name__}: {e}")
return (input_path_str, "failed", f"{type(e).__name__}: {e}")
except Exception as e:
# Catch any other unexpected errors
worker_log.exception(f"Unexpected worker failure processing input '{input_name}': {e}")
return (input_path_str, "failed", f"Unexpected Worker Error: {e}")

View File

@ -1,291 +0,0 @@
import bpy
from pathlib import Path
import time
import os
import math
# Try importing NumPy
try:
import numpy as np
numpy_available = True
# print("NumPy module found.") # Less verbose
except ImportError:
print("Warning: NumPy module not found. Median calc disabled, mean uses loop.")
numpy_available = False
# --- Configuration ---
ASSET_LIBRARY_NAME = "Nodes-Linked" # <<< Name of Asset Library in Prefs
TEMPLATE_MATERIAL_NAME = "Template_PBRMaterial" # <<< Name of template Material in current file
PLACEHOLDER_NODE_LABEL = "PBRSET_PLACEHOLDER" # <<< Label of placeholder node in template mat
ASSET_NAME_PREFIX = "PBRSET_" # <<< Prefix of Node Group assets to process
MATERIAL_NAME_PREFIX = "Mat_" # <<< Prefix for created Materials
THUMBNAIL_PROPERTY_NAME = "thumbnail_filepath" # <<< Custom property name on Node Groups
VALID_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff"}
DERIVED_MAP_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.tif', '.tiff']
VIEWPORT_GAMMA = 0.4
SCALED_SIZE = (32, 32) # Downscale target size for calculations
# --- >>> SET MATERIAL CREATION LIMIT HERE <<< ---
# Max number of *new* materials created per run (0 = no limit)
MATERIAL_CREATION_LIMIT = 900
# ------------------------------------------------
# --- Helper Functions ---
def find_node_by_label(node_tree, label, node_type=None):
# Finds first node by label and optional type (using node.type)
if not node_tree: return None
for node in node_tree.nodes:
if node.label and node.label == label:
if node_type is None or node.type == node_type: return node
return None
def calculate_value_from_image(image, target_size=(64, 64), mode='color', method='median'):
# Calculates median/mean from downscaled image copy, cleans up temp image
temp_img = None; #... (Full implementation from previous step) ...
if not image: return None
try:
if not image.has_data:
try: _ = len(image.pixels); image.update()
except Exception: pass
if not image.has_data: return None # Cannot proceed
temp_img = image.copy()
if not temp_img: return None
temp_img.scale(target_size[0], target_size[1])
try: _ = len(temp_img.pixels); temp_img.update()
except Exception: pass # Ignore access error, check has_data
if not temp_img.has_data: return None
width=temp_img.size[0]; height=temp_img.size[1]; channels=temp_img.channels
if width == 0 or height == 0 or channels == 0: return None
pixels = temp_img.pixels[:]; result_value = None;
if numpy_available: # Use NumPy
np_pixels = np.array(pixels); num_elements = len(np_pixels); num_pixels_actual = num_elements // channels;
if num_pixels_actual == 0: return None
np_pixels = np_pixels[:num_pixels_actual * channels]; pixels_reshaped = np_pixels.reshape((num_pixels_actual, channels))
if mode == 'color': # Color Median/Mean (NumPy)
if channels < 3: return None
calc_linear = np.median(pixels_reshaped[:, :3], axis=0) if method == 'median' else np.mean(pixels_reshaped[:, :3], axis=0)
inv_gamma = 1.0 / VIEWPORT_GAMMA; calc_linear_clamped = np.clip(calc_linear, 0.0, None)
calc_srgb_np = np.power(calc_linear_clamped, inv_gamma); calc_srgb_clamped = np.clip(calc_srgb_np, 0.0, 1.0)
result_value = (calc_srgb_clamped[0], calc_srgb_clamped[1], calc_srgb_clamped[2], 1.0)
elif mode == 'grayscale': # Grayscale Median/Mean (NumPy)
calc_val = np.median(pixels_reshaped[:, 0]) if method == 'median' else np.mean(pixels_reshaped[:, 0])
result_value = min(max(0.0, calc_val), 1.0)
elif method == 'mean': # Fallback Mean Loop
# print(" Calculating mean using standard loop...") # Verbose
actual_len = len(pixels); #... (Mean loop logic) ...
if actual_len == 0: return None; num_pixels_in_buffer=actual_len//channels; max_elements=num_pixels_in_buffer*channels
if num_pixels_in_buffer == 0: return None
if mode == 'color':
sum_r,sum_g,sum_b = 0.0,0.0,0.0; step=channels
for i in range(0, max_elements, step):
if i+2 >= actual_len: break; sum_r+=pixels[i]; sum_g+=pixels[i+1]; sum_b+=pixels[i+2]
avg_r_lin,avg_g_lin,avg_b_lin = sum_r/num_pixels_in_buffer, sum_g/num_pixels_in_buffer, sum_b/num_pixels_in_buffer
inv_gamma = 1.0/VIEWPORT_GAMMA
avg_r_srgb,avg_g_srgb,avg_b_srgb = min(max(0.0,pow(max(0.0,avg_r_lin),inv_gamma)),1.0), min(max(0.0,pow(max(0.0,avg_g_lin),inv_gamma)),1.0), min(max(0.0,pow(max(0.0,avg_b_lin),inv_gamma)),1.0)
result_value = (avg_r_srgb, avg_g_srgb, avg_b_srgb, 1.0)
elif mode == 'grayscale':
sum_val=0.0; step=channels
for i in range(0, max_elements, step): sum_val+=pixels[i]
result_value = min(max(0.0, sum_val/num_pixels_in_buffer), 1.0)
else: print(" Error: NumPy required for median calculation."); return None
return result_value
except Exception as e: print(f" Error during value calculation for '{image.name}': {e}"); return None
finally: # Cleanup
if temp_img:
try: bpy.data.images.remove(temp_img, do_unlink=True)
except Exception: pass # Ignore cleanup errors
# --- Main Function ---
def create_materials_for_library_assets(library_name):
start_time = time.time(); print(f"--- Starting Material Creation for Library '{library_name}' ---")
print(f"Material Creation Limit per run: {'Unlimited' if MATERIAL_CREATION_LIMIT <= 0 else MATERIAL_CREATION_LIMIT}")
# (Prerequisite checks...)
template_mat=bpy.data.materials.get(TEMPLATE_MATERIAL_NAME); #... etc ...
if not template_mat or not template_mat.use_nodes or not find_node_by_label(template_mat.node_tree, PLACEHOLDER_NODE_LABEL, 'GROUP'): print("Template Prereq Failed."); return
library=bpy.context.preferences.filepaths.asset_libraries.get(library_name); #... etc ...
if not library or not Path(bpy.path.abspath(library.path)).exists(): print("Library Prereq Failed."); return
print(f"Found template material and library path...")
# (File scanning...)
materials_created=0; materials_skipped=0; nodegroups_processed=0; link_errors=0; files_to_process=[]; library_path_obj=Path(bpy.path.abspath(library.path))
#... (populate files_to_process) ...
if library_path_obj.is_dir():
for item in library_path_obj.iterdir():
if item.is_file() and item.suffix.lower() == '.blend': files_to_process.append(str(item))
if not files_to_process: print(f"Warning: No .blend files found in dir: {library_path_obj}")
elif library_path_obj.is_file() and library_path_obj.suffix.lower() == '.blend':
files_to_process.append(str(library_path_obj))
else: print(f"Error: Library path not dir or .blend: {library_path_obj}"); return
print(f"Found {len(files_to_process)} .blend file(s) to inspect.")
# Initialize counters and flag for limit
created_in_this_run = 0
limit_reached_flag = False
for blend_file_path in files_to_process: # ... (inspect loop) ...
print(f"\nInspecting library file: {os.path.basename(blend_file_path)}...")
potential_nodegroups = []; # ... (inspection logic) ...
try:
with bpy.data.libraries.load(blend_file_path, link=False) as (data_from, data_to): potential_nodegroups = list(data_from.node_groups)
except Exception as e_load_inspect: print(f" Error inspecting file '{blend_file_path}': {e_load_inspect}"); continue
print(f" Found {len(potential_nodegroups)} NGs. Checking for '{ASSET_NAME_PREFIX}'...")
for asset_nodegroup_name in potential_nodegroups: # ... (NG loop) ...
if not asset_nodegroup_name.startswith(ASSET_NAME_PREFIX): continue
nodegroups_processed += 1
base_name = asset_nodegroup_name.removeprefix(ASSET_NAME_PREFIX)
material_name = f"{MATERIAL_NAME_PREFIX}{base_name}"
if bpy.data.materials.get(material_name): materials_skipped += 1; continue
linked_nodegroup = None; preview_path = None
try: # --- Start Main Processing Block for NG ---
# (Linking logic...)
existing_group = bpy.data.node_groups.get(asset_nodegroup_name); #... etc linking ...
is_correctly_linked = (existing_group and existing_group.library and bpy.path.abspath(existing_group.library.filepath) == blend_file_path)
if is_correctly_linked: linked_nodegroup = existing_group
else: # Link it
with bpy.data.libraries.load(blend_file_path, link=True, relative=False) as (data_from, data_to):
if asset_nodegroup_name in data_from.node_groups: data_to.node_groups = [asset_nodegroup_name]
else: print(f" Error: NG '{asset_nodegroup_name}' not found during link."); continue # Skip NG
linked_nodegroup = bpy.data.node_groups.get(asset_nodegroup_name)
if not linked_nodegroup or not linked_nodegroup.library: print(f" Error: NG '{asset_nodegroup_name}' link failed."); linked_nodegroup = None; link_errors += 1
if not linked_nodegroup: print(f" Failed link NG '{asset_nodegroup_name}'. Skip."); continue # Skip NG
preview_path = linked_nodegroup.get(THUMBNAIL_PROPERTY_NAME) # Path to COL-1 1K
# (Duplicate, Rename, Replace Placeholder...)
new_material = template_mat.copy(); #... checks ...
if not new_material: print(f" Error: Failed copy template mat. Skip."); continue
new_material.name = material_name
if not new_material.use_nodes or not new_material.node_tree: print(f" Error: New mat '{material_name}' no nodes."); continue
placeholder_node = find_node_by_label(new_material.node_tree, PLACEHOLDER_NODE_LABEL, 'GROUP'); #... checks ...
if not placeholder_node: print(f" Error: Placeholder '{PLACEHOLDER_NODE_LABEL}' not found."); continue
placeholder_node.node_tree = linked_nodegroup
print(f" Created material '{material_name}' and linked NG '{linked_nodegroup.name}'.")
# --- Load base COL-1 image once ---
thumbnail_image = None
if preview_path and Path(preview_path).is_file():
try: thumbnail_image = bpy.data.images.load(preview_path, check_existing=True)
except Exception as e_load_base: print(f" Error loading base thumbnail '{preview_path}': {e_load_base}")
# --- Set Viewport Color (Median) ---
median_color = None
if thumbnail_image: median_color = calculate_value_from_image(thumbnail_image, target_size=SCALED_SIZE, mode='color', method='median')
if median_color: new_material.diffuse_color = median_color; print(f" Set viewport color: {median_color[:3]}")
else: print(f" Warn: Could not set viewport color.")
# --- Determine Paths and Metal Map Existence ---
roughness_path = None; metallic_path = None; metal_map_found = False; #... etc ...
if preview_path and "_COL-1" in preview_path:
try: # ... path derivation logic ...
base_path_obj=Path(preview_path); directory=base_path_obj.parent; base_stem=base_path_obj.stem
if "_COL-1" in base_stem:
rough_stem=base_stem.replace("_COL-1", "_ROUGH")
for ext in DERIVED_MAP_EXTENSIONS:
potential_path=directory/f"{rough_stem}{ext}";
if potential_path.is_file(): roughness_path=str(potential_path); break
metal_stem=base_stem.replace("_COL-1", "_METAL")
for ext in DERIVED_MAP_EXTENSIONS:
potential_path=directory/f"{metal_stem}{ext}";
if potential_path.is_file(): metallic_path=str(potential_path); metal_map_found=True; break
except Exception as e_derive: print(f" Error deriving paths: {e_derive}")
if not metal_map_found: print(f" Info: No METAL map found. Assuming Spec/Gloss.")
# --- Set Viewport Roughness (Median, Conditional Inversion) ---
median_roughness = None; # ... etc ...
if roughness_path:
try: rough_img = bpy.data.images.load(roughness_path, check_existing=True)
except Exception as e_load_rough: print(f" Error loading rough image: {e_load_rough}")
if rough_img: median_roughness = calculate_value_from_image(rough_img, target_size=SCALED_SIZE, mode='grayscale', method='median')
else: print(f" Error: load None for rough path.")
if median_roughness is not None:
final_roughness_value = median_roughness
if not metal_map_found: final_roughness_value = 1.0 - median_roughness; print(f" Inverting ROUGH->Gloss: {median_roughness:.3f} -> {final_roughness_value:.3f}")
new_material.roughness = min(max(0.0, final_roughness_value), 1.0); print(f" Set viewport roughness: {new_material.roughness:.3f}")
else: print(f" Warn: Could not set viewport roughness.")
# --- Set Viewport Metallic (Median) ---
median_metallic = None; # ... etc ...
if metal_map_found:
try: metal_img = bpy.data.images.load(metallic_path, check_existing=True)
except Exception as e_load_metal: print(f" Error loading metal image: {e_load_metal}")
if metal_img: median_metallic = calculate_value_from_image(metal_img, target_size=SCALED_SIZE, mode='grayscale', method='median')
else: print(f" Error: load None for metal path.")
if median_metallic is not None: new_material.metallic = median_metallic; print(f" Set viewport metallic: {median_metallic:.3f}")
else: new_material.metallic = 0.0; # Default
if metal_map_found: print(f" Warn: Could not calc viewport metallic. Set 0.0.")
else: print(f" Set viewport metallic to default: 0.0")
# --- Mark Material as Asset ---
mat_asset_data = None; # ... (logic remains same) ...
try: # ... asset marking ...
if not new_material.asset_data: new_material.asset_mark(); print(f" Marked material as asset.")
mat_asset_data = new_material.asset_data
except Exception as e_asset: print(f" Error marking mat asset: {e_asset}")
# --- Copy Asset Tags ---
if mat_asset_data and linked_nodegroup.asset_data: # ... (logic remains same) ...
try: # ... tag copying ...
source_tags=linked_nodegroup.asset_data.tags; target_tags=mat_asset_data.tags
tags_copied_count=0; existing_target_tag_names={t.name for t in target_tags}
for src_tag in source_tags:
if src_tag.name not in existing_target_tag_names: target_tags.new(name=src_tag.name); tags_copied_count += 1
if tags_copied_count > 0: print(f" Copied {tags_copied_count} asset tags.")
except Exception as e_tags: print(f" Error copying tags: {e_tags}")
# --- Set Custom Preview for Material ---
if preview_path and Path(preview_path).is_file(): # ... (logic remains same) ...
try: # ... preview setting ...
with bpy.context.temp_override(id=new_material): bpy.ops.ed.lib_id_load_custom_preview(filepath=preview_path)
except RuntimeError as e_op: print(f" Error running preview op for mat '{new_material.name}': {e_op}")
except Exception as e_prev: print(f" Unexpected preview error for mat: {e_prev}")
elif preview_path: print(f" Warn: Thumb path not found for preview step: '{preview_path}'")
# --- Increment Counters & Check Limit ---
materials_created += 1 # Overall counter for summary
created_in_this_run += 1 # Counter for this run's limit
# Check limit AFTER successful creation
if MATERIAL_CREATION_LIMIT > 0 and created_in_this_run >= MATERIAL_CREATION_LIMIT:
print(f"\n--- Material Creation Limit ({MATERIAL_CREATION_LIMIT}) Reached ---")
limit_reached_flag = True
break # Exit inner loop
except Exception as e: # Catch errors for the whole NG processing block
print(f" An unexpected error occurred processing NG '{asset_nodegroup_name}': {e}")
# --- End Main Processing Block for NG ---
# Check flag to stop outer loop
if limit_reached_flag:
print("Stopping library file iteration due to limit.")
break # Exit outer loop
# (Completion summary...)
end_time = time.time(); duration = end_time - start_time; print("\n--- Material Creation Finished ---"); # ... etc ...
print(f"Duration: {duration:.2f} seconds")
print(f"Summary: Processed {nodegroups_processed} NGs. Created {materials_created} Mats this run. Skipped {materials_skipped}. Link Errors {link_errors}.")
if limit_reached_flag: print(f"NOTE: Script stopped early due to creation limit ({MATERIAL_CREATION_LIMIT}). Run again to process more.")
# --- How to Run ---
# 1. Rerun Script 1 to add "thumbnail_filepath" property.
# 2. Setup Asset Library in Prefs. Set ASSET_LIBRARY_NAME below.
# 3. In current file, create "Template_PBRMaterial" with "PBRSET_PLACEHOLDER" node.
# 4. Set MATERIAL_CREATION_LIMIT in Config section above (0 for unlimited).
# 5. Paste script & Run (Alt+P).
if __name__ == "__main__":
# Only need ASSET_LIBRARY_NAME configuration here now
if ASSET_LIBRARY_NAME == "My Asset Library": # Default check
print("\nERROR: Please update the 'ASSET_LIBRARY_NAME' variable in the script's Configuration section.")
print(" Set it to the name of your asset library in Blender Preferences before running.\n")
elif not bpy.data.materials.get(TEMPLATE_MATERIAL_NAME):
print(f"\nERROR: Template material '{TEMPLATE_MATERIAL_NAME}' not found in current file.\n")
else:
create_materials_for_library_assets(ASSET_LIBRARY_NAME)

View File

@ -1,988 +0,0 @@
# Full script - PBR Texture Importer V4 (Manifest, Auto-Save/Reload, Aspect Ratio, Asset Tags)
import bpy
import os # For auto-save rename/remove
from pathlib import Path
import time
import base64
import numpy as np # For stats calculation
import json # For manifest handling
import re # For parsing scaling string
# --- USER CONFIGURATION ---
# File Paths & Templates
texture_root_directory = r"G:\02 Content\10-19 Content\13 Textures Power of Two\13.00" # <<< CHANGE THIS PATH!
PARENT_TEMPLATE_NAME = "Template_PBRSET" # Name of the parent node group template
CHILD_TEMPLATE_NAME = "Template_PBRTYPE" # Name of the child node group template
# Processing Limits & Intervals
MAX_NEW_GROUPS_PER_RUN = 1000 # Max NEW parent groups created per run before stopping
SAVE_INTERVAL = 25 # Auto-save interval during NEW group creation (every N groups)
# Features & Behavior
AUTO_SAVE_ENABLED = True # Enable periodic auto-saving (main file + manifest) during processing?
AUTO_RELOAD_ON_FINISH = True # Save and reload the blend file upon successful script completion?
# Naming & Structure Conventions
VALID_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff"} # Allowed texture file types
RESOLUTION_LABELS = ["1k", "2k", "4k", "8k"] # Expected resolution labels (LOWEST FIRST for aspect/tag calc)
SG_VALUE_NODE_LABEL = "SpecularGlossy" # Label for the Specular/Glossy value node in parent template
HISTOGRAM_NODE_PREFIX = "Histogram-" # Prefix for Combine XYZ nodes storing stats (e.g., "Histogram-ROUGH")
ASPECT_RATIO_NODE_LABEL = "AspectRatioCorrection" # Label for the Value node storing the aspect ratio correction factor
# Texture Map Properties
PBR_COLOR_SPACE_MAP = { # Map PBR type (from filename) to Blender color space
"AO": "sRGB", "COL-1": "sRGB", "COL-2": "sRGB", "COL-3": "sRGB",
"DISP": "Non-Color", "NRM": "Non-Color", "REFL": "Non-Color", "ROUGH": "Non-Color",
"METAL": "Non-Color", "FUZZ": "Non-Color", "MASK": "Non-Color", "SSS": "sRGB",
}
DEFAULT_COLOR_SPACE = "sRGB" # Fallback color space if PBR type not in map
# --- END USER CONFIGURATION ---
# --- Helper Functions ---
def parse_texture_filename(filename_stem):
"""Parses texture filename stem based on expected convention."""
parts = filename_stem.split('_');
# Expecting Tag_Groupname_Resolution_Scaling_PBRType
if len(parts) == 5:
return {"Tag": parts[0], "Groupname": parts[1], "Resolution": parts[2], "Scaling": parts[3], "PBRType": parts[4]}
else:
print(f" Warn: Skip '{filename_stem}' - Expected 5 parts, found {len(parts)}.");
return None
def find_nodes_by_label(node_tree, label, node_type=None):
"""Finds ALL nodes in a node tree matching the label and optionally type."""
if not node_tree:
return []
matching_nodes = []
for node in node_tree.nodes:
if node.label and node.label == label:
if node_type is None or node.type == node_type:
matching_nodes.append(node)
return matching_nodes
def encode_name_b64(name_str):
"""Encodes a string using URL-safe Base64 for node group names."""
try:
return base64.urlsafe_b64encode(name_str.encode('utf-8')).decode('ascii')
except Exception as e:
print(f" Error base64 encoding '{name_str}': {e}");
return name_str # Fallback to original name on error
def calculate_image_stats(image):
"""Calculates Min, Max, Median of the first channel of a Blender image."""
if not image: return None
pixels_arr, value_channel_arr, result = None, None, None
try:
width = image.size[0]; height = image.size[1]; channels = image.channels
if width == 0 or height == 0 or channels == 0:
print(f" Warn: Invalid dims for '{image.name}'. Skip stats."); return None
actual_len = len(image.pixels); expected_len = width * height * channels
if expected_len != actual_len:
print(f" Warn: Pixel buffer mismatch for '{image.name}'. Skip stats."); return None
if actual_len == 0: return None
pixels_arr = np.fromiter(image.pixels, dtype=np.float32, count=actual_len)
if channels == 1: value_channel_arr = pixels_arr
elif channels >= 2: value_channel_arr = pixels_arr[0::channels]
else: return None
if value_channel_arr is None or value_channel_arr.size == 0:
print(f" Warn: No value channel for '{image.name}'. Skip stats."); return None
min_val = float(np.min(value_channel_arr))
max_val = float(np.max(value_channel_arr))
median_val = float(np.median(value_channel_arr))
result = (min_val, max_val, median_val)
except MemoryError:
print(f" Error: Not enough memory for stats calc on '{image.name}'.")
except Exception as e:
print(f" Error during stats calc for '{image.name}': {e}");
import traceback; traceback.print_exc()
finally:
# Explicitly delete potentially large numpy arrays
if 'value_channel_arr' in locals() and value_channel_arr is not None:
try:
del value_channel_arr
except NameError:
pass # Ignore if already gone
if 'pixels_arr' in locals() and pixels_arr is not None:
try:
del pixels_arr
except NameError:
pass # Ignore if already gone
return result
def calculate_aspect_ratio_factor(image_width, image_height, scaling_string):
"""Calculates the X-axis UV scaling factor based on image dims and scaling string."""
if image_height <= 0:
print(" Warn: Image height is zero, cannot calculate aspect ratio. Returning 1.0.")
return 1.0 # Return 1.0 if height is invalid
# Calculate the actual aspect ratio of the image file
current_aspect_ratio = image_width / image_height
# Check the scaling string
if scaling_string.upper() == "EVEN":
# 'EVEN' means uniform scaling was applied (or none needed).
# The correction factor is the image's own aspect ratio.
return current_aspect_ratio
else:
# Handle non-uniform scaling cases ("Xnnn", "Ynnn")
match = re.match(r"([XY])(\d+)", scaling_string, re.IGNORECASE)
if not match:
print(f" Warn: Invalid Scaling string format '{scaling_string}'. Returning current ratio {current_aspect_ratio:.4f} as fallback.")
# Fallback to the image's own ratio if scaling string is invalid
return current_aspect_ratio
axis = match.group(1).upper()
try:
amount = int(match.group(2))
if amount <= 0:
print(f" Warn: Zero or negative Amount in Scaling string '{scaling_string}'. Returning current ratio {current_aspect_ratio:.4f}.")
return current_aspect_ratio
except ValueError:
print(f" Warn: Invalid Amount in Scaling string '{scaling_string}'. Returning current ratio {current_aspect_ratio:.4f}.")
return current_aspect_ratio
# Apply the non-uniform correction formula
factor = current_aspect_ratio # Default to current ratio in case of issues below
scaling_factor_percent = amount / 100.0
try:
if axis == 'X':
if scaling_factor_percent == 0: raise ZeroDivisionError
factor = current_aspect_ratio / scaling_factor_percent
elif axis == 'Y':
factor = current_aspect_ratio * scaling_factor_percent
# No 'else' needed due to regex structure
except ZeroDivisionError:
print(f" Warn: Division by zero during factor calculation. Returning current ratio {current_aspect_ratio:.4f}.")
return current_aspect_ratio
return factor
# --- Manifest Helper Functions ---
def get_manifest_path(context_filepath):
"""Gets the expected path for the manifest JSON file based on blend filepath."""
if not context_filepath:
return None
blend_path = Path(context_filepath)
manifest_filename = f"{blend_path.stem}_manifest.json"
return blend_path.parent / manifest_filename
def load_manifest(manifest_path):
"""Loads the manifest data from the JSON file."""
if not manifest_path or not manifest_path.exists():
return {}
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
data = json.load(f)
print(f" Loaded manifest from: {manifest_path.name}")
return data
except json.JSONDecodeError:
print(f"!!! ERROR: Manifest file '{manifest_path.name}' is corrupted. Starting fresh. !!!")
return {}
except Exception as e:
print(f"!!! ERROR: Could not load manifest file '{manifest_path.name}': {e} !!!")
return {}
def save_manifest(manifest_path, data):
"""Saves the manifest data to the JSON file."""
if not manifest_path:
return False
try:
with open(manifest_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
return True
except Exception as e:
print(f"!!!!!!!!!!!!!!!!!!!\n!!! Manifest save FAILED: {e} !!!\n!!!!!!!!!!!!!!!!!!!")
return False
# --- Auto-Save Helper Function ---
def perform_safe_autosave(manifest_path, manifest_data):
"""Performs a safe auto-save of the main blend file and manifest."""
blend_filepath = bpy.data.filepath
if not blend_filepath or not manifest_path:
print(" Skipping auto-save: Blend file is not saved.")
return False
print(f"\n--- Attempting Auto-Save ({time.strftime('%H:%M:%S')}) ---")
blend_path = Path(blend_filepath)
manifest_path_obj = Path(manifest_path) # Ensure it's a Path object
blend_bak_path = blend_path.with_suffix('.blend.bak')
manifest_bak_path = manifest_path_obj.with_suffix('.json.bak')
# 1. Delete old backups if they exist
try:
if blend_bak_path.exists():
blend_bak_path.unlink()
if manifest_bak_path.exists():
manifest_bak_path.unlink()
except OSError as e:
print(f" Warn: Could not delete old backup file: {e}")
# Continue anyway, renaming might still work
# 2. Rename current files to backup
renamed_blend = False
renamed_manifest = False
try:
if blend_path.exists():
os.rename(blend_path, blend_bak_path)
renamed_blend = True
# print(f" Renamed '{blend_path.name}' to '{blend_bak_path.name}'") # Optional verbose log
if manifest_path_obj.exists():
os.rename(manifest_path_obj, manifest_bak_path)
renamed_manifest = True
# print(f" Renamed '{manifest_path_obj.name}' to '{manifest_bak_path.name}'") # Optional verbose log
except OSError as e:
print(f"!!! ERROR: Failed to rename files for backup: {e} !!!")
# Attempt to roll back renames if only one succeeded
if renamed_blend and not renamed_manifest and blend_bak_path.exists():
print(f" Attempting rollback: Renaming {blend_bak_path.name} back...")
try:
os.rename(blend_bak_path, blend_path)
except OSError as rb_e:
print(f" Rollback rename of blend file FAILED: {rb_e}")
if renamed_manifest and not renamed_blend and manifest_bak_path.exists():
print(f" Attempting rollback: Renaming {manifest_bak_path.name} back...")
try:
os.rename(manifest_bak_path, manifest_path_obj)
except OSError as rb_e:
print(f" Rollback rename of manifest file FAILED: {rb_e}")
print("--- Auto-Save ABORTED ---")
return False
# 3. Save new main blend file
save_blend_success = False
try:
bpy.ops.wm.save_mainfile()
print(f" Saved main blend file: {blend_path.name}")
save_blend_success = True
except Exception as e:
print(f"!!!!!!!!!!!!!!!!!!!!!!!!!\n!!! Auto-Save FAILED (Blend File Save): {e} !!!\n!!!!!!!!!!!!!!!!!!!!!!!!!")
# Attempt to restore from backup
print(" Attempting to restore from backup...")
try:
if blend_bak_path.exists():
os.rename(blend_bak_path, blend_path)
if manifest_bak_path.exists():
os.rename(manifest_bak_path, manifest_path_obj)
print(" Restored from backup.")
except OSError as re:
print(f"!!! CRITICAL: Failed to restore from backup after save failure: {re} !!!")
print(f"!!! Please check for '.bak' files manually in: {blend_path.parent} !!!")
print("--- Auto-Save ABORTED ---")
return False
# 4. Save new manifest file (only if blend save succeeded)
if save_blend_success:
if save_manifest(manifest_path, manifest_data):
print(f" Saved manifest file: {manifest_path_obj.name}")
print("--- Auto-Save Successful ---")
return True
else:
# Manifest save failed, but blend file is okay. Warn user.
print("!!! WARNING: Auto-save completed for blend file, but manifest save FAILED. Manifest may be out of sync. !!!")
return True # Still counts as 'completed' in terms of blend file safety
return False # Should not be reached
# --- Asset Tagging Helper Functions ---
def add_tag_if_new(asset_data, tag_name):
"""Adds a tag to the asset data if it's not None/empty and doesn't already exist."""
if not asset_data or not tag_name or not isinstance(tag_name, str) or tag_name.strip() == "":
return False # Invalid input
cleaned_tag_name = tag_name.strip() # Remove leading/trailing whitespace
if not cleaned_tag_name:
return False # Don't add empty tags
# Check if tag already exists
if cleaned_tag_name not in [t.name for t in asset_data.tags]:
try:
asset_data.tags.new(cleaned_tag_name)
print(f" + Added Asset Tag: '{cleaned_tag_name}'")
return True
except Exception as e:
print(f" Error adding tag '{cleaned_tag_name}': {e}")
return False
else:
# print(f" Tag '{cleaned_tag_name}' already exists.") # Optional info
return False # Not added because it existed
def get_supplier_tag_from_path(file_path_str, groupname):
"""
Determines supplier tag based on directory structure.
Assumes structure is .../Supplier/Groupname/file.ext or .../Supplier/file.ext
"""
try:
file_path = Path(file_path_str).resolve()
groupname_lower = groupname.lower()
if not file_path.is_file():
print(f" Warn (get_supplier_tag): Input path is not a file: {file_path_str}")
return None
current_dir = file_path.parent # Directory containing the file
if not current_dir:
print(f" Warn (get_supplier_tag): Cannot get parent directory for {file_path_str}")
return None # Cannot determine without parent
parent_dir = current_dir.parent # Directory potentially containing the 'supplier' name
# Check if we are at the root or have no parent
if not parent_dir or parent_dir == current_dir:
# If the file is in the root scan directory or similar shallow path,
# maybe the directory it's in IS the supplier tag? Or return None?
# Returning current_dir.name might be unexpected, let's return None for safety.
print(f" Warn (get_supplier_tag): File path too shallow to determine supplier reliably: {file_path_str}")
return None
# Compare the file's directory name with the groupname
if current_dir.name.lower() == groupname_lower:
# Structure is likely .../Supplier/Groupname/file.ext
# Return the name of the directory ABOVE the groupname directory
return parent_dir.name
else:
# Structure is likely .../Supplier/file.ext
# Return the name of the directory CONTAINING the file
return current_dir.name
except Exception as e:
print(f" Error getting supplier tag for {groupname} from path {file_path_str}: {e}")
return None
def apply_asset_tags(parent_group, groupname, group_info):
"""Applies various asset tags to the parent node group."""
if not parent_group:
return
# 1. Ensure group is marked as an asset
try:
if not parent_group.asset_data:
print(f" Marking '{parent_group.name}' as asset for tagging.")
parent_group.asset_mark()
# Ensure asset_data is available after marking
if not parent_group.asset_data:
print(f" Error: Could not access asset_data for '{parent_group.name}' after marking.")
return
asset_data = parent_group.asset_data
except Exception as e_mark:
print(f" Error marking group '{parent_group.name}' as asset: {e_mark}")
return # Cannot proceed without asset_data
# 2. Apply Supplier Tag (Current Requirement)
try:
# Find lowest resolution path (reuse logic from aspect ratio)
lowest_res_path = None; found_res = False
pbr_types_dict = group_info.get("pbr_types", {})
# Check RESOLUTION_LABELS in order (assuming lowest is first)
for res_label in RESOLUTION_LABELS:
for res_data in pbr_types_dict.values(): # Check all PBR types for this res
if res_label in res_data:
lowest_res_path = res_data[res_label]
found_res = True
break # Found path for this resolution label
if found_res:
break # Found lowest available resolution path
if lowest_res_path:
supplier_tag = get_supplier_tag_from_path(lowest_res_path, groupname)
if supplier_tag:
add_tag_if_new(asset_data, supplier_tag) # Use helper to add if new
else:
print(f" Warn (apply_asset_tags): No image path found for group '{groupname}' to determine supplier tag.")
except Exception as e_supp:
print(f" Error during supplier tag processing for '{groupname}': {e_supp}")
# 3. --- Future Tagging Logic Placeholder ---
# Example: Tag based on PBR Types present
# try:
# present_pbr_types = list(group_info.get("pbr_types", {}).keys())
# for pbr_tag in present_pbr_types:
# # Maybe add prefix or modify tag name
# add_tag_if_new(asset_data, f"PBR_{pbr_tag}")
# except Exception as e_pbr:
# print(f" Error during PBR type tagging for '{groupname}': {e_pbr}")
# Example: Tag based on filename Tag (if not default like 'T-MR')
# filename_tag = group_info.get("tag") # Need to store 'Tag' in group_info during scan
# if filename_tag and filename_tag not in ["T-MR", "T-SG"]:
# add_tag_if_new(asset_data, f"Tag_{filename_tag}")
# --- End Future Tagging Logic ---
# --- Main Processing Function ---
def process_textures_to_groups(root_directory):
"""Scans textures, creates/updates node groups based on templates and manifest."""
start_time = time.time()
print(f"--- Starting Texture Processing ---")
print(f"Scanning directory: {root_directory}")
root_path = Path(root_directory)
if not root_path.is_dir():
print(f"Error: Directory not found: {root_directory}")
return False # Indicate failure
# --- Manifest Setup ---
current_blend_filepath = bpy.data.filepath
manifest_path = get_manifest_path(current_blend_filepath)
manifest_data = {}
manifest_enabled = False
if manifest_path:
manifest_data = load_manifest(manifest_path)
manifest_enabled = True
# Flag will be True if any change requires saving the manifest
manifest_needs_saving = False
# --- End Manifest Setup ---
# --- Load Templates ---
template_parent = bpy.data.node_groups.get(PARENT_TEMPLATE_NAME)
template_child = bpy.data.node_groups.get(CHILD_TEMPLATE_NAME)
if not template_parent:
print(f"Error: Parent template '{PARENT_TEMPLATE_NAME}' not found.")
return False
if not template_child:
print(f"Error: Child template '{CHILD_TEMPLATE_NAME}' not found.")
return False
print(f"Found templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'")
# --- End Load Templates ---
# --- Initialize Data Structures ---
# Stores {"GroupName": {"pbr_types": {...}, "scaling": "...", "sg": False, "thumb": "..."}}
texture_data = {}
file_count, processed_files = 0, 0
groups_created, groups_updated, child_groups_created, child_groups_updated = 0, 0, 0, 0
nodes_updated, links_created = 0, 0
# Cache for image datablocks loaded in THIS RUN only
loaded_images_this_run = {}
# --- End Initialize Data Structures ---
print("Scanning files...")
# --- File Scanning ---
for dirpath, _, filenames in os.walk(root_directory):
for filename in filenames:
file_path = Path(dirpath) / filename
# Check extension
if file_path.suffix.lower() not in VALID_EXTENSIONS:
continue
file_count += 1
filename_stem = file_path.stem
parsed = parse_texture_filename(filename_stem)
if not parsed:
continue # Skip if filename doesn't match format
# Extract parts
groupname = parsed["Groupname"]
pbr_type = parsed["PBRType"]
resolution_label = parsed["Resolution"].lower()
scaling_str = parsed["Scaling"]
tag_str = parsed["Tag"].upper()
file_path_str = str(file_path)
# Validate resolution label
if resolution_label not in RESOLUTION_LABELS:
print(f"Warn: Skip '{filename}' - Invalid Res '{resolution_label}'. Expected one of {RESOLUTION_LABELS}")
continue
# Ensure base structure for group exists in texture_data
group_entry = texture_data.setdefault(groupname, {
"pbr_types": {}, "scaling": None, "sg": False, "thumb": None
})
# Store texture path under the specific PBR type and resolution
group_entry["pbr_types"].setdefault(pbr_type, {})[resolution_label] = file_path_str
# Store scaling string ONCE per groupname (first encountered wins)
if group_entry["scaling"] is None:
group_entry["scaling"] = scaling_str
elif group_entry["scaling"] != scaling_str:
# Warn only once per group if inconsistency found
if not group_entry.get("scaling_warning_printed", False):
print(f" Warn: Inconsistent 'Scaling' string found for group '{groupname}'. "
f"Using first encountered: '{group_entry['scaling']}'.")
group_entry["scaling_warning_printed"] = True
# Track SG status and thumbnail path
if tag_str == "T-SG":
group_entry["sg"] = True
# Use 1k COL-1 as the potential thumbnail source
if resolution_label == "1k" and pbr_type == "COL-1":
group_entry["thumb"] = file_path_str
processed_files += 1
# --- End File Scanning ---
print(f"\nFile Scan Complete. Found {file_count} files, parsed {processed_files} valid textures.")
total_groups_found = len(texture_data)
print(f"Total unique Groupnames found: {total_groups_found}")
if not texture_data:
print("No valid textures found. Exiting.")
return True # No work needed is considered success
print("\n--- Processing Node Groups ---")
all_groupnames = sorted(list(texture_data.keys()))
processing_stopped_early = False
# --- Main Processing Loop ---
for groupname in all_groupnames:
group_info = texture_data[groupname] # Get pre-scanned info
pbr_types_data = group_info.get("pbr_types", {})
scaling_string_for_group = group_info.get("scaling")
sg_status_for_group = group_info.get("sg", False)
thumbnail_path_for_group = group_info.get("thumb")
target_parent_name = f"PBRSET_{groupname}"
print(f"\nProcessing Group: '{target_parent_name}'")
parent_group = bpy.data.node_groups.get(target_parent_name)
is_new_parent = False
# --- Find or Create Parent Group ---
if parent_group is None:
# Check batch limit BEFORE creating
if groups_created >= MAX_NEW_GROUPS_PER_RUN:
print(f"\n--- Reached NEW parent group limit ({MAX_NEW_GROUPS_PER_RUN}). Stopping. ---")
processing_stopped_early = True
break # Exit the main groupname loop
print(f" Creating new parent group: '{target_parent_name}'")
parent_group = template_parent.copy()
if not parent_group:
print(f" Error: Failed copy parent template. Skip group '{groupname}'.")
continue # Skip to next groupname
parent_group.name = target_parent_name
groups_created += 1
is_new_parent = True
# --- Auto-Save Trigger ---
# Trigger AFTER creating the group and incrementing counter
if AUTO_SAVE_ENABLED and groups_created > 0 and groups_created % SAVE_INTERVAL == 0:
if perform_safe_autosave(manifest_path, manifest_data):
# If auto-save succeeded, manifest is up-to-date on disk
manifest_needs_saving = False
else:
# Auto-save failed, continue but warn
print("!!! WARNING: Auto-save failed. Continuing processing... !!!")
# --- End Auto-Save Trigger ---
else: # Update Existing Parent Group
print(f" Updating existing parent group: '{target_parent_name}'")
groups_updated += 1
# --- End Find or Create Parent Group ---
# --- Process Parent Group Internals ---
# This block processes both newly created and existing parent groups
try:
# --- Calculate and Store Aspect Ratio Correction (Once per group) ---
# Find the designated Value node in the parent template
aspect_node_list = find_nodes_by_label(parent_group, ASPECT_RATIO_NODE_LABEL, 'VALUE')
if aspect_node_list:
aspect_node = aspect_node_list[0] # Assume first found is correct
if scaling_string_for_group:
# Find the path to the lowest resolution image available
lowest_res_path = None; found_res = False
# Check resolution labels in configured order (e.g., "1k", "2k"...)
for res_label in RESOLUTION_LABELS:
# Check all PBR types for this resolution
for res_data in pbr_types_data.values():
if res_label in res_data:
lowest_res_path = res_data[res_label]
found_res = True
break # Found path for this resolution label
if found_res:
break # Found lowest available resolution path
if lowest_res_path:
# Load the image (use cache if possible)
img = None; img_load_error = False
if lowest_res_path in loaded_images_this_run:
img = loaded_images_this_run[lowest_res_path]
img_load_error = (img is None) # Check if cached result was failure
else:
# Attempt to load if not cached
try:
img_path_obj = Path(lowest_res_path)
if img_path_obj.is_file():
img = bpy.data.images.load(lowest_res_path, check_existing=True)
else:
img_load_error = True
print(f" Error: Aspect source image not found: {lowest_res_path}")
if img is None and not img_load_error: # Check if load function returned None
img_load_error = True
print(f" Error: Failed loading aspect source image: {lowest_res_path}")
except Exception as e_load_aspect:
print(f" Error loading aspect source image: {e_load_aspect}")
img_load_error = True
# Cache the result (image object or None)
loaded_images_this_run[lowest_res_path] = img if not img_load_error else None
if not img_load_error and img:
# Get dimensions and calculate factor
img_width, img_height = img.size[0], img.size[1]
factor = calculate_aspect_ratio_factor(img_width, img_height, scaling_string_for_group)
print(f" Calculated Aspect Ratio Factor: {factor:.4f} (from {img_width}x{img_height}, Scaling='{scaling_string_for_group}')")
# Store factor in node if value changed significantly
if abs(aspect_node.outputs[0].default_value - factor) > 0.0001:
aspect_node.outputs[0].default_value = factor
print(f" Set '{ASPECT_RATIO_NODE_LABEL}' node value to {factor:.4f}")
else:
print(f" Warn: Could not load image '{lowest_res_path}' for aspect ratio calc.")
else:
print(f" Warn: No suitable image found (e.g., 1k) to calculate aspect ratio for '{groupname}'.")
else:
print(f" Warn: No Scaling string found for group '{groupname}'. Cannot calculate aspect ratio.")
# else: # Optional Warning if node is missing from template
# print(f" Warn: Value node '{ASPECT_RATIO_NODE_LABEL}' not found in parent group '{parent_group.name}'. Cannot store aspect ratio.")
# --- End Aspect Ratio Correction ---
# Set SG Value
sg_nodes = find_nodes_by_label(parent_group, SG_VALUE_NODE_LABEL, 'VALUE')
if sg_nodes:
sg_node = sg_nodes[0]
target_val = 1.0 if sg_status_for_group else 0.0
if abs(sg_node.outputs[0].default_value - target_val) > 0.001:
sg_node.outputs[0].default_value = target_val
print(f" Set '{SG_VALUE_NODE_LABEL}' to: {target_val}")
# Set Asset Info (Thumbnail Path Prop, Initial Preview & Tagging)
# This block runs for both new and existing groups
try:
# 1. Set/Update Thumbnail Path Property & Mark Asset
if not parent_group.asset_data:
parent_group.asset_mark()
print(f" Marked '{parent_group.name}' as asset.")
# Update thumbnail property logic
if thumbnail_path_for_group:
thumb_path_obj = Path(thumbnail_path_for_group)
if thumb_path_obj.is_file():
if parent_group.get("thumbnail_filepath") != thumbnail_path_for_group:
parent_group["thumbnail_filepath"] = thumbnail_path_for_group
if not is_new_parent: print(f" Updated thumbnail path property.") # Log update only if not new
elif "thumbnail_filepath" in parent_group:
del parent_group["thumbnail_filepath"]
if not is_new_parent: print(f" Removed thumbnail path property (file not found).")
elif "thumbnail_filepath" in parent_group:
del parent_group["thumbnail_filepath"]
if not is_new_parent: print(f" Removed old thumbnail path property.")
# 2. Set Initial Preview (Only if NEW parent)
if is_new_parent and thumbnail_path_for_group and Path(thumbnail_path_for_group).is_file():
print(f" Attempting initial preview from '{Path(thumbnail_path_for_group).name}'...")
try:
with bpy.context.temp_override(id=parent_group):
bpy.ops.ed.lib_id_load_custom_preview(filepath=thumbnail_path_for_group)
print(f" Set initial custom preview.")
except Exception as e_prev:
print(f" Preview Error: {e_prev}")
# 3. Apply Asset Tags (Supplier, etc.)
apply_asset_tags(parent_group, groupname, group_info)
except Exception as e_asset_info:
print(f" Error setting asset info/tags: {e_asset_info}")
# --- End Asset Info ---
# --- Process Child Groups (PBR Types) ---
for pbr_type, resolutions_data in pbr_types_data.items():
# print(f" Processing PBR Type: {pbr_type}") # Can be verbose
# Find placeholder node in parent
holder_nodes = find_nodes_by_label(parent_group, pbr_type, 'GROUP')
if not holder_nodes:
print(f" Warn: No placeholder node labeled '{pbr_type}' in parent group '{parent_group.name}'. Skipping PBR Type.")
continue
holder_node = holder_nodes[0] # Assume first is correct
# Determine child group name (Base64 encoded)
logical_child_name = f"{groupname}_{pbr_type}"
target_child_name_b64 = encode_name_b64(logical_child_name)
# Find or Create Child Group
child_group = bpy.data.node_groups.get(target_child_name_b64)
if child_group is None:
# print(f" Creating new child group for '{pbr_type}'") # Verbose
child_group = template_child.copy()
if not child_group:
print(f" Error: Failed copy child template. Skip PBR Type.")
continue
child_group.name = target_child_name_b64
child_groups_created += 1
else:
# print(f" Updating existing child group for '{pbr_type}'") # Verbose
child_groups_updated += 1
# Assign child group to placeholder if needed
if holder_node.node_tree != child_group:
holder_node.node_tree = child_group
print(f" Assigned child group '{child_group.name}' to placeholder '{holder_node.label}'.")
# Connect placeholder output to parent output socket if needed
try:
source_socket = holder_node.outputs[0] if holder_node.outputs else None
group_output_node = next((n for n in parent_group.nodes if n.type == 'GROUP_OUTPUT'), None)
target_socket = None
if group_output_node:
target_socket = group_output_node.inputs.get(pbr_type) # Get socket by name/label
if source_socket and target_socket:
# Check if link already exists
link_exists = any(link.from_socket == source_socket and link.to_socket == target_socket for link in parent_group.links)
if not link_exists:
parent_group.links.new(source_socket, target_socket)
links_created += 1
print(f" Connected '{holder_node.label}' output to parent output socket '{pbr_type}'.")
# else: # Optional warning if sockets aren't found
# if not source_socket: print(f" Warn: No output socket found on placeholder '{holder_node.label}'.")
# if not target_socket: print(f" Warn: No input socket '{pbr_type}' found on parent output node.")
except Exception as e_link:
print(f" Error linking sockets for '{pbr_type}': {e_link}")
# Ensure parent output socket type is Color
try:
item = parent_group.interface.items_tree.get(pbr_type)
if item and item.in_out == 'OUTPUT' and item.socket_type != 'NodeSocketColor':
item.socket_type = 'NodeSocketColor'
# print(f" Set parent output socket '{pbr_type}' type to Color.") # Optional info
except Exception as e_sock:
print(f" Error updating socket type for '{pbr_type}': {e_sock}")
# --- Process Resolutions within Child Group ---
for resolution_label, image_path_str in resolutions_data.items():
# Find image texture nodes within the CHILD group
image_nodes = find_nodes_by_label(child_group, resolution_label, 'TEX_IMAGE')
if not image_nodes:
# print(f" Warn: No node labeled '{resolution_label}' found in child group for '{pbr_type}'.") # Optional
continue
# --- >>> Manifest Check <<< ---
is_processed = False
if manifest_enabled: # Only check if manifest is enabled
# Check if this specific group/pbr/res combo is done
processed_resolutions = manifest_data.get(groupname, {}).get(pbr_type, [])
if resolution_label in processed_resolutions:
is_processed = True
# print(f" Skipping {groupname}/{pbr_type}/{resolution_label} (Manifest)") # Verbose skip log
if is_processed:
continue # Skip to the next resolution
# --- >>> End Manifest Check <<< ---
# --- Load Image & Assign (if not skipped) ---
# print(f" Processing Resolution: {resolution_label} for {pbr_type}") # Verbose
img = None
image_load_failed = False
# Check intra-run cache first
if image_path_str in loaded_images_this_run:
img = loaded_images_this_run[image_path_str]
image_load_failed = (img is None) # Respect cached failure
else:
# Not cached in this run, attempt to load
try:
image_path = Path(image_path_str)
if not image_path.is_file():
print(f" Error: Image file not found: {image_path_str}")
image_load_failed = True
else:
# Use check_existing=True to potentially reuse existing datablocks
img = bpy.data.images.load(str(image_path), check_existing=True)
if not img:
print(f" Error: Failed loading image via bpy.data.images.load: {image_path_str}")
image_load_failed = True
# else: # Success block is handled below
# pass
except RuntimeError as e_runtime_load:
print(f" Runtime Error loading image '{image_path_str}': {e_runtime_load}")
image_load_failed = True
except Exception as e_gen_load:
print(f" Unexpected error loading image '{image_path_str}': {e_gen_load}")
image_load_failed = True
# Cache result (image object or None for failure)
loaded_images_this_run[image_path_str] = img if not image_load_failed else None
# --- Process image if loaded/cached successfully ---
if not image_load_failed and img:
try:
# Set Color Space
correct_color_space = PBR_COLOR_SPACE_MAP.get(pbr_type, DEFAULT_COLOR_SPACE)
if img.colorspace_settings.name != correct_color_space:
print(f" Setting '{Path(img.filepath).name}' color space -> {correct_color_space}")
img.colorspace_settings.name = correct_color_space
# Histogram Stats Calculation
if resolution_label == "1k" and pbr_type in ["ROUGH", "DISP"]:
target_node_label = f"{HISTOGRAM_NODE_PREFIX}{pbr_type}"
target_nodes = find_nodes_by_label(parent_group, target_node_label, 'COMBXYZ')
if target_nodes:
target_node = target_nodes[0]
try:
socket_x = target_node.inputs.get("X")
socket_y = target_node.inputs.get("Y")
socket_z = target_node.inputs.get("Z")
if socket_x and socket_y and socket_z:
print(f" Calculating histogram stats for {pbr_type} 1K...")
stats = calculate_image_stats(img)
if stats:
min_val, max_val, median_val = stats
print(f" Stats: Min={min_val:.4f}, Max={max_val:.4f}, Median={median_val:.4f}")
# Store stats in the Combine XYZ node
socket_x.default_value = min_val
socket_y.default_value = max_val
socket_z.default_value = median_val
print(f" Stored stats in '{target_node_label}'.")
else:
print(f" Warn: Failed calc stats for '{Path(img.filepath).name}'.")
# else: print(f" Warn: Node '{target_node_label}' missing X/Y/Z sockets.")
except Exception as e_combxyz_store:
print(f" Error processing stats in '{target_node_label}': {e_combxyz_store}")
# else: print(f" Warn: No stats node '{target_node_label}' found.")
# Assign Image to nodes in child group
nodes_updated_this_res = 0
for image_node in image_nodes:
if image_node.image != img:
image_node.image = img
nodes_updated_this_res += 1
nodes_updated += nodes_updated_this_res
if nodes_updated_this_res > 0:
print(f" Assigned image '{Path(img.filepath).name}' to {nodes_updated_this_res} node(s).")
# --- >>> Update Manifest <<< ---
if manifest_enabled:
# Ensure nested structure exists
manifest_data.setdefault(groupname, {}).setdefault(pbr_type, [])
# Add resolution if not already present
if resolution_label not in manifest_data[groupname][pbr_type]:
manifest_data[groupname][pbr_type].append(resolution_label)
# Keep the list sorted for consistency in the JSON file
manifest_data[groupname][pbr_type].sort()
manifest_needs_saving = True # Mark that we need to save later
# print(f" Marked {groupname}/{pbr_type}/{resolution_label} processed in manifest.") # Verbose
# --- >>> End Update Manifest <<< ---
except Exception as e_proc_img:
print(f" Error during post-load processing for image '{image_path_str}': {e_proc_img}")
# Continue to next resolution even if post-load fails
# --- End Process image ---
# --- End Resolution Loop ---
# --- End PBR Type Loop ---
except Exception as e_group:
print(f" !!! ERROR processing group '{groupname}': {e_group} !!!")
import traceback; traceback.print_exc()
continue # Continue to next groupname
# --- End Main Processing Loop ---
# --- Final Manifest Save ---
# Save if manifest is enabled AND changes were made since the last save/start.
# This happens even if the script stopped early due to MAX_NEW_GROUPS_PER_RUN.
if manifest_enabled and manifest_needs_saving:
print("\n--- Attempting Final Manifest Save (End of Run) ---")
if save_manifest(manifest_path, manifest_data):
print(" Manifest saved successfully.")
# Error message handled within save_manifest
# --- End Final Manifest Save ---
# --- Final Summary ---
end_time = time.time(); duration = end_time - start_time
print("\n--- Script Run Finished ---")
if processing_stopped_early:
print(f"--- NOTE: Reached NEW parent group processing limit ({MAX_NEW_GROUPS_PER_RUN}). ---")
print(f"--- You may need to SAVE manually, REVERT/RELOAD file, and RUN SCRIPT AGAIN. ---")
print(f"Duration: {duration:.2f} seconds this run.")
print(f"Summary: New Parents={groups_created}, Updated Parents={groups_updated}, New Children={child_groups_created}, Updated Children={child_groups_updated}.")
print(f" Images assigned={nodes_updated} times. Links created={links_created}.")
# Add other stats if needed, e.g., number of tags added
# --- End Final Summary ---
return True # Indicate successful completion (or reaching limit)
# --- How to Run ---
# 1. Ensure 'numpy' is available in Blender's Python environment.
# 2. Create Node Group "Template_PBRSET": Configure placeholders, Value nodes (SG, Aspect Ratio), Stats nodes, outputs.
# 3. Create Node Group "Template_PBRTYPE": Configure Image Texture nodes labeled by resolution.
# 4. !! SAVE YOUR BLEND FILE AT LEAST ONCE !! for manifest, auto-saving, and auto-reloading to work.
# 5. Adjust variables in the '--- USER CONFIGURATION ---' section at the top as needed.
# 6. Paste into Blender's Text Editor and run (Alt+P or Run Script button). Check Window -> Toggle System Console.
# 7. If script stops due to limit: SAVE manually, REVERT/REOPEN file, RUN SCRIPT AGAIN. Manifest prevents reprocessing.
if __name__ == "__main__":
print(f"Script execution started at: {time.strftime('%Y-%m-%d %H:%M:%S')}")
# Pre-run Checks using variables from CONFIG section
valid_run_setup = True
try:
tex_dir_path = Path(texture_root_directory)
# Basic check if path looks like a placeholder or doesn't exist
if texture_root_directory == r"C:\path\to\your\texture\library" or not tex_dir_path.is_dir() :
print(f"\nERROR: 'texture_root_directory' is invalid or a placeholder.")
print(f" Current value: '{texture_root_directory}'")
valid_run_setup = False
except Exception as e_path:
print(f"\nERROR checking texture_root_directory: {e_path}")
valid_run_setup = False
# Check templates
if not bpy.data.node_groups.get(PARENT_TEMPLATE_NAME):
print(f"\nERROR: Parent template node group '{PARENT_TEMPLATE_NAME}' not found.")
valid_run_setup = False
if not bpy.data.node_groups.get(CHILD_TEMPLATE_NAME):
print(f"\nERROR: Child template node group '{CHILD_TEMPLATE_NAME}' not found.")
valid_run_setup = False
# Check numpy (needed for stats)
try:
import numpy
except ImportError:
print("\nCRITICAL ERROR: Python library 'numpy' not found (required for image stats).")
print(" Please install numpy into Blender's Python environment.")
valid_run_setup = False
# Execute main function if setup checks pass
script_completed_successfully = False
if valid_run_setup:
# Check if file is saved before running features that depend on it
if not bpy.data.filepath:
print("\nWARNING: Blend file not saved. Manifest, Auto-Save, and Auto-Reload features disabled.")
script_completed_successfully = process_textures_to_groups(texture_root_directory)
else:
print("\nScript aborted due to configuration errors.")
# --- Final Save & Reload ---
# Use config variables directly as they are in module scope
if script_completed_successfully and AUTO_RELOAD_ON_FINISH:
if bpy.data.filepath: # Only if file is saved
print("\n--- Auto-saving and reloading blend file ---")
try:
bpy.ops.wm.save_mainfile()
print(" Blend file saved.")
print(" Reloading...")
# Ensure script execution stops cleanly before reload starts
bpy.ops.wm.open_mainfile(filepath=bpy.data.filepath)
# Script execution effectively stops here upon reload
except Exception as e:
print(f"!!! ERROR during final save/reload: {e} !!!")
else:
print("\nSkipping final save & reload because the blend file is not saved.")
# --- End Final Save & Reload ---
# This print might not be reached if reload occurs
print(f"Script execution finished processing at: {time.strftime('%Y-%m-%d %H:%M:%S')}")

File diff suppressed because it is too large Load Diff

View File

@ -1,144 +0,0 @@
# config.py
# Core settings defining the pipeline standards and output format.
# --- Core Definitions ---
# Old definitions (commented out)
# ALLOWED_ASSET_TYPES = ["Surface", "Model", "Decal", "Atlas", "UtilityMap"]
# ALLOWED_FILE_TYPES = [
# "MAP_COL", "MAP_NRM", "MAP_METAL", "MAP_ROUGH", "MAP_AO", "MAP_DISP",
# "MAP_REFL", "MAP_SSS", "MAP_FUZZ", "MAP_IDMAP", "MAP_MASK",
# "MAP_IMPERFECTION", # Added for imperfection maps
# "MODEL", "EXTRA", "FILE_IGNORE"
# ]
# New definitions using dictionaries
ASSET_TYPE_DEFINITIONS = {
"Surface": {
"description": "Standard PBR material set for a surface.",
"color": "#87CEEB", # Light Blue
"examples": ["WoodFloor01", "MetalPlate05"]
},
"Model": {
"description": "A 3D model file.",
"color": "#FFA500", # Orange
"examples": ["Chair.fbx", "Character.obj"]
},
"Decal": {
"description": "A texture designed to be projected onto surfaces.",
"color": "#90EE90", # Light Green
"examples": ["Graffiti01", "LeakStain03"]
},
"Atlas": {
"description": "A texture sheet containing multiple smaller textures.",
"color": "#FFC0CB", # Pink
"examples": ["FoliageAtlas", "UITextureSheet"]
},
"UtilityMap": {
"description": "A map used for specific technical purposes (e.g., flow map).",
"color": "#D3D3D3", # Light Grey
"examples": ["FlowMap", "CurvatureMap"]
}
}
FILE_TYPE_DEFINITIONS = {
"MAP_COL": {"description": "Color/Albedo Map", "color": "#FFFFE0", "examples": ["_col.", "_basecolor."]},
"MAP_NRM": {"description": "Normal Map", "color": "#E6E6FA", "examples": ["_nrm.", "_normal."]},
"MAP_METAL": {"description": "Metalness Map", "color": "#C0C0C0", "examples": ["_metal.", "_met."]},
"MAP_ROUGH": {"description": "Roughness Map", "color": "#A0522D", "examples": ["_rough.", "_rgh."]},
"MAP_AO": {"description": "Ambient Occlusion Map", "color": "#A9A9A9", "examples": ["_ao.", "_ambientocclusion."]},
"MAP_DISP": {"description": "Displacement/Height Map", "color": "#FFB6C1", "examples": ["_disp.", "_height."]},
"MAP_REFL": {"description": "Reflection/Specular Map", "color": "#E0FFFF", "examples": ["_refl.", "_specular."]},
"MAP_SSS": {"description": "Subsurface Scattering Map", "color": "#FFDAB9", "examples": ["_sss.", "_subsurface."]},
"MAP_FUZZ": {"description": "Fuzz/Sheen Map", "color": "#FFA07A", "examples": ["_fuzz.", "_sheen."]},
"MAP_IDMAP": {"description": "ID Map (for masking)", "color": "#F08080", "examples": ["_id.", "_matid."]},
"MAP_MASK": {"description": "Generic Mask Map", "color": "#FFFFFF", "examples": ["_mask."]},
"MAP_IMPERFECTION": {"description": "Imperfection Map (scratches, dust)", "color": "#F0E68C", "examples": ["_imp.", "_imperfection."]},
"MODEL": {"description": "3D Model File", "color": "#FFA500", "examples": [".fbx", ".obj"]},
"EXTRA": {"description": "Non-standard/Unclassified File", "color": "#778899", "examples": [".txt", ".zip"]},
"FILE_IGNORE": {"description": "File to be ignored", "color": "#2F4F4F", "examples": ["Thumbs.db", ".DS_Store"]}
}
# --- Target Output Standards ---
TARGET_FILENAME_PATTERN = "{base_name}_{map_type}_{resolution}.{ext}"
STANDARD_MAP_TYPES = [
"COL", "NRM", "ROUGH", "METAL", "AO", "DISP", "REFL",
"SSS", "FUZZ", "IDMAP", "MASK"
]
# Map types that should always receive a numeric suffix (e.g., COL-1, COL-2)
# based on preset keyword order, even if only one variant is found.
RESPECT_VARIANT_MAP_TYPES = ["COL"]
# Subdirectory within the final set folder for non-essential/unknown files
EXTRA_FILES_SUBDIR = "Extra"
OUTPUT_BASE_DIR = "../Asset_Processor_Output" #accepts both relative and absolute paths
METADATA_FILENAME = "metadata.json"
# --- Blender Integration Settings ---
# Default paths to Blender files for node group and material creation.
# Set these to absolute or relative paths if you want defaults.
# Command-line arguments (--nodegroup-blend, --materials-blend) will override these.
DEFAULT_NODEGROUP_BLEND_PATH = r"G:/02 Content/10-19 Content/19 Catalogs/19.01 Blender Asset Catalogue/_CustomLibraries/Nodes-Linked/PBRSET-Nodes-Testing.blend" # e.g., r"G:\Blender\Libraries\NodeGroups.blend"
DEFAULT_MATERIALS_BLEND_PATH = r"G:/02 Content/10-19 Content/19 Catalogs/19.01 Blender Asset Catalogue/_CustomLibraries/Materials-Append/PBR Materials-Testing.blend" # e.g., r"G:\Blender\Libraries\Materials.blend"
# Path to the Blender executable. Required for running Blender scripts.
# Example: r"C:\Program Files\Blender Foundation\Blender 3.6\blender.exe"
BLENDER_EXECUTABLE_PATH = r"C:/Program Files/Blender Foundation/Blender 4.4/blender.exe" # <<< SET THIS PATH!
# --- Image Processing Settings ---
# Target resolutions (Largest dimension in pixels)
PNG_COMPRESSION_LEVEL = 6 # 0 (none) to 9 (max)
# Quality for JPG output (0-100)
JPG_QUALITY = 98
# Resolution dimension threshold (pixels) above which 8-bit images are forced to JPG, overriding input format logic.
RESOLUTION_THRESHOLD_FOR_JPG = 4096
IMAGE_RESOLUTIONS = {"8K": 8192,"4K": 4096, "2K": 2048, "1K": 1024}
# Aspect ratio decimals (used for metadata, could potentially be removed later)
ASPECT_RATIO_DECIMALS = 2
# Bit depth rules per standard map type ('respect' or 'force_8bit')
MAP_BIT_DEPTH_RULES = {
"COL": "force_8bit", "NRM": "respect", "ROUGH": "force_8bit", "METAL": "force_8bit",
"AO": "force_8bit", "DISP": "respect", "REFL": "force_bit", "SSS": "respect",
"FUZZ": "force_bit", "IDMAP": "force_8bit", "MASK": "force_8bit",
"DEFAULT": "respect" # Fallback for map types not listed
}
# Output format preferences for 16-bit data
OUTPUT_FORMAT_16BIT_PRIMARY = "png" # Options: exr_dwaa, exr_dwab, exr_zip, png, tif
OUTPUT_FORMAT_16BIT_FALLBACK = "png"
# Output format for 8-bit data
OUTPUT_FORMAT_8BIT = "png" # Could allow 'jpg' later with quality settings
# Map types that should always be saved in a lossless format (e.g., PNG, EXR)
# regardless of resolution threshold or input format.
#FORCE_LOSSLESS_MAP_TYPES = ["NRM", "NRMRGN"]
# --- Map Merging Rules ---
# List of dictionaries, each defining a merge operation.
MAP_MERGE_RULES = [
{
"output_map_type": "NRMRGH", # Suffix or standard name for the merged map
"inputs": { # Map target RGB channels to standard input map type names
"R": "NRM", # Use Red channel from NRM
"G": "NRM", # Use Green channel from NRM
"B": "ROUGH" # Use Red channel from ROUGH (assuming it's grayscale)
},
"defaults": { # Default values (0.0 - 1.0) if an input map is missing
"R": 0.5, "G": 0.5, "B": 0.5
},
# 'respect_inputs' (use 16bit if any input is), 'force_8bit', 'force_16bit'
"output_bit_depth": "respect_inputs"
},
# Example: Merge Metalness(R), Roughness(G), AO(B) -> "MRA" map often used in engines
# {
# "output_map_type": "MRA",
# "inputs": {"R": "METAL", "G": "ROUGH", "B": "AO"},
# "defaullllts": {"R": 0.0, "G": 1.0, "B": 1.0}, # Default Metal=0, Rough=1, AO=1
# "output_bit_depth": "force_8bit" # Usually fine as 8bit
# },
]
# --- Metadata Settings ---
CALCULATE_STATS_RESOLUTION = "1K" # Resolution suffix used for calculating stats
DEFAULT_ASSET_CATEGORY = "Surface" # If rules don't identify Asset or Decal
# --- Internal Settings ---
# Temporary directory prefix for processing folders
TEMP_DIR_PREFIX = "_PROCESS_ASSET_"

View File

@ -1,90 +0,0 @@
# Final Plan for Updating documentation.txt with Debugging Details
This document outlines the final plan for enhancing `documentation.txt` with detailed internal information crucial for debugging the Asset Processor Tool. This plan incorporates analysis of `asset_processor.py`, `configuration.py`, `main.py`, and `gui/processing_handler.py`.
## Task Objective
Analyze relevant source code (`asset_processor.py`, `configuration.py`, `main.py`, `gui/processing_handler.py`) to gather specific details about internal logic, state management, error handling, data structures, concurrency, resource management, and limitations. Integrate this information into the existing `documentation.txt` to aid developers in debugging.
## Analysis Steps Completed
1. Read and analyzed `readme.md`.
2. Listed code definitions in the root directory (`.`).
3. Listed code definitions in the `gui/` directory.
4. Read and analyzed `asset_processor.py`.
5. Read and analyzed `configuration.py`.
6. Read and analyzed `main.py`.
7. Read and analyzed `gui/processing_handler.py`.
## Final Integration Plan (Merged Structure)
1. **Add New Top-Level Section:**
* Append the following section header to the end of `documentation.txt`:
```
================================
Internal Details for Debugging
================================
```
2. **Create Subsections:**
* Under the new "Internal Details" section, create the following subsections (renumbering from 7 onwards):
* `7. Internal Logic & Algorithms`
* `8. State Management`
* `9. Error Handling & Propagation`
* `10. Key Data Structures`
* `11. Concurrency Models (CLI & GUI)`
* `12. Resource Management`
* `13. Known Limitations & Edge Cases`
3. **Populate Subsections with Specific Details:**
* **7. Internal Logic & Algorithms:**
* **Configuration Preparation (`Configuration` class):** Detail the `__init__` process: loading `config.py` and preset JSON, validating structure (`_validate_configs`), compiling regex (`_compile_regex_patterns` using `_fnmatch_to_regex`). Mention compiled patterns storage.
* **CLI Argument Parsing (`main.py:setup_arg_parser`):** Briefly describe `argparse` usage and key flags influencing execution.
* **Output Directory Resolution (`main.py:main`):** Explain how the final output path is determined and resolved.
* **Asset Processing (`AssetProcessor` class):**
* *Classification (`_inventory_and_classify_files`):* Describe multi-pass approach using compiled regex from `Configuration`. Detail variant sorting criteria.
* *Map Processing (`_process_maps`):* Detail image loading, Gloss->Rough inversion, resizing, format determination (using `Configuration` rules), bit depth conversion, stats calculation, aspect ratio logic, save fallback.
* *Merging (`_merge_maps`):* Detail common resolution finding, input loading, channel merging (using `Configuration` rules), output determination, save fallback.
* *Metadata (`_determine_base_metadata`, etc.):* Summarize base name extraction, category/archetype determination (using `Configuration` rules), `metadata.json` population.
* **Blender Integration (`main.py:run_blender_script`, `gui/processing_handler.py:_run_blender_script_subprocess`):** Explain subprocess execution, command construction (`-b`, `--python`, `--`), argument passing (`asset_root_dir`).
* **8. State Management:**
* `Configuration` object: Holds loaded config state and compiled regex. Instantiated per worker.
* `AssetProcessor`: Primarily stateless between `process()` calls. Internal state within `process()` managed by local variables (e.g., `current_asset_metadata`). `self.classified_files` populated once and filtered per asset.
* `main.py`: Tracks overall CLI run counts (processed, skipped, failed).
* `gui/processing_handler.py`: Manages GUI processing run state via flags (`_is_running`, `_cancel_requested`) and stores `Future` objects (`_futures`).
* **9. Error Handling & Propagation:**
* Custom Exceptions: `AssetProcessingError`, `ConfigurationError`.
* `Configuration`: Raises `ConfigurationError` on load/validation failure. Logs regex compilation warnings.
* `AssetProcessor`: Catches exceptions within per-asset loop, logs error, marks asset "failed", continues loop. Handles specific save fallbacks (EXR->PNG). Raises `AssetProcessingError` for critical setup failures.
* Worker Wrapper (`main.py:process_single_asset_wrapper`): Catches exceptions from `Configuration`/`AssetProcessor`, logs, returns "failed" status tuple.
* Process Pool (`main.py`, `gui/processing_handler.py`): `try...except` around executor block catches pool-level errors.
* GUI Communication (`ProcessingHandler`): Catches errors during future result retrieval, emits failure status via signals.
* Blender Scripts: Checks subprocess return code, logs stderr. Catches `FileNotFoundError`.
* **10. Key Data Structures:**
* `Configuration` attributes: `compiled_map_keyword_regex`, `compiled_extra_regex`, etc. (compiled `re.Pattern` objects).
* `AssetProcessor` structures: `self.classified_files` (dict[str, list[dict]]), `processed_maps_details_asset` (dict[str, dict[str, dict]]), `file_to_base_name_map` (dict[Path, Optional[str]]).
* Return values: Status dictionary from `AssetProcessor.process()`, status tuple from `process_single_asset_wrapper`.
* `ProcessingHandler._futures`: dict[Future, str].
* **11. Concurrency Models (CLI & GUI):**
* **Common Core:** Both use `concurrent.futures.ProcessPoolExecutor` running `main.process_single_asset_wrapper`. `Configuration` and `AssetProcessor` instantiated within each worker process for isolation.
* **CLI Orchestration (`main.py:run_processing`):** Direct executor usage, `as_completed` gathers results synchronously.
* **GUI Orchestration (`gui/processing_handler.py`):** `ProcessingHandler` (QObject) runs executor logic in a `QThread`. Results processed in handler thread, communicated asynchronously to UI thread via Qt signals (`progress_updated`, `file_status_updated`, `processing_finished`).
* **Cancellation (`gui/processing_handler.py:request_cancel`):** Sets flag, attempts `executor.shutdown(wait=False)`, tries cancelling pending futures. Limitation: Does not stop already running workers.
* **12. Resource Management:**
* `Configuration`: Uses `with open` for preset files.
* `AssetProcessor`: Manages temporary workspace (`tempfile.mkdtemp`, `shutil.rmtree` in `finally`). Uses `with open` for metadata JSON.
* `ProcessPoolExecutor`: Lifecycle managed via `with` statement in `main.py` and `gui/processing_handler.py`.
* **13. Known Limitations & Edge Cases:**
* Configuration: Basic structural validation only; regex compilation errors are warnings; `_fnmatch_to_regex` helper is basic.
* AssetProcessor: Relies heavily on filename patterns; high memory potential for large images; limited intra-asset error recovery; simplified prediction logic.
* CLI: Basic preset file existence check only before starting workers; Blender executable finding logic order (config > PATH).
* GUI Concurrency: Cancellation doesn't stop currently executing worker processes.
4. **Switch Mode:** Request switching to Code mode to apply these changes by appending to `documentation.txt`.

View File

@ -1,269 +0,0 @@
================================
Asset Processor Tool - Developer Documentation
================================
This document provides a concise overview of the Asset Processor Tool's codebase for developers joining the project. It focuses on the architecture, key components, and development workflow.
**NOTE:** This documentation strictly excludes details on environment setup, dependency installation, building the project, or deployment procedures. It assumes familiarity with Python and the relevant libraries (OpenCV, NumPy, PySide6).
--------------------------------
1. Project Overview
--------------------------------
* **Purpose:** To process 3D asset source files (texture sets, models, etc., typically from ZIP archives or folders) into a standardized library format.
* **Core Functionality:** Uses configurable JSON presets to interpret different asset sources, automating tasks like file classification, image resizing, channel merging, and metadata generation.
* **High-Level Architecture:** Consists of a core processing engine (`AssetProcessor`), a configuration system handling presets (`Configuration`), multiple interfaces (GUI, CLI, Directory Monitor), and optional integration with Blender for automated material/nodegroup creation.
--------------------------------
2. Codebase Structure
--------------------------------
Key files and directories:
* `asset_processor.py`: Contains the `AssetProcessor` class, the core logic for processing a single asset through the pipeline. Includes methods for classification, map processing, merging, metadata generation, and output organization. Also provides methods for predicting output structure used by the GUI.
* `configuration.py`: Defines the `Configuration` class. Responsible for loading core settings from `config.py` and merging them with a specified preset JSON file (`Presets/*.json`). Pre-compiles regex patterns from presets for efficiency.
* `config.py`: Stores global default settings, constants, and core rules (e.g., standard map types, default resolutions, merge rules, output format rules, Blender paths).
* `main.py`: Entry point for the Command-Line Interface (CLI). Handles argument parsing, logging setup, parallel processing orchestration (using `concurrent.futures.ProcessPoolExecutor`), calls `AssetProcessor` via a wrapper function, and optionally triggers Blender scripts.
* `monitor.py`: Implements the automated directory monitoring feature using the `watchdog` library. Contains the `ZipHandler` class to detect new ZIP files and trigger processing via `main.run_processing`.
* `gui/`: Directory containing all code related to the Graphical User Interface (GUI), built with PySide6.
* `main_window.py`: Defines the `MainWindow` class, the main application window structure, UI layout (preset editor, processing panel, drag-and-drop, preview table, controls), event handling (button clicks, drag/drop), and menu setup. Manages GUI-specific logging (`QtLogHandler`).
* `processing_handler.py`: Defines the `ProcessingHandler` class (runs on a `QThread`). Manages the execution of the main asset processing pipeline (using `ProcessPoolExecutor`) and Blender script execution in the background to keep the GUI responsive. Communicates progress and results back to the `MainWindow` via signals.
* `prediction_handler.py`: Defines the `PredictionHandler` class (runs on a `QThread`). Manages background file analysis/preview generation by calling `AssetProcessor.get_detailed_file_predictions()`. Sends results back to the `MainWindow` via signals to update the preview table.
* `preview_table_model.py`: Defines `PreviewTableModel` (inherits `QAbstractTableModel`) and `PreviewSortFilterProxyModel` for managing and displaying data in the GUI's preview table, including custom sorting logic.
* `blenderscripts/`: Contains Python scripts (`create_nodegroups.py`, `create_materials.py`) designed to be executed *within* Blender, typically triggered by the main tool after processing to automate PBR nodegroup and material setup in `.blend` files.
* `Presets/`: Contains supplier-specific configuration files in JSON format (e.g., `Poliigon.json`). These define rules for interpreting asset filenames, classifying maps, handling variants, etc. `_template.json` serves as a base for new presets.
* `Testfiles/`: Contains example input assets for testing purposes.
* `Tickets/`: Directory for issue and feature tracking using Markdown files.
--------------------------------
3. Key Components/Modules
--------------------------------
* **`AssetProcessor` (`asset_processor.py`):** The heart of the tool. Orchestrates the entire processing pipeline for a single input asset (ZIP or folder). Responsibilities include workspace management, file classification, metadata extraction, map processing (resizing, format conversion), channel merging, `metadata.json` generation, and organizing final output files.
* **`Configuration` (`configuration.py`):** Manages the loading and merging of configuration settings. Takes a preset name, loads defaults from `config.py`, loads the specified `Presets/*.json`, merges them, validates settings, and pre-compiles regex patterns defined in the preset for efficient use by `AssetProcessor`.
* **`MainWindow` (`gui/main_window.py`):** The main class for the GUI application. Sets up the UI layout, connects user actions (button clicks, drag/drop) to slots, manages the preset editor, interacts with background handlers (`ProcessingHandler`, `PredictionHandler`) via signals/slots, and displays feedback (logs, progress, status).
* **`ProcessingHandler` (`gui/processing_handler.py`):** Handles the execution of the core asset processing logic and Blender scripts in a background thread for the GUI. Manages the `ProcessPoolExecutor` for parallel asset processing and communicates progress/results back to the `MainWindow`.
* **`PredictionHandler` (`gui/prediction_handler.py`):** Handles the generation of file classification previews in a background thread for the GUI. Calls `AssetProcessor`'s prediction methods and sends results back to the `MainWindow` to populate the preview table without blocking the UI.
* **`ZipHandler` (`monitor.py`):** A `watchdog` event handler used by `monitor.py`. Detects newly created ZIP files in the monitored input directory, validates the filename format (for preset extraction), and triggers the main processing logic via `main.run_processing`.
--------------------------------
4. Core Concepts & Data Flow
--------------------------------
* **Preset-Driven Configuration:**
* Global defaults are set in `config.py`.
* Supplier-specific rules (filename patterns, map keywords, variant handling, etc.) are defined using regex in `Presets/*.json` files.
* The `Configuration` class loads `config.py` and merges it with the selected preset JSON, providing a unified configuration object to the `AssetProcessor`. Regex patterns are pre-compiled during `Configuration` initialization for performance.
* **Asset Processing Pipeline (Simplified Flow):**
1. **Workspace Setup:** Create a temporary directory.
2. **Extract/Copy:** Extract ZIP or copy folder contents to the workspace.
3. **Classify Files:** Scan workspace, use compiled regex from `Configuration` to classify files (Map, Model, Extra, Ignored, Unrecognized). Handle 16-bit variants and assign suffixes based on rules.
4. **Determine Metadata:** Extract asset name, category, archetype based on preset rules.
5. **Skip Check:** If overwrite is false, check if output already exists; if so, skip this asset.
6. **Process Maps:** Load images, resize (no upscale), convert format/bit depth based on complex rules (`config.py` and preset), handle Gloss->Roughness inversion, calculate stats, determine aspect ratio change. Save processed maps.
7. **Merge Maps:** Combine channels from different processed maps based on `MAP_MERGE_RULES` in `config.py`. Save merged maps.
8. **Generate `metadata.json`:** Collect all relevant information (map details, stats, aspect ratio, category, etc.) and write to `metadata.json` in the workspace.
9. **Organize Output:** Create the final output directory structure (`<output_base>/<supplier>/<asset_name>/`) and move processed maps, merged maps, models, `metadata.json`, Extra files, and Ignored files into it.
10. **Cleanup Workspace:** Delete the temporary directory.
11. **(Optional) Blender Scripts:** If triggered via CLI/GUI, execute `blenderscripts/*.py` using the configured Blender executable via a subprocess.
* **Parallel Processing:**
* Multiple input assets are processed concurrently using `concurrent.futures.ProcessPoolExecutor`.
* This pool is managed by `main.py` (CLI) or `gui/processing_handler.py` (GUI).
* Each asset runs in an isolated worker process, ensuring separate `Configuration` and `AssetProcessor` instances.
* **GUI Interaction & Threading:**
* The GUI (`PySide6`) uses `QThread` to run `ProcessingHandler` (asset processing) and `PredictionHandler` (file preview generation) in the background, preventing the UI from freezing.
* Communication between the main UI thread (`MainWindow`) and background threads relies on Qt's signals and slots mechanism for thread safety (e.g., updating progress, status messages, preview table data).
* `PredictionHandler` calls `AssetProcessor` methods to get file classification details, which are then sent back to `MainWindow` to populate the `PreviewTableModel`.
* **Output (`metadata.json`):**
* A key output file generated for each processed asset.
* Contains structured data about the asset: map filenames, resolutions, formats, bit depths, merged map details, calculated image statistics, aspect ratio change info, asset category/archetype, source preset used, list of ignored source files, etc. This file is intended for use by downstream tools or scripts (like the Blender integration scripts).
--------------------------------
5. Development Workflow
--------------------------------
* **Modifying Core Processing Logic:** Changes to how assets are classified, maps are processed/resized/converted, channels are merged, or metadata is generated typically involve editing the `AssetProcessor` class in `asset_processor.py`.
* **Changing Global Settings/Rules:** Adjustments to default output paths, standard resolutions, default format rules, map merge definitions, or Blender paths should be made in `config.py`.
* **Adding/Modifying Supplier Rules:** To add support for a new asset source or change how an existing one is interpreted, create or edit the corresponding JSON file in the `Presets/` directory. Refer to `_template.json` and existing presets for structure. Focus on defining accurate regex patterns in `map_type_mapping`, `bit_depth_variants`, `model_patterns`, `source_naming_convention`, etc.
* **Adjusting CLI Behavior:** Changes to command-line arguments, argument parsing, or the overall CLI workflow are handled in `main.py`.
* **Modifying the GUI:** UI layout changes, adding new controls, altering event handling, or modifying background task management for the GUI involves working within the `gui/` directory, primarily `main_window.py`, `processing_handler.py`, and `prediction_handler.py`. UI elements are built using PySide6 widgets.
* **Enhancing Blender Integration:** Improvements or changes to how nodegroups or materials are created in Blender require editing the Python scripts within the `blenderscripts/` directory. Consider how these scripts are invoked and what data they expect (primarily from `metadata.json` and command-line arguments passed via subprocess calls in `main.py` or `gui/processing_handler.py`).
--------------------------------
6. Coding Conventions
--------------------------------
* **Object-Oriented:** The codebase heavily utilizes classes (e.g., `AssetProcessor`, `Configuration`, `MainWindow`, various Handlers).
* **Type Hinting:** Python type hints are used throughout the code for clarity and static analysis.
* **Logging:** Standard Python `logging` module is used for logging messages at different levels (DEBUG, INFO, WARNING, ERROR). The GUI uses a custom `QtLogHandler` to display logs in the UI console.
* **Error Handling:** Uses standard `try...except` blocks and defines some custom exceptions (e.g., `ConfigurationError`, `AssetProcessingError`).
* **Parallelism:** Uses `concurrent.futures.ProcessPoolExecutor` for CPU-bound tasks (asset processing).
* **GUI:** Uses `PySide6` (Qt for Python) with signals and slots for communication between UI elements and background threads (`QThread`).
* **Configuration:** Relies on Python modules (`config.py`) for core settings and JSON files (`Presets/`) for specific rule sets.
* **File Paths:** Uses `pathlib.Path` for handling file system paths.
================================
Internal Details for Debugging
================================
This section provides deeper technical details about the internal workings, intended to aid in debugging unexpected behavior.
--------------------------------
7. Internal Logic & Algorithms
--------------------------------
* **Configuration Preparation (`Configuration` class in `configuration.py`):**
* Instantiated per preset (`__init__`).
* Loads core settings from `config.py` using `importlib.util`.
* Loads specified preset from `presets/{preset_name}.json`.
* Validates basic structure of loaded settings (`_validate_configs`), checking for required keys and basic types (e.g., `map_type_mapping` is a list of dicts).
* Compiles regex patterns (`_compile_regex_patterns`) from preset rules (extra, model, bit depth, map keywords) using `re.compile` (mostly case-insensitive) and stores them on the instance (e.g., `self.compiled_map_keyword_regex`). Uses `_fnmatch_to_regex` helper for basic wildcard conversion.
* **CLI Argument Parsing (`main.py:setup_arg_parser`):**
* Uses `argparse` to define and parse command-line arguments.
* Key arguments influencing flow: `--preset` (required), `--output-dir` (optional override), `--workers` (concurrency), `--overwrite` (force reprocessing), `--verbose` (logging level), `--nodegroup-blend`, `--materials-blend`.
* Calculates a default worker count based on `os.cpu_count()`.
* **Output Directory Resolution (`main.py:main`):**
* Determines the base output directory by checking `--output-dir` argument first, then falling back to `OUTPUT_BASE_DIR` from `config.py`.
* Resolves the path to an absolute path and ensures the directory exists (`Path.resolve()`, `Path.mkdir(parents=True, exist_ok=True)`).
* **Asset Processing (`AssetProcessor` class in `asset_processor.py`):**
* **Classification (`_inventory_and_classify_files`):**
* Multi-pass approach: Explicit Extra (regex) -> Models (regex) -> Potential Maps (keyword regex) -> Standalone 16-bit check (regex) -> Prioritize 16-bit variants -> Final Maps -> Remaining as Unrecognised (Extra).
* Uses compiled regex patterns provided by the `Configuration` object passed during initialization.
* Sorts potential map variants based on: 1. Preset rule index, 2. Keyword index within rule, 3. Alphabetical path. Suffixes (`-1`, `-2`) are assigned later per-asset based on this sort order and `RESPECT_VARIANT_MAP_TYPES`.
* **Map Processing (`_process_maps`):**
* Loads images using `cv2.imread` (flags: `IMREAD_UNCHANGED` or `IMREAD_GRAYSCALE`). Converts loaded 3-channel images from BGR to RGB for internal consistency (stats, merging).
* **Saving Channel Order:** Before saving with `cv2.imwrite`, 3-channel images are conditionally converted back from RGB to BGR *only* if the target output format is *not* EXR (e.g., for PNG, JPG, TIF). This ensures correct channel order for standard formats while preserving RGB for EXR. (Fix for ISSUE-010).
* Handles Gloss->Roughness inversion: Loads gloss, inverts using float math (`1.0 - img/norm`), stores as float32 with original dtype. Prioritizes gloss source if both gloss and native rough exist.
* Resizes using `cv2.resize` (interpolation: `INTER_LANCZOS4` for downscale, `INTER_CUBIC` for potential same-size/upscale - though upscaling is generally avoided by checks).
* Determines output format based on hierarchy: `FORCE_LOSSLESS_MAP_TYPES` > `RESOLUTION_THRESHOLD_FOR_JPG` > Input format priority (TIF/EXR often lead to lossless) > Configured defaults (`OUTPUT_FORMAT_16BIT_PRIMARY`, `OUTPUT_FORMAT_8BIT`).
* Determines output bit depth based on `MAP_BIT_DEPTH_RULES` ('respect' vs 'force_8bit').
* Converts dtype before saving (e.g., float to uint8/uint16 using scaling factors 255.0/65535.0).
* Calculates stats (`_calculate_image_stats`) on normalized float64 data (in RGB space) for a specific resolution (`CALCULATE_STATS_RESOLUTION`).
* Calculates aspect ratio string (`_normalize_aspect_ratio_change`) based on relative dimension changes.
* Handles save fallback: If primary 16-bit format (e.g., EXR) fails, attempts fallback (e.g., PNG).
* **Merging (`_merge_maps_from_source`):**
* Identifies the required *source* files for merge inputs based on classified files.
* Determines common resolutions based on available processed maps (as a proxy for size compatibility).
* Loads required source maps for each common resolution using the `_load_and_transform_source` helper (utilizing the cache).
* Converts loaded inputs to float32 (normalized 0-1).
* Injects default values (from rule `defaults`) for missing channels.
* Merges channels using `cv2.merge`.
* Determines output bit depth based on rule (`force_16bit`, `respect_inputs`).
* Determines output format based on complex rules (`config.py` and preset), considering the highest format among *source* inputs if not forced lossless or over JPG threshold. Handles JPG 16-bit conflict by forcing 8-bit.
* Saves the merged image using the `_save_image` helper, including final data type/color space conversions and fallback logic (e.g., EXR->PNG).
* **Metadata (`_determine_base_metadata`, `_determine_single_asset_metadata`, `_generate_metadata_file`):**
* Base name determined using `source_naming` separator/index from `Configuration`, with fallback to common prefix or input name. Handles multiple assets within one input.
* Category determined by model presence or `decal_keywords` from `Configuration`.
* Archetype determined by matching keywords in `archetype_rules` (from `Configuration`) against file stems/base name.
* Final `metadata.json` populated by accumulating results (map details, stats, features, etc.) during the per-asset processing loop.
* **Blender Integration (`main.py:run_blender_script`, `gui/processing_handler.py:_run_blender_script_subprocess`):**
* Uses `subprocess.run` to execute Blender.
* Command includes `-b` (background), the target `.blend` file, `--python` followed by the script path (`blenderscripts/*.py`), and `--` separator.
* Arguments after `--` (currently just the `asset_root_dir`, and optionally the nodegroup blend path for the materials script) are passed to the Python script via `sys.argv`.
* Uses `--factory-startup` in GUI handler. Checks return code and logs stdout/stderr.
--------------------------------
8. State Management
--------------------------------
* **`Configuration` Object:** Holds the loaded and merged configuration state (core + preset) and compiled regex patterns. Designed to be immutable after initialization. Instantiated once per worker process.
* **`AssetProcessor` Instance:** Primarily stateless between calls to `process()`. State *within* a `process()` call is managed through local variables scoped to the overall call or the per-asset loop (e.g., `current_asset_metadata`, `processed_maps_details_asset`). `self.classified_files` is populated once by `_inventory_and_classify_files` early in `process()` and then used read-only (filtered copies) within the per-asset loop.
* **`main.py` (CLI):** Tracks overall run progress (processed, skipped, failed counts) based on results returned from worker processes.
* **`gui/processing_handler.py`:** Manages the state of a GUI processing run using internal flags (`_is_running`, `_cancel_requested`) and stores `Future` objects in `self._futures` dictionary while the pool is active.
--------------------------------
9. Error Handling & Propagation
--------------------------------
* **Custom Exceptions:** `ConfigurationError` (raised by `Configuration` on load/validation failure), `AssetProcessingError` (raised by `AssetProcessor` for various processing failures).
* **Configuration:** `ConfigurationError` halts initialization. Regex compilation errors are logged as warnings but do not stop initialization.
* **AssetProcessor:** Uses `try...except Exception` within key pipeline steps (`_process_maps`, `_merge_maps`, etc.) and within the per-asset loop in `process()`. Errors specific to one asset are logged (`log.error(exc_info=True)`), the asset is marked "failed" in the returned status dictionary, and the loop continues to the next asset. Critical setup errors (e.g., workspace creation) raise `AssetProcessingError`, halting the entire `process()` call. Includes specific save fallback logic (EXR->PNG) on `cv2.imwrite` failure for 16-bit formats.
* **Worker Wrapper (`main.py:process_single_asset_wrapper`):** Catches `ConfigurationError`, `AssetProcessingError`, and general `Exception` during worker execution. Logs the error and returns a ("failed", error_message) status tuple to the main process.
* **Process Pool (`main.py`, `gui/processing_handler.py`):** The `with ProcessPoolExecutor(...)` block handles pool setup/teardown. A `try...except` around `as_completed` or `future.result()` catches critical worker failures (e.g., process crash).
* **GUI Communication (`ProcessingHandler`):** Catches exceptions during `future.result()` retrieval. Emits `file_status_updated` signal with "failed" status and error message. Emits `processing_finished` with final counts.
* **Blender Scripts:** Checks `subprocess.run` return code. Logs stderr as ERROR if return code is non-zero, otherwise as WARNING. Catches `FileNotFoundError` if the Blender executable path is invalid.
--------------------------------
10. Key Data Structures
--------------------------------
* **`Configuration` Instance Attributes:**
* `compiled_map_keyword_regex`: `dict[str, list[tuple[re.Pattern, str, int]]]` (Base type -> list of compiled regex tuples)
* `compiled_extra_regex`, `compiled_model_regex`: `list[re.Pattern]`
* `compiled_bit_depth_regex_map`: `dict[str, re.Pattern]` (Base type -> compiled regex)
* **`AssetProcessor` Internal Structures (within `process()`):**
* `self.classified_files`: `dict[str, list[dict]]` (Category -> list of file info dicts like `{'source_path': Path, 'map_type': str, ...}`)
* `processed_maps_details_asset`, `merged_maps_details_asset`: `dict[str, dict[str, dict]]` (Map Type -> Resolution Key -> Details Dict `{'path': Path, 'width': int, ...}`)
* `file_to_base_name_map`: `dict[Path, Optional[str]]` (Source relative path -> Determined asset base name or None)
* `current_asset_metadata`: `dict` (Accumulates name, category, archetype, stats, map details per asset)
* **Return Values:**
* `AssetProcessor.process()`: `Dict[str, List[str]]` (e.g., `{"processed": [...], "skipped": [...], "failed": [...]}`)
* `main.process_single_asset_wrapper()`: `Tuple[str, str, Optional[str]]` (input_path, status_string, error_message)
* **`ProcessingHandler._futures`:** `dict[Future, str]` (Maps `concurrent.futures.Future` object to the input path string)
* **Image Data:** `numpy.ndarray` (Handled by OpenCV).
--------------------------------
11. Concurrency Models (CLI & GUI)
--------------------------------
* **Common Core:** Both CLI and GUI utilize `concurrent.futures.ProcessPoolExecutor` for parallel processing. The target function executed by workers is `main.process_single_asset_wrapper`.
* **Isolation:** Crucially, `Configuration` and `AssetProcessor` objects are instantiated *within* the `process_single_asset_wrapper` function, meaning each worker process gets its own independent configuration and processor instance based on the arguments passed. This prevents state conflicts between concurrent asset processing tasks. Data is passed between the main process and workers via pickling of arguments and return values.
* **CLI Orchestration (`main.py:run_processing`):**
* Creates the `ProcessPoolExecutor`.
* Submits all `process_single_asset_wrapper` tasks.
* Uses `concurrent.futures.as_completed` to iterate over finished futures as they complete, blocking until the next one is done.
* Gathers results synchronously within the main script's execution flow.
* **GUI Orchestration (`gui/processing_handler.py`):**
* The `ProcessingHandler` object (a `QObject`) contains the `run_processing` method.
* This method is intended to be run in a separate `QThread` (managed by `MainWindow`) to avoid blocking the main UI thread.
* Inside `run_processing`, it creates and manages the `ProcessPoolExecutor`.
* It uses `as_completed` similarly to the CLI to iterate over finished futures.
* **Communication:** Instead of blocking the thread gathering results, it emits Qt signals (`progress_updated`, `file_status_updated`, `processing_finished`) from within the `as_completed` loop. These signals are connected to slots in `MainWindow` (running on the main UI thread), allowing for thread-safe updates to the GUI (progress bar, table status, status bar messages).
* **Cancellation (GUI - `gui/processing_handler.py:request_cancel`):**
* Sets an internal `_cancel_requested` flag.
* Attempts `executor.shutdown(wait=False)` which prevents new tasks from starting and may cancel pending ones (depending on Python version).
* Manually iterates through stored `_futures` and calls `future.cancel()` on those not yet running or done.
* **Limitation:** This does *not* forcefully terminate worker processes that are already executing the `process_single_asset_wrapper` function. Cancellation primarily affects pending tasks and the processing of results from already running tasks (they will be marked as failed/cancelled when their future completes).
--------------------------------
12. Resource Management
--------------------------------
* **Configuration:** Preset JSON files are opened and closed using `with open(...)`.
* **AssetProcessor:**
* Temporary workspace directory created using `tempfile.mkdtemp()`.
* Cleanup (`_cleanup_workspace`) uses `shutil.rmtree()` and is called within a `finally` block in the main `process()` method, ensuring cleanup attempt even if errors occur.
* Metadata JSON file written using `with open(...)`.
* Image data is loaded into memory using OpenCV/NumPy; memory usage depends on image size and number of concurrent workers.
* **Process Pool:** The `ProcessPoolExecutor` manages the lifecycle of worker processes. Using it within a `with` statement (as done in `main.py` and `gui/processing_handler.py`) ensures proper shutdown and resource release for the pool itself.
--------------------------------
13. Known Limitations & Edge Cases
--------------------------------
* **Configuration:**
* Validation (`_validate_configs`) is primarily structural (key presence, basic types), not deeply logical (e.g., doesn't check if regex patterns are *sensible*).
* Regex compilation errors in `_compile_regex_patterns` are logged as warnings but don't prevent `Configuration` initialization, potentially leading to unexpected classification later.
* `_fnmatch_to_regex` helper only handles basic `*` and `?` wildcards. Complex fnmatch patterns might not translate correctly.
* **AssetProcessor:**
* Heavily reliant on correct filename patterns and rules defined in presets. Ambiguous or incorrect patterns lead to misclassification.
* Potential for high memory usage when processing very large images, especially with many workers.
* Error handling within `process()` is per-asset; a failure during map processing for one asset marks the whole asset as failed, without attempting other maps for that asset. No partial recovery within an asset.
* Gloss->Roughness inversion assumes gloss map is single channel or convertible to grayscale.
* `predict_output_structure` and `get_detailed_file_predictions` use simplified logic (e.g., assuming PNG output, highest resolution only) and may not perfectly match final output names/formats in all cases.
* Filename sanitization (`_sanitize_filename`) is basic and might not cover all edge cases for all filesystems.
* **CLI (`main.py`):**
* Preset existence check (`{preset}.json`) happens only in the main process before workers start.
* Blender executable finding logic relies on `config.py` path being valid or `blender` being in the system PATH.
* **GUI Concurrency (`gui/processing_handler.py`):**
* Cancellation (`request_cancel`) is not immediate for tasks already running in worker processes. It prevents new tasks and stops processing results from completed futures once the flag is checked.
* **General:**
* Limited input format support (ZIP archives, folders). Internal file formats limited by OpenCV (`cv2.imread`, `cv2.imwrite`). Optional `OpenEXR` package recommended for full EXR support.
* Error messages propagated from workers might lack full context in some edge cases.

View File

@ -1,70 +0,0 @@
# Asset Processor Tool Documentation Plan
This document outlines the proposed structure for the documentation of the Asset Processor Tool, based on the content from `readme.md` and `documentation.txt`. The goal is to create a clear, modular, and comprehensive documentation set within a new `Documentation` directory.
## Proposed Directory Structure
```
Documentation/
├── 00_Overview.md
├── 01_User_Guide/
│ ├── 01_Introduction.md
│ ├── 02_Features.md
│ ├── 03_Installation.md
│ ├── 04_Configuration_and_Presets.md
│ ├── 05_Usage_GUI.md
│ ├── 06_Usage_CLI.md
│ ├── 07_Usage_Monitor.md
│ ├── 08_Usage_Blender.md
│ ├── 09_Output_Structure.md
│ └── 10_Docker.md
└── 02_Developer_Guide/
├── 01_Architecture.md
├── 02_Codebase_Structure.md
├── 03_Key_Components.md
├── 04_Configuration_System_and_Presets.md
├── 05_Processing_Pipeline.md
├── 06_GUI_Internals.md
├── 07_Monitor_Internals.md
├── 08_Blender_Integration_Internals.md
├── 09_Development_Workflow.md
├── 10_Coding_Conventions.md
└── 11_Debugging_Notes.md
```
## File Content Breakdown
### `Documentation/00_Overview.md`
* Project purpose, scope, and intended audience.
* High-level summary of the tool's functionality.
* Table of Contents for the entire documentation set.
### `Documentation/01_User_Guide/`
* **`01_Introduction.md`**: Brief welcome and purpose for users.
* **`02_Features.md`**: Detailed list of user-facing features.
* **`03_Installation.md`**: Requirements and step-by-step installation instructions.
* **`04_Configuration_and_Presets.md`**: Explains user-level configuration options (`config.py` settings relevant to users) and how to select and understand presets.
* **`05_Usage_GUI.md`**: Guide on using the Graphical User Interface, including descriptions of panels, controls, and workflow.
* **`06_Usage_CLI.md`**: Guide on using the Command-Line Interface, including arguments and examples.
* **`07_Usage_Monitor.md`**: Guide on setting up and using the Directory Monitor for automated processing.
* **`08_Usage_Blender.md`**: Explains the user-facing aspects of the Blender integration.
* **`09_Output_Structure.md`**: Describes the structure and contents of the generated asset library.
* **`10_Docker.md`**: Instructions for building and running the tool using Docker.
### `Documentation/02_Developer_Guide/`
* **`01_Architecture.md`**: High-level technical architecture, core components, and their relationships.
* **`02_Codebase_Structure.md`**: Detailed breakdown of key files and directories within the project.
* **`03_Key_Components.md`**: In-depth explanation of major classes and modules (`AssetProcessor`, `Configuration`, GUI Handlers, etc.).
* **`04_Configuration_System_and_Presets.md`**: Technical details of the configuration loading and merging process, the structure of preset JSON files, and guidance on creating/modifying presets for developers.
* **`05_Processing_Pipeline.md`**: Step-by-step technical breakdown of the asset processing logic within the `AssetProcessor` class.
* **`06_GUI_Internals.md`**: Technical details of the GUI implementation, including threading, signals/slots, and background task management.
* **`07_Monitor_Internals.md`**: Technical details of the Directory Monitor implementation using `watchdog`.
* **`08_Blender_Integration_Internals.md`**: Technical details of how the Blender scripts are executed and interact with the processed assets.
* **`09_Development_Workflow.md`**: Guidance for developers on contributing, setting up a development environment, and modifying specific parts of the codebase.
* **`10_Coding_Conventions.md`**: Overview of the project's coding standards, object-oriented approach, type hinting, logging, and error handling.
* **`11_Debugging_Notes.md`**: Advanced internal details, state management, error propagation, concurrency models, resource management, and known limitations/edge cases.
This plan provides a solid foundation for organizing the existing documentation and serves as a roadmap for creating the new markdown files.

Binary file not shown.

View File

@ -1,356 +0,0 @@
# Asset Processor Tool vX.Y
## Overview
This tool processes 3D asset source files (texture sets, models, etc., provided as ZIP archives or folders) into a standardized library format. It uses configurable presets to interpret different asset sources and automates tasks like file classification, image resizing, channel merging, and metadata generation.
The tool offers both a Graphical User Interface (GUI) for interactive use and a Command-Line Interface (CLI) for batch processing and scripting.
This tool is currently work in progress, rewritting features from an original proof of concept, original script can be found at `Deprecated-POC/` for reference
## Features
* **Preset-Driven:** Uses JSON presets (`presets/`) to define rules for different asset suppliers (e.g., `Poliigon.json`).
* **Dual Interface:** Provides both a user-friendly GUI and a powerful CLI.
* **Parallel Processing:** Utilizes multiple CPU cores for faster processing of multiple assets (configurable via `--workers` in CLI or GUI control).
* **Multi-Asset Input Handling:** Correctly identifies and processes multiple distinct assets contained within a single input ZIP or folder, creating separate outputs for each.
* **File Classification:** Automatically identifies map types (Color, Normal, Roughness, etc.), models, explicitly marked extra files, and unrecognised files based on preset rules.
* **Variant Handling:** Map types listed in `RESPECT_VARIANT_MAP_TYPES` (in `config.py`, e.g., `"COL"`) will *always* receive a numeric suffix (`-1`, `-2`, etc.). The numbering priority is determined primarily by the order of keywords listed in the preset's `map_type_mapping`. Alphabetical sorting of filenames is used only as a tie-breaker for files matching the exact same keyword pattern. Other map types will *never* receive a suffix.
* **16-bit Prioritization:** Correctly identifies 16-bit variants defined in preset `bit_depth_variants` (e.g., `*_NRM16.tif`), prioritizes them, and ignores the corresponding 8-bit version (marked as `Ignored` in GUI).
* **Map Processing:**
* Resizes texture maps to configured power of two resolutions (e.g., 4K, 2K, 1K), avoiding upscaling.
* Handles Glossiness map inversion to Roughness.
* Applies bit-depth rules (`respect` source or `force_8bit`).
* Saves maps in appropriate formats. Map types listed in `FORCE_LOSSLESS_MAP_TYPES` (in `config.py`, e.g., `"NRM"`, `"DISP"`) are *always* saved in a lossless format (PNG for 8-bit, configured 16-bit format like EXR/PNG for 16-bit), overriding other rules. For other map types, if the output is 8-bit and the resolution meets or exceeds `RESOLUTION_THRESHOLD_FOR_JPG` (in `config.py`), the output is forced to JPG. Otherwise, the format is based on input type and target bit depth: JPG inputs yield JPG outputs (8-bit); TIF inputs yield PNG/EXR (based on target bit depth and config); other inputs use configured formats (PNG/EXR). Merged maps follow similar logic, checking `FORCE_LOSSLESS_MAP_TYPES` first, then the threshold for 8-bit targets, then using the highest format from inputs (EXR > TIF > PNG > JPG hierarchy, with TIF adjusted to PNG/EXR based on target bit depth).
* Calculates basic image statistics (Min/Max/Mean) for a reference resolution.
* Calculates and stores the relative aspect ratio change string in metadata.
* **Channel Merging:** Combines channels from different maps into packed textures (e.g., NRMRGH) based on preset rules.
* **Metadata Generation:** Creates a `metadata.json` file for each asset containing details about maps, category, archetype, aspect ratio change, processing settings, etc. **Aspect Ratio Metadata:** Calculates the relative aspect ratio change during resizing and stores it in the `metadata.json` file (`aspect_ratio_change_string`). The format indicates if the aspect is unchanged (`EVEN`), scaled horizontally (`X150`, `X110`, etc.), scaled vertically (`Y150`, `Y125`, etc.)
* **Output Organization:** Creates a clean, structured output directory (`<output_base>/<supplier>/<asset_name>/`).
* **Skip/Overwrite:** Can skip processing if the output already exists or force reprocessing with the `--overwrite` flag (CLI) or checkbox (GUI).
* **Blender Integration:** Optionally runs Blender scripts (`create_nodegroups.py`, `create_materials.py`) after asset processing to automate node group and material creation in specified `.blend` files. Available via both CLI and GUI.
* **GUI Features:**
* Drag-and-drop input for assets (ZIPs/folders).
* Integrated preset editor panel for managing `.json` presets.
* Configurable output directory field with a browse button (defaults to path in `config.py`).
* Enhanced live preview table showing predicted file status (Mapped, Model, Extra, Unrecognised, Ignored, Error) based on the selected processing preset.
* Toggleable preview mode (via View menu) to switch between detailed file preview and a simple list of input assets.
* Toggleable log console panel (via View menu) displaying application log messages within the GUI.
* Progress bar, cancellation button, and clear queue button.
* **Blender Post-Processing Controls:** Checkbox to enable/disable Blender script execution and input fields with browse buttons to specify the target `.blend` files for node group and material creation (defaults configurable in `config.py`).
* **Responsive GUI:** Utilizes background threads (`QThread`) for processing (`ProcessPoolExecutor`) and file preview generation (`ThreadPoolExecutor`), ensuring the user interface remains responsive during intensive operations.
* **Optimized Classification:** Pre-compiles regular expressions from presets for faster file identification during classification.
* **Docker Support:** Includes a `Dockerfile` for containerized execution.
## Directory Structure
```
Asset_processor_tool/
├── main.py # CLI Entry Point & processing orchestrator
├── monitor.py # Directory monitoring script for automated processing
├── asset_processor.py # Core class handling single asset processing pipeline
├── configuration.py # Class for loading and accessing configuration
├── config.py # Core settings definition (output paths, resolutions, merge rules etc.)
├── blenderscripts/ # Scripts for integration with Blender
│ └── create_nodegroups.py # Script to create node groups from processed assets
│ └── create_materials.py # Script to create materials linking to node groups
├── gui/ # Contains files related to the Graphical User Interface
│ ├── main_window.py # Main GUI application window and layout
│ ├── processing_handler.py # Handles background processing logic for the GUI
│ ├── prediction_handler.py # Handles background file prediction/preview for the GUI
├── Presets/ # Preset definition files
│ ├── _template.json # Template for creating new presets
│ └── Poliigon.json # Example preset for Poliigon assets
├── Testfiles/ # Directory containing example input assets for testing
├── Tickets/ # Directory for issue and feature tracking (Markdown files)
│ ├── _template.md # Template for creating new tickets
│ └── Ticket-README.md # Explanation of the ticketing system
├── requirements.txt # Python package dependencies for standard execution
├── requirements-docker.txt # Dependencies specifically for the Docker environment
├── Dockerfile # Instructions for building the Docker container image
└── readme.md # This documentation file
```
* **Core Logic:** `main.py`, `monitor.py`, `asset_processor.py`, `configuration.py`, `config.py`
* **Blender Integration:** `blenderscripts/` directory
* **GUI:** `gui/` directory
* **Configuration:** `config.py`, `Presets/` directory
* **Dependencies:** `requirements.txt`, `requirements-docker.txt`
* **Containerization:** `Dockerfile`
* **Documentation/Planning:** `readme.md`, `Project Notes/` directory
* **Issue/Feature Tracking:** `Tickets/` directory (see `Tickets/README.md`)
* **Testing:** `Testfiles/` directory
## Architecture
This section provides a higher-level overview of the tool's internal structure and design, intended for developers or users interested in the technical implementation.
### Core Components
The tool is primarily built around several key Python modules:
* `config.py`: Defines core, global settings (output paths, resolutions, default behaviors, format rules, Blender executable path, default Blender file paths, etc.) that are generally not supplier-specific.
* `Presets/*.json`: Supplier-specific JSON files defining rules for interpreting source assets (filename patterns, map type keywords, model identification, etc.).
* `configuration.py` **(**`Configuration` **class)**: Responsible for loading the core `config.py` settings and merging them with a selected preset JSON file. Crucially, it also **pre-compiles** regular expression patterns defined in the preset (e.g., for map keywords, extra files, 16-bit variants) upon initialization. This pre-compilation significantly speeds up the file classification process.
* `asset_processor.py` **(**`AssetProcessor` **class)**: Contains the core logic for processing a *single* asset. It orchestrates the pipeline steps: workspace setup, extraction, file classification, metadata determination, map processing, channel merging, metadata file generation, and output organization.
* `main.py`: Serves as the entry point for the Command-Line Interface (CLI). It handles argument parsing, sets up logging, manages the parallel processing pool, calls `AssetProcessor` for each input asset via a wrapper function, and optionally triggers Blender script execution after processing.
* `gui/`: Contains modules related to the Graphical User Interface (GUI), built using PySide6.
* `monitor.py`: Implements the directory monitoring functionality for automated processing.
### Parallel Processing (CLI & GUI)
To accelerate the processing of multiple assets, the tool utilizes Python's `concurrent.futures.ProcessPoolExecutor`.
* Both `main.py` (for CLI) and `gui/processing_handler.py` (for GUI background tasks) create a process pool.
* The actual processing for each asset is delegated to the `main.process_single_asset_wrapper` function. This wrapper is executed in a separate worker process within the pool.
* The wrapper function is responsible for instantiating the `Configuration` and `AssetProcessor` classes for the specific asset being processed in that worker. This isolates each asset's processing environment.
* Results (success, skip, failure, error messages) are communicated back from the worker processes to the main coordinating script (either `main.py` or `gui/processing_handler.py`).
### Asset Processing Pipeline (`AssetProcessor` class)
The `AssetProcessor` class executes a sequence of steps for each asset:
1. `_setup_workspace()`: Creates a temporary directory for processing.
2. `_extract_input()`: Extracts the input ZIP archive or copies the input folder contents into the temporary workspace.
3. `_inventory_and_classify_files()`: This is a critical step that scans the workspace and classifies each file based on rules defined in the loaded `Configuration` (which includes the preset). It uses the pre-compiled regex patterns for efficiency. Key logic includes:
* Identifying files explicitly marked for the `Extra/` folder.
* Identifying model files.
* Matching potential texture maps against keyword patterns.
* Identifying and prioritizing 16-bit variants (e.g., `_NRM16.tif`) over their 8-bit counterparts based on `source_naming.bit_depth_variants` patterns. Ignored 8-bit files are tracked.
* Handling map variants (e.g., multiple Color maps) by assigning suffixes (`-1`, `-2`) based on the `RESPECT_VARIANT_MAP_TYPES` setting in `config.py` and the order of keywords defined in the preset's `map_type_mapping`.
* Classifying any remaining files as 'Unrecognised' (which are also moved to the `Extra/` folder).
4. `_determine_base_metadata()`: Determines the asset's base name, category (Texture, Asset, Decal), and archetype (e.g., Wood, Metal) based on classified files and preset rules (`source_naming`, `asset_category_rules`, `archetype_rules`).
5. **Skip Check**: If `overwrite` is false, checks if the final output directory and metadata file already exist. If so, processing for this asset stops early.
6. `_process_maps()`: Iterates through classified texture maps. For each map:
* Loads the image data (handling potential Gloss->Roughness inversion).
* Resizes the map to each target resolution specified in `config.py`, avoiding upscaling.
* Determines the output bit depth based on `MAP_BIT_DEPTH_RULES` (`respect` source or `force_8bit`).
* Determines the output file format (`.jpg`, `.png`, `.exr`) based on a combination of factors:
* The `RESOLUTION_THRESHOLD_FOR_JPG` (forces JPG for 8-bit maps above the threshold).
* The original input file format (e.g., `.jpg` inputs tend to produce `.jpg` outputs if 8-bit and below threshold).
* The target bit depth (16-bit outputs use configured `OUTPUT_FORMAT_16BIT_PRIMARY` or `_FALLBACK`).
* Configured 8-bit format (`OUTPUT_FORMAT_8BIT`).
* The `FORCE_LOSSLESS_MAP_TYPES` list in `config.py` (overrides all other logic for specified map types, ensuring PNG/EXR output).
* Saves the processed map for each resolution, applying appropriate compression/quality settings. Includes fallback logic if saving in the primary format fails (e.g., EXR -> PNG).
* Calculates basic image statistics (Min/Max/Mean) for a reference resolution (`CALCULATE_STATS_RESOLUTION`) and determines the aspect ratio change string (e.g., "EVEN", "X150", "Y075") stored in the metadata.
7. `_merge_maps()`: Combines channels from different processed maps into new textures (e.g., NRMRGH) based on `MAP_MERGE_RULES` defined in `config.py`. It determines the output format for merged maps similarly to `_process_maps` (checking `FORCE_LOSSLESS_MAP_TYPES` first, then threshold, then input hierarchy), considering the formats of the input maps involved.
8. `_generate_metadata_file()`: Collects all gathered information (asset name, maps present, resolutions, stats, aspect ratio change, etc.) and writes it to the `metadata.json` file.
9. `_organize_output_files()`: Moves the processed maps, merged maps, models, metadata file, and any 'Extra'/'Unrecognised'/'Ignored' files from the temporary workspace to the final structured output directory (`<output_base>/<supplier>/<asset_name>/`).
10. `_cleanup_workspace()`: Removes the temporary workspace directory.
### GUI Architecture (`gui/`)
The GUI provides an interactive way to use the tool and manage presets.
* **Framework**: Built using `PySide6`, the official Python bindings for the Qt framework.
* **Main Window (**`main_window.py`**)**: Defines the main application window, which includes:
* An integrated preset editor panel (using `QSplitter`).
* A processing panel with drag-and-drop support, output directory selection, a file preview table, and processing controls.
* **Blender Post-Processing Controls:** A group box containing a checkbox to enable/disable Blender script execution and input fields with browse buttons for specifying the target `.blend` files for node group and material creation.
* **Threading Model**: To prevent the UI from freezing during potentially long operations, background tasks are run in separate `QThread`s:
* `ProcessingHandler` **(**`processing_handler.py`**)**: Manages the execution of the main processing pipeline (using `ProcessPoolExecutor` and `main.process_single_asset_wrapper`, similar to the CLI) and the optional Blender script execution in a background thread. Receives the target output directory and Blender integration settings from the main window.
* `PredictionHandler` **(**`prediction_handler.py`**)**: Manages the generation of file previews in a background thread using a `ThreadPoolExecutor` to parallelize prediction across multiple assets. It calls `AssetProcessor.get_detailed_file_predictions()`, which performs extraction and classification.
* **Communication**: Qt's **signal and slot mechanism** is used for communication between the background threads (`ProcessingHandler`, `PredictionHandler`) and the main GUI thread (`MainWindow`). For example, signals are emitted to update the progress bar, populate the preview table, and report completion status or errors. A custom `QtLogHandler` redirects Python log messages to the UI console via signals.
* **Preset Editor**: The editor allows creating, modifying, and saving preset JSON files directly within the GUI. Changes are tracked, and users are prompted to save before closing or loading another preset if changes are pending. Includes an optional, toggleable log console panel at the top.
### Monitor Architecture (`monitor.py`)
The `monitor.py` script enables automated processing of assets dropped into a designated input directory.
* **File System Watching**: Uses the `watchdog` library (specifically `PollingObserver` for cross-platform compatibility) to monitor the specified `INPUT_DIR`.
* **Event Handling**: A custom `ZipHandler` detects `on_created` events for `.zip` files.
* **Filename Parsing**: It expects filenames in the format `[preset]_filename.zip` and uses a regular expression (`PRESET_FILENAME_REGEX`) to extract the `preset` name.
* **Preset Validation**: Checks if the extracted preset name corresponds to a valid `.json` file in the `Presets/` directory.
* **Processing Trigger**: If the filename format and preset are valid, it calls the `main.run_processing` function (the same core logic used by the CLI) to process the detected ZIP file using the extracted preset.
* **File Management**: Moves the source ZIP file to either a `PROCESSED_DIR` (on success/skip) or an `ERROR_DIR` (on failure or invalid preset) after the processing attempt.
### Error Handling
* Custom exception classes (`ConfigurationError`, `AssetProcessingError`) are defined and used to signal specific types of errors during configuration loading or asset processing.
* Standard Python logging is used throughout the application (CLI, GUI, Monitor, Core Logic) to record information, warnings, and errors. Log levels can be configured.
* Worker processes in the processing pool capture exceptions and report them back to the main process for logging and status updates.
## Requirements
* Python 3.8+
* Required Python Packages (see `requirements.txt`):
* `opencv-python` (for image processing)
* `numpy` (for numerical operations)
* `PySide6` (only needed for the GUI)
* Optional Python Packages:
* `OpenEXR` (provides more robust EXR file handling, recommended if processing EXR sources)
* **Blender:** A working installation of Blender is required for the optional Blender integration features. The path to the executable should be configured in `config.py` or available in the system's PATH.
Install dependencies using pip:
```bash
pip install -r requirements.txt
```
(For GUI, ensure PySide6 is included or install separately: `pip install PySide6`)
## Configuration
The tool's behavior is controlled by two main configuration components:
1. `config.py`**:** Defines core, global settings:
* `OUTPUT_BASE_DIR`: Default root directory for processed assets.
* `DEFAULT_ASSET_CATEGORY`: Fallback category ("Texture", "Asset", "Decal").
* `IMAGE_RESOLUTIONS`: Dictionary mapping resolution keys (e.g., "4K") to pixel dimensions.
* `RESPECT_VARIANT_MAP_TYPES`: List of map type strings (e.g., `["COL"]`) that should always receive a numeric suffix (`-1`, `-2`, etc.) based on preset order, even if only one variant exists.
* `TARGET_FILENAME_PATTERN`: Format string for output filenames.
* `MAP_MERGE_RULES`: List defining how to merge channels (e.g., creating NRMRGH).
* `ARCHETYPE_RULES`: Rules for determining asset usage archetype (e.g., Wood, Metal).
* `RESOLUTION_THRESHOLD_FOR_JPG`: Dimension threshold (pixels) above which 8-bit maps are forced to JPG format, overriding other format logic.
* `FORCE_LOSSLESS_MAP_TYPES`: List of map type strings (e.g., `["NRM", "DISP"]`) that should *always* be saved losslessly (PNG/EXR), overriding the JPG threshold and other format logic.
* `BLENDER_EXECUTABLE_PATH`: Path to the Blender executable (required for Blender integration).
* `DEFAULT_NODEGROUP_BLEND_PATH`: Default path to the .blend file for node group creation (used by GUI if not specified).
* `DEFAULT_MATERIALS_BLEND_PATH`: Default path to the .blend file for material creation (used by GUI if not specified).
* ... and other processing parameters (JPEG quality, PNG compression, 16-bit/8-bit output formats, etc.).
2. `presets/*.json`**:** Define supplier-specific rules. Each JSON file represents a preset (e.g., `Poliigon.json`). Key sections include:
* `supplier_name`: Name of the asset source.
* `map_type_mapping`: A list of dictionaries defining rules to map source filename keywords/patterns to standard map types. Each dictionary should have `"target_type"` (e.g., `"COL"`, `"NRM"`) and `"keywords"` (a list of source filename patterns like `["_col*", "_color"]`). For map types listed in `config.py`'s `RESPECT_VARIANT_MAP_TYPES`, the numbering priority (`-1`, `-2`, etc.) is determined primarily by the order of the keywords within the `"keywords"` list for the matching rule. Alphabetical sorting of filenames is used only as a secondary tie-breaker for files matching the exact same keyword pattern. Other map types do not receive suffixes.
* `bit_depth_variants`: Dictionary mapping standard map types (e.g., `"NRM"`) to fnmatch patterns used to identify their high bit-depth source files (e.g., `"*_NRM16*.tif"`). These take priority over standard keyword matches, and the corresponding 8-bit version will be ignored.
* `bit_depth_rules`: Specifies whether to `respect` source bit depth or `force_8bit` for specific map types (defined in `config.py`).
* `model_patterns`: Regex patterns to identify model files (e.g., `*.fbx`, `*.obj`).
* `move_to_extra_patterns`: Regex patterns for files to move directly to the `Extra/` output folder.
* `source_naming_convention`: Defines separator and indices for extracting base name/archetype from source filenames.
* `asset_category_rules`: Keywords/patterns to identify specific asset categories (e.g., "Decal").
Use `presets/_template.json` as a starting point for creating new presets.
## Usage
### 1. Graphical User Interface (GUI)
* **Run:**
```bash
python -m gui.main_window
```
*(Note: Run this command from the project root directory)*
* **Interface:**
* **Menu Bar:** Contains a "View" menu to toggle visibility of the Log Console and enable/disable the detailed file preview.
* **Preset Editor Panel (Left):**
* Optional **Log Console:** A text area at the top displaying application log messages (toggle via View menu).
* **Preset List:** Allows creating, deleting, loading, editing, and saving presets. Select a preset here to load it into the editor tabs below.
* **Preset Editor Tabs:** Edit preset details ("General & Naming", "Mapping & Rules").
* **Processing Panel (Right):**
* **Preset Selector:** Select the preset to use for *processing* the current queue.
* **Output Directory:** Displays the target output directory. Defaults to the path in `config.py`. Use the "Browse..." button to select a different directory.
* **Drag and Drop Area:** Drag asset ZIP files or folders here to add them to the queue.
* **Preview Table:** Displays information about the assets in the queue. Behavior depends on the "Disable Detailed Preview" option in the View menu:
* **Detailed Preview (Default):** Shows all files found within the dropped assets, their predicted classification status (Mapped, Model, Extra, Unrecognised, Ignored, Error), predicted output name (if applicable), and other details based on the selected *processing* preset. Rows are color-coded by status.
* **Simple View (Preview Disabled):** Shows only the list of top-level input asset paths (ZIPs/folders) added to the queue.
* **Progress Bar:** Shows the overall processing progress.
* **Blender Post-Processing:** A group box containing a checkbox to enable/disable the optional Blender script execution. When enabled, input fields and browse buttons appear to specify the `.blend` files for node group and material creation. These fields default to the paths configured in `config.py`.
* **Options & Controls (Bottom):**
* `Overwrite Existing`: Checkbox to force reprocessing if output already exists.
* `Workers`: Spinbox to set the number of assets to process concurrently.
* `Clear Queue`: Button to remove all assets from the queue and clear the preview.
* `Start Processing`: Button to begin processing all assets in the queue.
* `Cancel`: Button to attempt stopping ongoing processing.
* **Status Bar:** Displays messages about the current state, errors, or completion.
### 2. Command-Line Interface (CLI)
* **Run:**
```bash
python main.py [OPTIONS] INPUT_PATH [INPUT_PATH ...]
```
* **Arguments:**
* `INPUT_PATH`: One or more paths to input ZIP files or folders.
* `-p PRESET`, `--preset PRESET`: (Required) Name of the preset to use (e.g., `Poliigon`).
* `-o OUTPUT_DIR`, `--output-dir OUTPUT_DIR`: Override the `OUTPUT_BASE_DIR` set in `config.py`.
* `-w WORKERS`, `--workers WORKERS`: Number of parallel processes (default: auto-detected based on CPU cores).
* `--overwrite`: Force reprocessing and overwrite existing output.
* `-v`, `--verbose`: Enable detailed DEBUG level logging.
* `--nodegroup-blend NODEGROUP_BLEND`: Path to the .blend file for creating/updating node groups. Overrides `config.py` default. If provided, triggers node group script execution after processing.
* `--materials-blend MATERIALS_BLEND`: Path to the .blend file for creating/updating materials. Overrides `config.py` default. If provided, triggers material script execution after processing.
* **Example:**
```bash
python main.py "C:/Downloads/WoodFine001.zip" -p Poliigon -o "G:/Assets/Processed" --workers 4 --overwrite --nodegroup-blend "G:/Blender/Libraries/NodeGroups.blend" --materials-blend "G:/Blender/Libraries/Materials.blend"
```
### 3. Directory Monitor (Automated Processing)
* **Run:**
```bash
python monitor.py
```
* **Functionality:** This script continuously monitors a specified input directory for new `.zip` files. When a file matching the expected format `[preset]_filename.zip` appears, it automatically triggers the processing pipeline using the extracted preset name. **Note:** The directory monitor currently does *not* support the optional Blender script execution. This feature is only available via the CLI and GUI.
* **Configuration (Environment Variables):**
* `INPUT_DIR`: Directory to monitor for new ZIP files (default: `/data/input`).
* `OUTPUT_DIR`: Base directory for processed asset output (default: `/data/output`).
* `PROCESSED_DIR`: Directory where successfully processed/skipped source ZIPs are moved (default: `/data/processed`).
* `ERROR_DIR`: Directory where source ZIPs that failed processing are moved (default: `/data/error`).
* `LOG_LEVEL`: Logging verbosity (e.g., `INFO`, `DEBUG`) (default: `INFO`).
* `POLL_INTERVAL`: How often to check the input directory (seconds) (default: `5`).
* `PROCESS_DELAY`: Delay after detecting a file before processing starts (seconds) (default: `2`).
* `NUM_WORKERS`: Number of parallel workers for processing (default: auto-detected).
* **Output:**
* Logs processing activity to the console.
* Processed assets are created in the `OUTPUT_DIR` following the standard structure.
* The original input `.zip` file is moved to `PROCESSED_DIR` on success/skip or `ERROR_DIR` on failure.
### 4. Blender Node Group Creation Script (`blenderscripts/create_nodegroups.py`)
* **Purpose:** This script, designed to be run *within* Blender (either manually or triggered by `main.py`/GUI), scans processed assets and creates/updates PBR node groups in the active `.blend` file.
* **Execution:** Typically run via the Asset Processor tool's CLI or GUI after asset processing. Can also be run manually in Blender's Text Editor.
* **Prerequisites (for manual run):**
* A library of assets processed by this tool, located at a known path.
* A Blender file containing two template node groups named exactly `Template_PBRSET` and `Template_PBRTYPE`.
* **Configuration (Inside the script for manual run):**
* `PROCESSED_ASSET_LIBRARY_ROOT`: **Must be updated** within the script to point to the base output directory where the processed supplier folders (e.g., `Poliigon/`) are located. This is overridden by the tool when run via CLI/GUI.
* **Functionality:** Reads metadata, creates/updates node groups, loads textures, sets up nodes, applies metadata-driven settings (aspect ratio, stats, highest resolution), and sets asset previews. Includes an explicit save command at the end.
### 5. Blender Material Creation Script (`blenderscripts/create_materials.py`)
* **Purpose:** This script, designed to be run *within* Blender (either manually or triggered by `main.py`/GUI), scans processed assets and creates/updates materials in the active `.blend` file that link to the PBRSET node groups created by `create_nodegroups.py`.
* **Execution:** Typically run via the Asset Processor tool's CLI or GUI after asset processing. Can also be run manually in Blender's Text Editor.
* **Prerequisites (for manual run):**
* A library of assets processed by this tool, located at a known path.
* A `.blend` file containing the PBRSET node groups created by `create_nodegroups.py`.
* A template material in the *current* Blender file named `Template_PBRMaterial` that uses nodes and contains a Group node labeled `PLACEHOLDER_NODE_LABEL`.
* **Configuration (Inside the script for manual run):**
* `PROCESSED_ASSET_LIBRARY_ROOT`: **Must be updated** within the script to point to the base output directory where the processed supplier folders (e.g., `Poliigon/`) are located. This is overridden by the tool when run via CLI/GUI.
* `NODEGROUP_BLEND_FILE_PATH`: **Must be updated** within the script to point to the `.blend` file containing the PBRSET node groups. This is overridden by the tool when run via CLI/GUI.
* `TEMPLATE_MATERIAL_NAME`, `PLACEHOLDER_NODE_LABEL`, `MATERIAL_NAME_PREFIX`, `PBRSET_GROUP_PREFIX`, etc., can be adjusted if needed.
* **Functionality:** Reads metadata, creates/updates materials by copying the template, links the corresponding PBRSET node group from the specified `.blend` file, marks materials as assets, copies tags, sets custom previews, and sets viewport properties based on metadata. Includes an explicit save command at the end.
## Processing Pipeline (Simplified)
1. **Extraction:** Input ZIP/folder contents are extracted/copied to a temporary workspace.
2. **Classification:** Files are scanned and classified (map, model, extra, ignored) using preset rules.
3. **Metadata Determination:** Asset name, category, and archetype are determined.
4. **Skip Check:** If output exists and overwrite is off, processing stops here.
5. **Map Processing:** Identified maps are loaded, resized, converted (bit depth, format), and saved. Gloss maps are inverted if needed. Stats are calculated.
6. **Merging:** Channels are merged according to preset rules and saved.
7. **Metadata Generation:** `metadata.json` is created with all collected information.
8. **Output Organization:** Processed files are moved to the final structured output directory.
9. **Cleanup:** The temporary workspace is removed.
10. **Optional Blender Script Execution:** If configured via CLI or GUI, Blender is launched in the background to run `create_nodegroups.py` and `create_materials.py` on specified `.blend` files, using the processed asset output directory as input.
## Output Structure
Processed assets are saved to: `<output_base_directory>/<supplier_name>/<asset_name>/`
Each asset directory typically contains:
* Processed texture maps (e.g., `AssetName_Color_4K.png`, `AssetName_NRM_2K.exr`).
* Merged texture maps (e.g., `AssetName_NRMRGH_4K.png`).
* Model files (if present in source).
* `metadata.json`: Detailed information about the asset and processing.
* `Extra/` (subdirectory): Contains source files that were not classified as standard maps or models. This includes files explicitly matched by `move_to_extra_patterns` in the preset (e.g., previews, documentation) as well as any other unrecognised files.
## Docker
A `Dockerfile` and `requirements-docker.txt` are provided for building a container image to run the processor in an isolated environment. Build and run using standard Docker commands.

View File

@ -23,7 +23,7 @@ This document outlines the coding conventions and general practices followed wit
* **Configuration:** Core application settings are defined in `config/app_settings.json`. Supplier-specific rules are managed in JSON files within the `Presets/` directory. The `Configuration` class (`configuration.py`) is responsible for loading `app_settings.json` and merging it with the selected preset file.
* **File Paths:** Use `pathlib.Path` objects for handling file system paths. Avoid using string manipulation for path joining or parsing.
* **Docstrings:** Write clear and concise docstrings for modules, classes, methods, and functions, explaining their purpose, arguments, and return values.
* **Comments:** Use comments to explain complex logic or non-obvious parts of the code.
* **Comments:** Use comments to explain complex logic or non-obvious parts of the code. Avoid obsolete comments (e.g., commented-out old code) and redundant comments (e.g., comments stating the obvious, like `# Import module` or `# Initialize variable`). The goal is to maintain clarity while minimizing unnecessary token usage for LLM tools.
* **Imports:** Organize imports at the top of the file, grouped by standard library, third-party libraries, and local modules.
* **Naming:**
* Use `snake_case` for function and variable names.

View File

@ -1,106 +0,0 @@
# DRAFT README Enhancements - Architecture Section & Refinements
**(Note: This is a draft. Integrate the "Architecture" section and the refinements into the main `readme.md` file.)**
---
## Refinements to Existing Sections
**(Suggest adding these points or similar wording to the relevant existing sections)**
* **In Features:**
* Add: **Responsive GUI:** Utilizes background threads for processing and file preview generation, ensuring the user interface remains responsive.
* Add: **Optimized Classification:** Pre-compiles regular expressions from presets for faster file identification during classification.
* **In Directory Structure:**
* Update Core Logic bullet: `* **Core Logic:** main.py, monitor.py, asset_processor.py, configuration.py, config.py` (explicitly add `configuration.py`).
---
## Architecture
**(Suggest adding this new section, perhaps after "Features" or "Directory Structure")**
This section provides a higher-level overview of the tool's internal structure and design, intended for developers or users interested in the technical implementation.
### Core Components
The tool is primarily built around several key Python modules:
* **`config.py`**: Defines core, global settings (output paths, resolutions, default behaviors, format rules, etc.) that are generally not supplier-specific.
* **`Presets/*.json`**: Supplier-specific JSON files defining rules for interpreting source assets (filename patterns, map type keywords, model identification, etc.).
* **`configuration.py` (`Configuration` class)**: Responsible for loading the core `config.py` settings and merging them with a selected preset JSON file. Crucially, it also **pre-compiles** regular expression patterns defined in the preset (e.g., for map keywords, extra files, 16-bit variants) upon initialization. This pre-compilation significantly speeds up the file classification process.
* **`asset_processor.py` (`AssetProcessor` class)**: Contains the core logic for processing a *single* asset. It orchestrates the pipeline steps: workspace setup, extraction, file classification, metadata determination, map processing, channel merging, metadata file generation, and output organization.
* **`main.py`**: Serves as the entry point for the Command-Line Interface (CLI). It handles argument parsing, sets up logging, manages the parallel processing pool, and calls `AssetProcessor` for each input asset via a wrapper function.
* **`gui/`**: Contains modules related to the Graphical User Interface (GUI), built using PySide6.
* **`monitor.py`**: Implements the directory monitoring functionality for automated processing.
### Parallel Processing (CLI & GUI)
To accelerate the processing of multiple assets, the tool utilizes Python's `concurrent.futures.ProcessPoolExecutor`.
* Both `main.py` (for CLI) and `gui/processing_handler.py` (for GUI background tasks) create a process pool.
* The actual processing for each asset is delegated to the `main.process_single_asset_wrapper` function. This wrapper is executed in a separate worker process within the pool.
* The wrapper function is responsible for instantiating the `Configuration` and `AssetProcessor` classes for the specific asset being processed in that worker. This isolates each asset's processing environment.
* Results (success, skip, failure, error messages) are communicated back from the worker processes to the main coordinating script (either `main.py` or `gui/processing_handler.py`).
### Asset Processing Pipeline (`AssetProcessor` class)
The `AssetProcessor` class executes a sequence of steps for each asset:
1. **`_setup_workspace()`**: Creates a temporary directory for processing.
2. **`_extract_input()`**: Extracts the input ZIP archive or copies the input folder contents into the temporary workspace.
3. **`_inventory_and_classify_files()`**: This is a critical step that scans the workspace and classifies each file based on rules defined in the loaded `Configuration` (which includes the preset). It uses the pre-compiled regex patterns for efficiency. Key logic includes:
* Identifying files explicitly marked for the `Extra/` folder.
* Identifying model files.
* Matching potential texture maps against keyword patterns.
* Identifying and prioritizing 16-bit variants (e.g., `_NRM16.tif`) over their 8-bit counterparts based on `source_naming.bit_depth_variants` patterns. Ignored 8-bit files are tracked.
* Handling map variants (e.g., multiple Color maps) by assigning suffixes (`-1`, `-2`) based on the `RESPECT_VARIANT_MAP_TYPES` setting in `config.py` and the order of keywords defined in the preset's `map_type_mapping`.
* Classifying any remaining files as 'Unrecognised' (which are also moved to the `Extra/` folder).
4. **`_determine_base_metadata()`**: Determines the asset's base name, category (Texture, Asset, Decal), and archetype (e.g., Wood, Metal) based on classified files and preset rules (`source_naming`, `asset_category_rules`, `archetype_rules`).
5. **Skip Check**: If `overwrite` is false, checks if the final output directory and metadata file already exist. If so, processing for this asset stops early.
6. **`_process_maps()`**: Iterates through classified texture maps. For each map:
* Loads the image data (handling potential Gloss->Roughness inversion).
* Resizes the map to each target resolution specified in `config.py`, avoiding upscaling.
* Determines the output bit depth based on `MAP_BIT_DEPTH_RULES` (`respect` source or `force_8bit`).
* Determines the output file format (`.jpg`, `.png`, `.exr`) based on a combination of factors:
* The `RESOLUTION_THRESHOLD_FOR_JPG` (forces JPG for 8-bit maps above the threshold).
* The original input file format (e.g., `.jpg` inputs tend to produce `.jpg` outputs if 8-bit and below threshold).
* The target bit depth (16-bit outputs use configured `OUTPUT_FORMAT_16BIT_PRIMARY` or `_FALLBACK`).
* Configured 8-bit format (`OUTPUT_FORMAT_8BIT`).
* Saves the processed map for each resolution, applying appropriate compression/quality settings. Includes fallback logic if saving in the primary format fails (e.g., EXR -> PNG).
* Calculates basic image statistics (Min/Max/Mean) for a reference resolution (`CALCULATE_STATS_RESOLUTION`).
7. **`_merge_maps()`**: Combines channels from different processed maps into new textures (e.g., NRMRGH) based on `MAP_MERGE_RULES` defined in `config.py`. It determines the output format for merged maps similarly to `_process_maps`, considering the formats of the input maps involved.
8. **`_generate_metadata_file()`**: Collects all gathered information (asset name, maps present, resolutions, stats, etc.) and writes it to the `metadata.json` file.
9. **`_organize_output_files()`**: Moves the processed maps, merged maps, models, metadata file, and any 'Extra'/'Unrecognised'/'Ignored' files from the temporary workspace to the final structured output directory (`<output_base>/<supplier>/<asset_name>/`).
10. **`_cleanup_workspace()`**: Removes the temporary workspace directory.
### GUI Architecture (`gui/`)
The GUI provides an interactive way to use the tool and manage presets.
* **Framework**: Built using `PySide6`, the official Python bindings for the Qt framework.
* **Main Window (`main_window.py`)**: Defines the main application window, which includes:
* An integrated preset editor panel (using `QSplitter`).
* A processing panel with drag-and-drop support, a file preview table, and processing controls.
* **Threading Model**: To prevent the UI from freezing during potentially long operations, background tasks are run in separate `QThread`s:
* **`ProcessingHandler` (`processing_handler.py`)**: Manages the execution of the main processing pipeline (using `ProcessPoolExecutor` and `main.process_single_asset_wrapper`, similar to the CLI) in a background thread.
* **`PredictionHandler` (`prediction_handler.py`)**: Manages the generation of file previews in a background thread. It calls `AssetProcessor.get_detailed_file_predictions()`, which performs the extraction and classification steps without full image processing, making it much faster.
* **Communication**: Qt's **signal and slot mechanism** is used for communication between the background threads (`ProcessingHandler`, `PredictionHandler`) and the main GUI thread (`MainWindow`). For example, signals are emitted to update the progress bar, populate the preview table, and report completion status or errors.
* **Preset Editor**: The editor allows creating, modifying, and saving preset JSON files directly within the GUI. Changes are tracked, and users are prompted to save before closing or loading another preset if changes are pending.
### Monitor Architecture (`monitor.py`)
The `monitor.py` script enables automated processing of assets dropped into a designated input directory.
* **File System Watching**: Uses the `watchdog` library (specifically `PollingObserver` for cross-platform compatibility) to monitor the specified `INPUT_DIR`.
* **Event Handling**: A custom `ZipHandler` detects `on_created` events for `.zip` files.
* **Filename Parsing**: It expects filenames in the format `[preset]_filename.zip` and uses a regular expression (`PRESET_FILENAME_REGEX`) to extract the `preset` name.
* **Preset Validation**: Checks if the extracted preset name corresponds to a valid `.json` file in the `Presets/` directory.
* **Processing Trigger**: If the filename format and preset are valid, it calls the `main.run_processing` function (the same core logic used by the CLI) to process the detected ZIP file using the extracted preset.
* **File Management**: Moves the source ZIP file to either a `PROCESSED_DIR` (on success/skip) or an `ERROR_DIR` (on failure or invalid preset) after the processing attempt.
### Error Handling
* Custom exception classes (`ConfigurationError`, `AssetProcessingError`) are defined and used to signal specific types of errors during configuration loading or asset processing.
* Standard Python logging is used throughout the application (CLI, GUI, Monitor, Core Logic) to record information, warnings, and errors. Log levels can be configured.
* Worker processes in the processing pool capture exceptions and report them back to the main process for logging and status updates.

View File

@ -1,150 +0,0 @@
Implementation Plan: Path Token Data Generation
This plan outlines the steps required to implement data generation/retrieval for the [IncrementingValue], ####, and [Sha5] path tokens used in OUTPUT_DIRECTORY_PATTERN and OUTPUT_FILENAME_PATTERN.
1. Goal Recap
Enable the use of [IncrementingValue] (or ####), [Time], and [Sha5] tokens within the output path patterns used by processing_engine.py. Implement logic to generate/retrieve data for these tokens and pass it to utils.path_utils.generate_path_from_pattern. Confirm handling of [Date] and [ApplicationPath].
2. Analysis Summary & Existing Token Handling
[Date], [Time], [ApplicationPath]: Handled automatically by utils/path_utils.py. No changes needed.
[IncrementingValue] / ####: Requires data provision based on scanning existing output directories. Implementation detailed below.
[Sha5]: Requires data provision (first 5 chars of SHA-256 hash of original input file). Implementation detailed below.
Path Generation Points: _save_image() and _generate_metadata_file() in processing_engine.py.
3. Implementation Plan per Token
3.1. [IncrementingValue] / #### (Directory Scan Logic)
Scope & Behavior: Determine the next available incrementing number by scanning existing directories in the final output_base_path that match the OUTPUT_DIRECTORY_PATTERN structure. The value represents the next sequence number globally across the pattern structure.
Location: New utility function get_next_incrementing_value in utils/path_utils.py, called from orchestrating code (main.py / monitor.py).
Mechanism:
get_next_incrementing_value(output_base_path: Path, output_directory_pattern: str) -> str:
Parses output_directory_pattern to find the incrementing token (#### or [IncrementingValue]) and determine padding digits.
Constructs a glob pattern based on the pattern structure (e.g., [0-9][0-9]_* for ##_*).
Uses output_base_path.glob() to find matching directories.
Extracts numerical prefixes from matching directory names using regex.
Finds the maximum existing integer value (or -1 if none).
Calculates next_value = max_value + 1.
Formats next_value as a zero-padded string based on the pattern's digits.
Returns the formatted string.
Orchestrator (main.py/monitor.py):
Load Configuration to get OUTPUT_DIRECTORY_PATTERN.
Get output_base_path.
Call next_increment_str = get_next_incrementing_value(output_base_path, config.output_directory_pattern).
Pass next_increment_str to ProcessingEngine.process as incrementing_value.
Integration (processing_engine.py):
Accept incrementing_value: Optional[str] in process signature.
Store on self.current_incrementing_value.
Add to token_data (key: 'incrementingvalue') in _save_image and _generate_metadata_file.
3.2. [Sha5]
Scope & Behavior: Calculate SHA-256 hash of the original input source file, take the first 5 characters.
Location: Orchestrating code (main.py / monitor.py) before ProcessingEngine invocation.
Mechanism: Use new utility function calculate_sha256 in utils/hash_utils.py. Call this in the orchestrator, get the first 5 chars, pass to ProcessingEngine.process.
Integration (processing_engine.py): Accept sha5_value: Optional[str] in process, store on self.current_sha5_value, add to token_data (key: 'sha5') in _save_image and _generate_metadata_file.
4. Proposed Code Changes
4.1. utils/hash_utils.py (New File)
# utils/hash_utils.py
import hashlib
import logging
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
def calculate_sha256(file_path: Path) -> Optional[str]:
"""Calculates the SHA-256 hash of a file."""
# Implementation as detailed in the previous plan revision...
if not isinstance(file_path, Path): return None
if not file_path.is_file(): return None
sha256_hash = hashlib.sha256()
try:
with open(file_path, "rb") as f:
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
return sha256_hash.hexdigest()
except OSError as e:
logger.error(f"Error reading file {file_path} for SHA-256: {e}", exc_info=True)
return None
except Exception as e:
logger.error(f"Unexpected error calculating SHA-256 for {file_path}: {e}", exc_info=True)
return None
python
4.2. utils/path_utils.py (Additions/Modifications)
# (In utils/path_utils.py)
import re
import logging
from pathlib import Path
from typing import Optional, Dict
logger = logging.getLogger(__name__)
# ... (existing generate_path_from_pattern function) ...
def get_next_incrementing_value(output_base_path: Path, output_directory_pattern: str) -> str:
"""Determines the next incrementing value based on existing directories."""
# Implementation as detailed in the previous plan revision...
logger.debug(f"Calculating next increment value for pattern '{output_directory_pattern}' in '{output_base_path}'")
match = re.match(r"(.*?)(\[IncrementingValue\]|(#+))(.*)", output_directory_pattern)
if not match: return "00" # Default fallback
prefix_pattern, increment_token, suffix_pattern = match.groups()
num_digits = len(increment_token) if increment_token.startswith("#") else 2
glob_increment_part = f"[{'0-9' * num_digits}]"
glob_prefix = re.sub(r'\[[^\]]+\]', '*', prefix_pattern)
glob_suffix = re.sub(r'\[[^\]]+\]', '*', suffix_pattern)
glob_pattern = f"{glob_prefix}{glob_increment_part}{glob_suffix}"
max_value = -1
try:
extract_prefix_re = re.escape(prefix_pattern)
extract_suffix_re = re.escape(suffix_pattern)
extract_regex = re.compile(rf"^{extract_prefix_re}(\d{{{num_digits}}}){extract_suffix_re}.*")
for item in output_base_path.glob(glob_pattern):
if item.is_dir():
num_match = extract_regex.match(item.name)
if num_match:
try: max_value = max(max_value, int(num_match.group(1)))
except (ValueError, IndexError): pass
except Exception as e: logger.error(f"Error searching increment values: {e}", exc_info=True)
next_value = max_value + 1
format_string = f"{{:0{num_digits}d}}"
next_value_str = format_string.format(next_value)
logger.info(f"Determined next incrementing value: {next_value_str}")
return next_value_str
python
4.3. main.py / monitor.py (Orchestration - Revised Call)
Imports: Add from utils.hash_utils import calculate_sha256, from utils.path_utils import get_next_incrementing_value.
Before ProcessingEngine.process call:
Get archive_path, output_dir.
Load config = Configuration(...).
full_sha = calculate_sha256(archive_path).
sha5_value = full_sha[:5] if full_sha else None.
next_increment_str = get_next_incrementing_value(output_dir, config.output_directory_pattern).
Modify call: engine.process(..., incrementing_value=next_increment_str, sha5_value=sha5_value).
4.4. processing_engine.py
Imports: Ensure Optional, logging, generate_path_from_pattern are imported.
process Method:
Update signature: def process(..., incrementing_value: Optional[str] = None, sha5_value: Optional[str] = None) -> ...:
Store args: self.current_incrementing_value = incrementing_value, self.current_sha5_value = sha5_value.
_save_image & _generate_metadata_file Methods:
Before calling generate_path_from_pattern, add stored values to token_data:
# Add new token data if available
if hasattr(self, 'current_incrementing_value') and self.current_incrementing_value is not None:
token_data['incrementingvalue'] = self.current_incrementing_value
if hasattr(self, 'current_sha5_value') and self.current_sha5_value is not None:
token_data['sha5'] = self.current_sha5_value
log.debug(f"Token data for path generation: {token_data}")

View File

@ -1,124 +0,0 @@
# Blender Integration Plan: Node Groups from Processed Assets
**Objective:** Develop a Python script (`blenderscripts/create_nodegroups.py`) to run manually inside Blender. This script will scan the output directory generated by the Asset Processor Tool, read `metadata.json` files, and create/update corresponding PBR node groups in the active Blender file, leveraging the pre-calculated metadata.
**Key Principles:**
* **Leverage Existing Tool Output:** Rely entirely on the structured output and `metadata.json` from the Asset Processor Tool. Avoid reprocessing or recalculating data already available.
* **Blender Environment:** The script is designed solely for Blender's Python environment (`bpy`).
* **Manual Execution:** Users will manually run this script from Blender's Text Editor.
* **Target Active File:** All operations modify the currently open `.blend` file.
* **Assume Templates:** The script will assume node group templates (`Template_PBRSET`, `Template_PBRTYPE`) exist in the active file. Error handling will be added if they are missing.
* **Focus on Node Groups:** The script's scope is limited to creating and updating the node groups, not materials.
**Detailed Plan:**
1. **Script Setup & Configuration:**
* Create a new Python file named `create_nodegroups.py` (intended location: `blenderscripts/`).
* Import necessary modules (`bpy`, `os`, `json`, `pathlib`).
* Define user-configurable variables at the top:
* `PROCESSED_ASSET_LIBRARY_ROOT`: Path to the root output directory of the Asset Processor Tool.
* `PARENT_TEMPLATE_NAME`: Name of the parent node group template (e.g., `"Template_PBRSET"`).
* `CHILD_TEMPLATE_NAME`: Name of the child node group template (e.g., `"Template_PBRTYPE"`).
* `ASPECT_RATIO_NODE_LABEL`: Label of the Value node in the parent template for aspect ratio correction (e.g., `"AspectRatioCorrection"`).
* `STATS_NODE_PREFIX`: Prefix for Combine XYZ nodes storing stats in the parent template (e.g., `"Histogram-"`).
* `ENABLE_MANIFEST`: Boolean flag to enable/disable the manifest system (default: `True`).
2. **Manifest Handling:**
* **Location:** Use a separate JSON file named `[ActiveBlendFileName]_manifest.json`, located in the same directory as the active `.blend` file.
* **Loading:** Implement `load_manifest(context)` that finds and reads the manifest JSON file. If not found or invalid, return an empty dictionary.
* **Saving:** Implement `save_manifest(context, manifest_data)` that writes the `manifest_data` dictionary to the manifest JSON file.
* **Checking:** Implement helper functions `is_asset_processed(manifest_data, asset_name)` and `is_map_processed(manifest_data, asset_name, map_type, resolution)` to check against the loaded manifest.
* **Updating:** Update the manifest dictionary in memory as assets/maps are processed. Save the manifest once at the end of the script.
3. **Core Logic - `process_library()` function:**
* Get Blender context.
* Load manifest data (if enabled).
* Validate that `PROCESSED_ASSET_LIBRARY_ROOT` exists.
* Validate that template node groups exist in `bpy.data.node_groups`. Exit gracefully with an error message if not found.
* Initialize counters (new groups, updated groups, etc.).
* **Scan Directory:** Use `os.walk` or `pathlib.rglob` to find all `metadata.json` files within the `PROCESSED_ASSET_LIBRARY_ROOT`.
* **Iterate Metadata:** For each `metadata.json` found:
* Parse the JSON data. Extract key information: `asset_name`, `supplier_name`, `archetype`, `maps` (dictionary of maps with resolutions, paths, stats), `aspect_ratio_change_string`.
* Check manifest if asset is already processed (if enabled). Skip if true.
* **Parent Group Handling:**
* Determine target parent group name (e.g., `f"PBRSET_{asset_name}"`).
* Find existing group or create a copy from `PARENT_TEMPLATE_NAME`.
* Mark group as asset (`asset_mark()`) if not already.
* **Apply Metadata:**
* Find the aspect ratio node using `ASPECT_RATIO_NODE_LABEL`. Calculate the correction factor based on the `aspect_ratio_change_string` from metadata (using helper function `calculate_factor_from_string`) and set the node's default value.
* For relevant map types (e.g., ROUGH, DISP), find the stats node (`STATS_NODE_PREFIX` + map type). Set the X, Y, Z inputs using the `min`, `max`, `mean` values stored in the map's metadata entry for the reference resolution.
* **Apply Asset Tags:** Use `asset_data.tags.new()` to add the `supplier_name` and `archetype` tags (checking for existence first).
* **Child Group Handling (Iterate through `maps` in metadata):**
* For each `map_type` (e.g., "COL", "NRM") and its data in the metadata:
* Determine target child group name (e.g., `f"PBRTYPE_{asset_name}_{map_type}"`).
* Find existing child group or create a copy from `CHILD_TEMPLATE_NAME`.
* Find the corresponding placeholder node in the *parent* group (by label matching `map_type`). Assign the child node group to this placeholder (`placeholder_node.node_tree = child_group`).
* Link the child group's output to the corresponding parent group's output socket. Ensure the parent output socket type is `NodeSocketColor`.
* **Image Node Handling (Iterate through resolutions for the map type):**
* For each `resolution` (e.g., "4K", "2K") and its `image_path` in the metadata:
* Check manifest if this specific map/resolution is processed (if enabled). Skip if true.
* Find the corresponding Image Texture node in the *child* group (by label matching `resolution`, e.g., "4K").
* Load the image using `bpy.data.images.load(image_path, check_existing=True)`. Handle potential file-not-found errors.
* Assign the loaded image to the `image_node.image`.
* Set the `image_node.image.colorspace_settings.name` based on the `map_type` (using a helper function `get_color_space`).
* Update manifest dictionary for this map/resolution (if enabled).
* Update manifest dictionary for the processed asset (if enabled).
* Save manifest data (if enabled and changes were made).
* Print summary (duration, groups created/updated, etc.).
4. **Helper Functions:**
* `find_nodes_by_label(node_tree, label, node_type)`: Reusable function to find nodes.
* `calculate_factor_from_string(aspect_string)`: Parses the `aspect_ratio_change_string` from metadata and returns the appropriate UV X-scaling factor.
* `get_color_space(map_type)`: Returns the appropriate Blender color space name for a given map type string.
* `add_tag_if_new(asset_data, tag_name)`: Adds a tag if it doesn't exist.
* Manifest loading/saving/checking functions.
5. **Execution Block (`if __name__ == "__main__":`)**
* Add pre-run checks (templates exist, library path valid, blend file saved if manifest enabled).
* Call the main `process_library()` function.
* Include basic timing and print statements for start/end.
**Mermaid Diagram:**
```mermaid
graph TD
A[Start Script in Blender] --> B(Load Config: Lib Path, Template Names, Node Labels);
B --> C{Check Templates Exist};
C -- Templates OK --> D(Load Manifest from adjacent .json file);
C -- Templates Missing --> X(Error & Exit);
D --> E{Scan Processed Library for metadata.json};
E --> F{For each metadata.json};
F --> G{Parse Metadata (Asset Name, Supplier, Archetype, Maps, Aspect Str, Stats)};
G --> H{Is Asset in Manifest?};
H -- Yes --> F;
H -- No --> I{Find/Create Parent Group (PBRSET_)};
I --> J(Mark as Asset & Apply Supplier + Archetype Tags);
J --> K(Find Aspect Node & Set Value from Aspect String);
K --> M{For each Map Type in Metadata};
M --> N(Find Stats Node & Set Values from Stats in Metadata);
N --> O{Find/Create Child Group (PBRTYPE_)};
O --> P(Assign Child to Parent Placeholder);
P --> Q(Link Child Output to Parent Output);
Q --> R{For each Resolution of Map};
R --> S{Is Map/Res in Manifest?};
S -- Yes --> R;
S -- No --> T(Find Image Node in Child);
T --> U(Load Processed Image);
U --> V(Assign Image to Node);
V --> W(Set Image Color Space);
W --> W1(Update Manifest Dict for Map/Res);
W1 --> R;
R -- All Resolutions Done --> M;
M -- All Map Types Done --> X1(Update Manifest Dict for Asset);
X1 --> F;
F -- All metadata.json Processed --> Y(Save Manifest Dict to adjacent .json file);
Y --> Z(Print Summary & Finish);
subgraph "Manifest Operations (External File)"
D; H; S; W1; X1; Y;
end
subgraph "Node/Asset Operations"
I; J; K; N; O; P; Q; T; U; V; W;
end

View File

@ -1,52 +0,0 @@
# Blender Integration Plan v2
## Goal
Add an optional step to `main.py` to run `blenderscripts/create_nodegroups.py` and `blenderscripts/create_materials.py` on specified `.blend` files after asset processing is complete.
## Proposed Plan
1. **Update `config.py`:**
* Add two new optional configuration variables: `DEFAULT_NODEGROUP_BLEND_PATH` and `DEFAULT_MATERIALS_BLEND_PATH`. These will store the default paths to the Blender files.
2. **Update `main.py` Argument Parser:**
* Add two new optional command-line arguments: `--nodegroup-blend` and `--materials-blend`.
* These arguments will accept file paths to the respective `.blend` files.
* If provided, these arguments will override the default paths specified in `config.py`.
3. **Update `blenderscripts/create_nodegroups.py` and `blenderscripts/create_materials.py`:**
* Modify both scripts to accept the processed asset library root path (`PROCESSED_ASSET_LIBRARY_ROOT`) as a command-line argument. This will be passed to the script when executed by Blender using the `--` separator.
* Update the scripts to read this path from `sys.argv` instead of using the hardcoded variable.
4. **Update `main.py` Execution Flow:**
* After the main asset processing loop (`run_processing`) completes and the summary is reported, check if the `--nodegroup-blend` or `--materials-blend` arguments (or their fallbacks from `config.py`) were provided.
* If a path for the nodegroup `.blend` file is available:
* Construct a command to execute Blender in the background (`-b`), load the specified nodegroup `.blend` file, run the `create_nodegroups.py` script using `--python`, pass the processed asset root directory as an argument after `--`, and save the `.blend` file (`-S`).
* Execute this command using the `execute_command` tool.
* If a path for the materials `.blend` file is available:
* Construct a similar command to execute Blender in the background, load the specified materials `.blend` file, run the `create_materials.py` script using `--python`, pass the processed asset root directory as an argument after `--`, and save the `.blend` file (`-S`).
* Execute this command using the `execute_command` tool.
* Include error handling for the execution of the Blender commands.
## Execution Flow Diagram
```mermaid
graph TD
A[Asset Processing Complete] --> B[Report Summary];
B --> C{Nodegroup Blend Path Specified?};
C -- Yes --> D[Get Nodegroup Blend Path (Arg or Config)];
D --> E[Construct Blender Command for Nodegroups];
E --> F[Execute Command: blender -b nodegroup.blend --python create_nodegroups.py -- <asset_root> -S];
F --> G{Command Successful?};
G -- Yes --> H{Materials Blend Path Specified?};
G -- No --> I[Log Nodegroup Error];
I --> H;
H -- Yes --> J[Get Materials Blend Path (Arg or Config)];
J --> K[Construct Blender Command for Materials];
K --> L[Execute Command: blender -b materials.blend --python create_materials.py -- <asset_root> -S];
L --> M{Command Successful?};
M -- Yes --> N[End main.py];
M -- No --> O[Log Materials Error];
O --> N;
H -- No --> N;
C -- No --> H;

View File

@ -1,131 +0,0 @@
# Blender Material Creation Script Plan
This document outlines the plan for creating a new Blender script (`create_materials.py`) in the `blenderscripts/` directory. This script will scan the processed asset library output by the Asset Processor Tool, read the `metadata.json` files, and create or update Blender materials that link to the corresponding PBRSET node groups found in a specified Blender Asset Library. The script will also set the material's viewport properties using pre-calculated statistics from the metadata. The script will skip processing an asset if the corresponding material already exists in the current Blender file.
## 1. Script Location and Naming
* Create a new file: `blenderscripts/create_materials.py`.
## 2. Script Structure
The script will follow a similar structure to `blenderscripts/create_nodegroups.py`, including:
* Import statements (`bpy`, `os`, `json`, `pathlib`, `time`, `base64`).
* A `--- USER CONFIGURATION ---` section at the top.
* Helper functions.
* A main processing function (e.g., `process_library_for_materials`).
* An execution block (`if __name__ == "__main__":`) to run the main function.
## 3. Configuration Variables
The script will include the following configuration variables in the `--- USER CONFIGURATION ---` section:
* `PROCESSED_ASSET_LIBRARY_ROOT`: Path to the root output directory of the Asset Processor Tool (same as in `create_nodegroups.py`). This is used to find the `metadata.json` files and reference images for previews.
* `PBRSET_ASSET_LIBRARY_NAME`: The name of the Blender Asset Library (configured in Blender Preferences) that contains the PBRSET node groups created by `create_nodegroups.py`.
* `TEMPLATE_MATERIAL_NAME`: Name of the required template material in the Blender file (e.g., "Template_PBRMaterial").
* `PLACEHOLDER_NODE_LABEL`: Label of the placeholder Group node within the template material's node tree where the PBRSET node group will be linked (e.g., "PBRSET_PLACEHOLDER").
* `MATERIAL_NAME_PREFIX`: Prefix for the created materials (e.g., "Mat_").
* `PBRSET_GROUP_PREFIX`: Prefix used for the PBRSET node groups created by `create_nodegroups.py` (e.g., "PBRSET_").
* `REFERENCE_MAP_TYPES`: List of map types to look for to find a reference image for the material preview (e.g., `["COL", "COL-1"]`).
* `REFERENCE_RESOLUTION_ORDER`: Preferred resolution order for the reference image (e.g., `["1K", "512", "2K", "4K"]`).
* `IMAGE_FILENAME_PATTERN`: Assumed filename pattern for processed images (same as in `create_nodegroups.py`).
* `FALLBACK_IMAGE_EXTENSIONS`: Fallback extensions for finding image files (same as in `create_nodegroups.py`).
* `VIEWPORT_COLOR_MAP_TYPES`: List of map types to check in metadata's `image_stats_1k` for viewport diffuse color.
* `VIEWPORT_ROUGHNESS_MAP_TYPES`: List of map types to check in metadata's `image_stats_1k` for viewport roughness.
* `VIEWPORT_METALLIC_MAP_TYPES`: List of map types to check in metadata's `image_stats_1k` for viewport metallic.
## 4. Helper Functions
The script will include the following helper functions:
* `find_nodes_by_label(node_tree, label, node_type=None)`: Reusable from `create_nodegroups.py` to find nodes in a node tree.
* `add_tag_if_new(asset_data, tag_name)`: Reusable from `create_nodegroups.py` to add asset tags.
* `reconstruct_image_path_with_fallback(...)`: Reusable from `create_nodegroups.py` to find image paths (needed for setting the custom preview).
* `get_stat_value(stats_dict, map_type_list, stat_key)`: A helper function to safely retrieve a specific statistic from the `image_stats_1k` dictionary.
## 5. Main Processing Logic (`process_library_for_materials`)
The main function will perform the following steps:
* **Pre-run Checks:**
* Verify `PROCESSED_ASSET_LIBRARY_ROOT` exists and is a directory.
* Verify the `PBRSET_ASSET_LIBRARY_NAME` exists in Blender's user preferences (`bpy.context.preferences.filepaths.asset_libraries`).
* Verify the `TEMPLATE_MATERIAL_NAME` material exists and uses nodes.
* Verify the `PLACEHOLDER_NODE_LABEL` Group node exists in the template material's node tree.
* **Scan for Metadata:**
* Iterate through supplier directories within `PROCESSED_ASSET_LIBRARY_ROOT`.
* Iterate through asset directories within each supplier directory.
* Identify `metadata.json` files.
* **Process Each Metadata File:**
* Load the `metadata.json` file.
* Extract `asset_name`, `supplier_name`, `archetype`, `processed_map_resolutions`, `merged_map_resolutions`, `map_details`, and `image_stats_1k`.
* Determine the expected PBRSET node group name: `f"{PBRSET_GROUP_PREFIX}{asset_name}"`.
* Determine the target material name: `f"{MATERIAL_NAME_PREFIX}{asset_name}"`.
* **Find or Create Material:**
* Check if a material with the `target_material_name` already exists in `bpy.data.materials`.
* If it exists, log a message indicating the asset is being skipped and move to the next metadata file.
* If it doesn't exist, copy the `TEMPLATE_MATERIAL_NAME` material and rename the copy to `target_material_name` (create mode). Handle potential copy failures.
* **Find Placeholder Node:**
* Find the node with `PLACEHOLDER_NODE_LABEL` in the target material's node tree using `find_nodes_by_label`. Handle cases where the node is not found or is not a Group node.
* **Find and Link PBRSET Node Group from Asset Library:**
* Get the path to the `.blend` file associated with the `PBRSET_ASSET_LIBRARY_NAME` from user preferences.
* Use `bpy.data.libraries.load(filepath, link=True)` to link the node group with `target_pbrset_group_name` from the external `.blend` file into the current file. Handle cases where the library or the node group is not found.
* Once linked, get the reference to the newly linked node group in `bpy.data.node_groups`.
* **Link Linked Node Group to Placeholder:**
* If both the placeholder node and the *newly linked* PBRSET node group are found, assign the linked node group to the `node_tree` property of the placeholder node.
* **Mark Material as Asset:**
* If the material is new or not already marked, call `material.asset_mark()`.
* **Copy Asset Tags:**
* If both the material and the *linked* PBRSET node group have asset data, copy tags (supplier, archetype) from the node group to the material using `add_tag_if_new`.
* **Set Custom Preview:**
* Find a suitable reference image path (e.g., lowest resolution COL map) using `reconstruct_image_path_with_fallback` and the `REFERENCE_MAP_TYPES` and `REFERENCE_RESOLUTION_ORDER` configurations.
* If a reference image path is found, use `bpy.ops.ed.lib_id_load_custom_preview` to set the custom preview for the material. This operation requires overriding the context.
* **Set Viewport Properties (using metadata stats):**
* Check if `image_stats_1k` is present and valid in the metadata.
* **Diffuse Color:** Use the `get_stat_value` helper to get the 'mean' stat for map types in `VIEWPORT_COLOR_MAP_TYPES`. If found and is a valid color list `[R, G, B]`, set `material.diffuse_color` to this value.
* **Roughness:** Use the `get_stat_value` helper to get the 'mean' stat for map types in `VIEWPORT_ROUGHNESS_MAP_TYPES`. If found, get the first value (for grayscale). Check if stats for `VIEWPORT_METALLIC_MAP_TYPES` exist. If metallic stats are *not* found, invert the roughness value (`1.0 - value`) before assigning it to `material.roughness`. Clamp the final value between 0.0 and 1.0.
* **Metallic:** Use the `get_stat_value` helper to get the 'mean' stat for map types in `VIEWPORT_METALLIC_MAP_TYPES`. If found, get the first value (for grayscale) and assign it to `material.metallic`. If metallic stats are *not* found, set `material.metallic` to 0.0.
* **Error Handling and Reporting:**
* Include `try...except` blocks to catch errors during file reading, JSON parsing, Blender operations, linking, etc.
* Print informative messages about progress, creation/update status, and errors.
* **Summary Report:**
* Print a summary of how many metadata files were processed, materials created/updated, node groups linked, errors encountered, and assets skipped.
## 6. Process Flow Diagram (Updated)
```mermaid
graph TD
A[Start Script] --> B{Pre-run Checks Pass?};
B -- No --> C[Abort Script];
B -- Yes --> D[Scan Processed Asset Root];
D --> E{Found metadata.json?};
E -- No --> F[Finish Script (No Assets)];
E -- Yes --> G[Loop through metadata.json files];
G --> H[Read metadata.json];
H --> I{Metadata Valid?};
I -- No --> J[Log Error, Skip Asset];
I -- Yes --> K[Extract Asset Info & Stats];
K --> L[Find or Create Material];
L --> M{Material Exists?};
M -- Yes --> N[Log Skip, Continue Loop];
M -- No --> O[Find Placeholder Node in Material];
O --> P[Find PBRSET NG in Library & Link];
P --> Q{Linking Successful?};
Q -- No --> R[Log Error, Skip Asset];
Q -- Yes --> S[Link Linked NG to Placeholder];
S --> T[Mark Material as Asset];
T --> U[Copy Asset Tags];
U --> V[Find Reference Image Path for Preview];
V --> W{Reference Image Found?};
W -- Yes --> X[Set Custom Material Preview];
W -- No --> Y[Log Warning (No Preview)];
X --> Z[Set Viewport Properties from Stats];
Y --> Z;
Z --> AA[Increment Counters];
AA --> G;
G --> AB[Print Summary Report];
AB --> AC[End Script];
J --> G;
N --> G;
R --> G;

View File

@ -1,103 +0,0 @@
# Blender Addon Plan: Material Merger
**Version:** 1.1 (Includes Extensibility Consideration)
**1. Goal:**
Create a standalone Blender addon that allows users to select two existing materials (generated by the Asset Processor Tool, or previously merged by this addon) and merge them into a new material. The merge should preserve their individual node structures (including custom tweaks) and combine their final outputs using a dedicated `MaterialMerge` node group.
**2. Core Functionality (Approach 2 - Node Copying):**
* **Trigger:** User selects two materials in Blender and invokes an operator (e.g., via a button in the Shader Editor's UI panel).
* **New Material Creation:** The addon creates a new Blender material, named appropriately (e.g., `MAT_Merged_<NameA>_<NameB>`).
* **Node Copying:**
* For *each* selected source material:
* Iterate through its node tree.
* Copy all nodes *except* the `Material Output` node into the *new* material's node tree, attempting to preserve relative layout and offsetting subsequent copies.
* **Identify Final Outputs:** Determine the node providing the final BSDF shader output and the node providing the final Displacement output *before* the original `Material Output` node.
* In a base material (from Asset Processor), these are expected to be the `PBR_BSDF` node group (BSDF output) and the `PBR_Handler` node group (Displacement output).
* In an already-merged material, these will be the outputs of its top-level `MaterialMerge` node group.
* Store references to these final output nodes and their relevant sockets.
* **MaterialMerge Node:**
* **Link/Append** the `MaterialMerge` node group into the new material's node tree.
* **Assumption:** This node group exists in `blender_files/utility_nodegroups.blend` relative to the addon's location.
* **Assumption:** Socket names are `Shader A`, `Shader B`, `Displacement A`, `Displacement B` (inputs) and `BSDF`, `Displacement` (outputs).
* **Connections:**
* Connect the identified final BSDF output of the *first* source material's copied structure to the `MaterialMerge` node's `Shader A` input.
* Connect the identified final Displacement output of the *first* source material's copied structure to the `MaterialMerge` node's `Displacement A` input.
* Connect the identified final BSDF output of the *second* source material's copied structure to the `MaterialMerge` node's `Shader B` input.
* Connect the identified final Displacement output of the *second* source material's copied structure to the `MaterialMerge` node's `Displacement B` input.
* Connect the `MaterialMerge` node's `BSDF` output to the new material's `Material Output` node's `Surface` input.
* Connect the `MaterialMerge` node's `Displacement` output to the new material's `Material Output` node's `Displacement` input.
* **Layout:** Optionally, attempt a basic auto-layout (`node_tree.nodes.update()`) or arrange the key nodes logically.
**3. User Interface (UI):**
* A simple panel in the Blender Shader Editor (Properties region - 'N' panel).
* Two dropdowns or search fields allowing the user to select existing materials from the current `.blend` file.
* A button labeled "Merge Selected Materials".
* Status messages/feedback (e.g., "Merged material created: [Name]", "Error: Could not find required nodes in [Material Name]").
**4. Addon Structure (Python):**
* `__init__.py`: Registers the addon, panel, and operator classes.
* `operator.py`: Contains the `OT_MergeMaterials` operator class implementing the core logic.
* `panel.py`: Contains the `PT_MaterialMergePanel` class defining the UI layout.
* (Optional) `utils.py`: Helper functions for node finding, copying, linking, identifying final outputs, etc.
**5. Error Handling:**
* Check if two valid materials are selected.
* Verify that the selected materials have node trees.
* Handle cases where the expected final BSDF/Displacement output nodes cannot be reliably identified in one or both source materials.
* Handle potential errors during node copying.
* Handle errors if the `utility_nodegroups.blend` file or the `MaterialMerge` node group within it cannot be found/linked.
**6. Assumptions to Verify (Based on User Feedback):**
* **Node Identification:**
* Base Material Handler: Node named `PBR_Handler`.
* Base Material BSDF: Node named `PBR_BSDF`.
* Merged Material Outputs: The `BSDF` and `Displacement` outputs of the top-level `MaterialMerge` node.
* **`MaterialMerge` Node:**
* Location: `blender_files/utility_nodegroups.blend` (relative path).
* Input Sockets: `Shader A`, `Shader B`, `Displacement A`, `Displacement B`.
* Output Sockets: `BSDF`, `Displacement`.
**7. Future Extensibility - Recursive Merging:**
* The core merging logic (copying nodes, identifying final outputs, connecting to a new `MaterialMerge` node) is designed to inherently support selecting an already-merged material as an input without requiring separate code paths initially. The identification of final BSDF/Displacement outputs needs to correctly handle both base materials and merged materials (checking for `PBR_BSDF`/`PBR_Handler` or the outputs of an existing `MaterialMerge` node).
**8. Mermaid Diagram of Node Flow:**
```mermaid
graph TD
subgraph New Merged Material
subgraph Copied from Source A (Mat_A or Merge_A)
%% Nodes representing the structure of Source A
Structure_A[...]
Final_BSDF_A[Final BSDF Output A]
Final_Disp_A[Final Displacement Output A]
Structure_A --> Final_BSDF_A
Structure_A --> Final_Disp_A
end
subgraph Copied from Source B (Mat_B or Merge_B)
%% Nodes representing the structure of Source B
Structure_B[...]
Final_BSDF_B[Final BSDF Output B]
Final_Disp_B[Final Displacement Output B]
Structure_B --> Final_BSDF_B
Structure_B --> Final_Disp_B
end
Merge[MaterialMerge]
Output[Material Output]
Final_BSDF_A -- BSDF --> Merge -- Shader A --> Merge
Final_Disp_A -- Displacement --> Merge -- Displacement A --> Merge
Final_BSDF_B -- BSDF --> Merge -- Shader B --> Merge
Final_Disp_B -- Displacement --> Merge -- Displacement B --> Merge
Merge -- BSDF --> Output -- Surface --> Output
Merge -- Displacement --> Output -- Displacement --> Output
end

View File

@ -1,124 +0,0 @@
# Architectural Plan: Data Flow Refinement (v3)
**Date:** 2025-04-30
**Author:** Roo (Architect Mode)
**Status:** Approved
## 1. Goal
Refine the application's data flow to establish the GUI as the single source of truth for processing rules. This involves moving prediction/preset logic upstream from the backend processor and ensuring the backend receives a *complete* `SourceRule` object for processing, thereby simplifying the processor itself. This version of the plan involves creating a new processing module (`processing_engine.py`) instead of refactoring the existing `asset_processor.py`.
## 2. Proposed Data Flow
The refined data flow centralizes rule generation and modification within the GUI components before passing a complete, explicit rule set to the backend. The `SourceRule` object structure serves as a consistent data contract throughout the pipeline.
```mermaid
sequenceDiagram
participant User
participant GUI_MainWindow as GUI (main_window.py)
participant GUI_Predictor as Predictor (prediction_handler.py)
participant GUI_UnifiedView as Unified View (unified_view_model.py)
participant Main as main.py
participant ProcessingEngine as New Backend (processing_engine.py)
participant Config as config.py
User->>+GUI_MainWindow: Selects Input & Preset
Note over GUI_MainWindow: Scans input, gets file list
GUI_MainWindow->>+GUI_Predictor: Request Prediction(File List, Preset Name, Input ID)
GUI_Predictor->>+Config: Load Preset Rules & Canonical Types
Config-->>-GUI_Predictor: Return Rules & Types
%% Prediction Logic (Internal to Predictor)
Note over GUI_Predictor: Perform file analysis (based on list), apply preset rules, generate COMPLETE SourceRule hierarchy (only overridable fields populated)
GUI_Predictor-->>-GUI_MainWindow: Return List[SourceRule] (Initial Rules)
GUI_MainWindow->>+GUI_UnifiedView: Populate View(List[SourceRule])
GUI_UnifiedView->>+Config: Read Allowed Asset/File Types for Dropdowns
Config-->>-GUI_UnifiedView: Return Allowed Types
Note over GUI_UnifiedView: Display rules, allow user edits
User->>GUI_UnifiedView: Modifies Rules (Overrides)
GUI_UnifiedView-->>GUI_MainWindow: Update SourceRule Objects in Memory
User->>+GUI_MainWindow: Trigger Processing
GUI_MainWindow->>+Main: Send Final List[SourceRule]
Main->>+ProcessingEngine: Queue Task(SourceRule) for each input
Note over ProcessingEngine: Execute processing based *solely* on the provided SourceRule and static config. No internal prediction/fallback.
ProcessingEngine-->>-Main: Processing Result
Main-->>-GUI_MainWindow: Update Status
GUI_MainWindow-->>User: Show Result/Status
```
## 3. Module-Specific Changes
* **`config.py`:**
* **Add Canonical Lists:** Introduce `ALLOWED_ASSET_TYPES` (e.g., `["Surface", "Model", "Decal", "Atlas", "UtilityMap"]`) and `ALLOWED_FILE_TYPES` (e.g., `["MAP_COL", "MAP_NRM", ..., "MODEL", "EXTRA", "FILE_IGNORE"]`).
* **Purpose:** Single source of truth for GUI dropdowns and validation.
* **Existing Config:** Retains static definitions like `IMAGE_RESOLUTIONS`, `MAP_MERGE_RULES`, `JPG_QUALITY`, etc.
* **`rule_structure.py`:**
* **Remove Enums:** Remove `AssetType` and `ItemType` Enums. Update `AssetRule.asset_type`, `FileRule.item_type_override`, etc., to use string types validated against `config.py` lists.
* **Field Retention:** Keep `FileRule.resolution_override` and `FileRule.channel_merge_instructions` fields for structural consistency, but they will not be populated or used for overrides in this flow.
* **`gui/prediction_handler.py` (or equivalent):**
* **Enhance Prediction Logic:** Modify `run_prediction` method.
* **Input:** Accept `input_source_identifier` (string), `file_list` (List[str] of relative paths), and `preset_name` (string) when called from GUI.
* **Load Config:** Read `ALLOWED_ASSET_TYPES`, `ALLOWED_FILE_TYPES`, and preset rules.
* **Relocate Classification:** Integrate classification/naming logic (previously in `asset_processor.py`) to operate on the provided `file_list`.
* **Generate Complete Rules:** Populate `SourceRule`, `AssetRule`, and `FileRule` objects.
* Set initial values only for *overridable* fields (e.g., `asset_type`, `item_type_override`, `target_asset_name_override`, `supplier_identifier`, `output_format_override`) based on preset rules/defaults.
* Explicitly **do not** populate static config fields like `FileRule.resolution_override` or `FileRule.channel_merge_instructions`.
* **Temporary Files (If needed for non-GUI):** May need logic later to handle direct path inputs (CLI/Docker) involving temporary extraction/cleanup, but the primary GUI flow uses the provided list.
* **Output:** Emit `rule_hierarchy_ready` signal with the `List[SourceRule]`.
* **NEW: `processing_engine.py` (New Module):**
* **Purpose:** Contains a new class (e.g., `ProcessingEngine`) for executing the processing pipeline based solely on a complete `SourceRule` and static configuration. Replaces `asset_processor.py` in the main workflow.
* **Initialization (`__init__`):** Takes the static `Configuration` object as input.
* **Core Method (`process`):** Accepts a single, complete `SourceRule` object. Orchestrates processing steps (workspace setup, extraction, map processing, merging, metadata, organization, cleanup).
* **Helper Methods (Refactored Logic):** Implement simplified versions of processing helpers (e.g., `_process_individual_maps`, `_merge_maps_from_source`, `_generate_metadata_file`, `_organize_output_files`, `_load_and_transform_source`, `_save_image`).
* Retrieve *overridable* parameters directly from the input `SourceRule`.
* Retrieve *static configuration* parameters (resolutions, merge rules) **only** from the stored `Configuration` object.
* Contain **no** prediction, classification, or fallback logic.
* **Dependencies:** `rule_structure.py`, `configuration.py`, `config.py`, cv2, numpy, etc.
* **`asset_processor.py` (Old Module):**
* **Status:** Remains in the codebase **unchanged** for reference.
* **Usage:** No longer called by `main.py` or GUI for standard processing.
* **`gui/main_window.py`:**
* **Scan Input:** Perform initial directory/archive scan to get the file list for each directory/archieve.
* **Initiate Prediction:** Call `PredictionHandler` with the file list, preset, and input identifier.
* **Receive/Pass Rules:** Handle `rule_hierarchy_ready`, pass `SourceRule` list to `UnifiedViewModel`.
* **Send Final Rules:** Send the final `SourceRule` list to `main.py`.
* **`gui/unified_view_model.py` / `gui/delegates.py`:**
* **Load Dropdown Options:** Source dropdowns (`AssetType`, `ItemType`) from `config.py`.
* **Data Handling:** Read/write user modifications to overridable fields in `SourceRule` objects.
* **No UI for Static Config:** Do not provide UI editing for resolution or merge instructions.
* **`main.py`:**
* **Receive Rule List:** Accept `List[SourceRule]` from GUI.
* **Instantiate New Engine:** Import and instantiate the new `ProcessingEngine` from `processing_engine.py`.
* **Queue Tasks:** Iterate `SourceRule` list, queue tasks.
* **Call New Engine:** Pass the individual `SourceRule` object to `ProcessingEngine.process` for each task.
## 4. Rationale / Benefits
* **Single Source of Truth:** GUI holds the final `SourceRule` objects.
* **Backend Simplification:** New `processing_engine.py` is focused solely on execution based on explicit rules and static config.
* **Decoupling:** Reduced coupling between GUI/prediction and backend processing.
* **Clarity:** Clearer data flow and component responsibilities.
* **Maintainability:** Easier maintenance and debugging.
* **Centralized Definitions:** `config.py` centralizes allowed types.
* **Preserves Reference:** Keeps `asset_processor.py` available for comparison.
* **Consistent Data Contract:** `SourceRule` structure is consistent from predictor output to engine input, enabling potential GUI bypass.
## 5. Potential Issues / Considerations
* **`PredictionHandler` Complexity:** Will require careful implementation of classification/rule population logic.
* **Performance:** Prediction logic needs to remain performant (threading).
* **Rule Structure Completeness:** Ensure `SourceRule` dataclasses hold all necessary *overridable* fields.
* **Preset Loading:** Robust preset loading/interpretation needed in `PredictionHandler`.
* **Static Config Loading:** Ensure the new `ProcessingEngine` correctly loads and uses the static `Configuration` object.
## 6. Documentation
This document (`ProjectNotes/Data_Flow_Refinement_Plan.md`) serves as the architectural plan. Relevant sections of the Developer Guide will need updating upon implementation.

View File

@ -1,118 +0,0 @@
# Data Interface: GUI Preview Edits to Processing Handler
## 1. Purpose
This document defines the data structures and interface used to pass user edits made in the GUI's file preview table (specifically changes to 'Status' and 'Predicted Asset' output name) to the backend `ProcessingHandler`. It also incorporates a structure for future asset-level properties.
## 2. Data Structures
Two primary data structures are used:
### 2.1. File Data (`file_list`)
* **Type:** `list[dict]`
* **Description:** A flat list where each dictionary represents a single file identified during the prediction phase. This list is modified by the GUI to reflect user edits to file status and output names.
* **Dictionary Keys (per file):**
* `original_path` (`str`): The full path to the source file. (Read-only by GUI)
* `predicted_asset_name` (`str | None`): The name of the asset group the file belongs to, derived from input. (Read-only by GUI)
* `predicted_output_name` (`str | None`): The backend's predicted final output filename. **EDITABLE** by the user in the GUI ('Predicted Asset' column).
* `status` (`str`): The backend's predicted status (e.g., 'Mapped', 'Ignored'). **EDITABLE** by the user in the GUI ('Status' column).
* `details` (`str | None`): Additional information or error messages. (Read-only by GUI, potentially updated by model validation).
* `source_asset` (`str`): An identifier for the source asset group (e.g., input folder/zip name). (Read-only by GUI)
### 2.2. Asset Properties (`asset_properties`)
* **Type:** `dict[str, dict]`
* **Description:** A dictionary mapping the `source_asset` identifier (string key) to a dictionary of asset-level properties determined by the backend prediction/preset. This structure is initially read-only in the GUI but designed for future expansion (e.g., editing asset category).
* **Asset Properties Dictionary Keys (Example):**
* `asset_category` (`str`): The determined category (e.g., 'surface', 'model').
* `asset_tags` (`list[str]`): Any relevant tags associated with the asset.
* *(Other future asset-level properties can be added here)*
## 3. Data Flow & Interface
```mermaid
graph LR
subgraph Backend
A[Prediction Logic] -- Generates --> B(file_list);
A -- Generates --> C(asset_properties);
end
subgraph GUI Components
D[PredictionHandler] -- prediction_results_ready(file_list, asset_properties) --> E(PreviewTableModel);
E -- Stores & Allows Edits --> F(Internal file_list);
E -- Stores --> G(Internal asset_properties);
H[MainWindow] -- Retrieves --> F;
H -- Retrieves --> G;
H -- Passes (file_list, asset_properties) --> I[ProcessingHandler];
end
subgraph Backend
I -- Uses Edited --> F;
I -- Uses Read-Only --> G;
end
style A fill:#lightblue,stroke:#333,stroke-width:1px
style B fill:#lightgreen,stroke:#333,stroke-width:1px
style C fill:#lightyellow,stroke:#333,stroke-width:1px
style D fill:#f9f,stroke:#333,stroke-width:2px
style E fill:#ccf,stroke:#333,stroke-width:2px
style F fill:#lightgreen,stroke:#333,stroke-width:1px
style G fill:#lightyellow,stroke:#333,stroke-width:1px
style H fill:#f9f,stroke:#333,stroke-width:2px
style I fill:#lightblue,stroke:#333,stroke-width:2px
```
1. **Prediction:** The `PredictionHandler` generates both the `file_list` and `asset_properties`.
2. **Signal:** It emits a signal (e.g., `prediction_results_ready`) containing both structures, likely as a tuple `(file_list, asset_properties)`.
3. **Table Model:** The `PreviewTableModel` receives the tuple. It stores `asset_properties` (read-only for now). It stores `file_list` and allows user edits to the `status` and `predicted_output_name` values within this list.
4. **Processing Trigger:** When the user initiates processing, the `MainWindow` retrieves the (potentially modified) `file_list` and the (unmodified) `asset_properties` from the `PreviewTableModel`.
5. **Processing Execution:** The `MainWindow` passes both structures to the `ProcessingHandler`.
6. **Handler Logic:** The `ProcessingHandler` iterates through the `file_list`. For each file, it uses the potentially edited `status` and `predicted_output_name`. If asset-level information is needed, it uses the file's `source_asset` key to look up the data in the `asset_properties` dictionary.
## 4. Example Data Passed to `ProcessingHandler`
* **`file_list` (Example):**
```python
[
{
'original_path': 'C:/Path/To/AssetA/AssetA_Diffuse.png',
'predicted_asset_name': 'AssetA',
'predicted_output_name': 'T_AssetA_BC.tga',
'status': 'Ignored', # <-- User Edit
'details': 'User override: Ignored',
'source_asset': 'AssetA'
},
{
'original_path': 'C:/Path/To/AssetA/AssetA_Normal.png',
'predicted_asset_name': 'AssetA',
'predicted_output_name': 'T_AssetA_Normals_DX.tga', # <-- User Edit
'status': 'Mapped',
'details': None,
'source_asset': 'AssetA'
},
# ... other files
]
```
* **`asset_properties` (Example):**
```python
{
'AssetA': {
'asset_category': 'surface',
'asset_tags': ['wood', 'painted']
},
'AssetB': {
'asset_category': 'model',
'asset_tags': ['metal', 'sci-fi']
}
# ... other assets
}
```
## 5. Implications
* **`PredictionHandler`:** Needs modification to generate and emit both `file_list` and `asset_properties`. Signal signature changes.
* **`PreviewTableModel`:** Needs modification to receive, store, and provide both structures. Must implement editing capabilities (`flags`, `setData`) for the relevant columns using the `file_list`. Needs methods like `get_edited_data()` returning both structures.
* **`MainWindow`:** Needs modification to retrieve both structures from the table model and pass them to the `ProcessingHandler`.
* **`ProcessingHandler`:** Needs modification to accept both structures in its processing method signature. Must update logic to use the edited `status` and `predicted_output_name` from `file_list` and look up data in `asset_properties` using `source_asset` when needed.

View File

@ -1,37 +0,0 @@
# FEAT-003: Selective Nodegroup Generation and Category Tagging - Implementation Plan
**Objective:** Modify `blenderscripts/create_nodegroups.py` to read the asset category from `metadata.json`, conditionally create nodegroups for "Surface" and "Decal" assets, and add the category as a tag to the Blender asset.
**Plan:**
1. **Modify `blenderscripts/create_nodegroups.py`:**
* Locate the main loop in `blenderscripts/create_nodegroups.py` that iterates through the processed assets.
* Inside this loop, for each asset directory, construct the path to the `metadata.json` file.
* Read the `metadata.json` file using Python's `json` module.
* Extract the `category` value from the parsed JSON data.
* Implement a conditional check: If the extracted `category` is *not* "Surface" and *not* "Decal", skip the existing nodegroup creation logic for this asset and proceed to the tagging step.
* If the `category` *is* "Surface" or "Decal", execute the existing nodegroup creation logic.
* After the conditional nodegroup creation (or skipping), use the Blender Python API (`bpy`) to find the corresponding Blender asset (likely the created node group, if applicable, or potentially the asset representation even if the nodegroup was skipped).
* Add the extracted `category` string as a tag to the found Blender asset.
2. **Testing:**
* Prepare a test set of processed assets that includes examples of "Surface", "Decal", and "Asset" categories, each with a corresponding `metadata.json` file.
* Run the modified `create_nodegroups.py` script within Blender, pointing it to the test asset library root.
* Verify in Blender that:
* Node groups were created only for the "Surface" and "Decal" assets.
* No node groups were created for "Asset" category assets.
* All processed assets (Surface, Decal, and Asset) have a tag corresponding to their category ("Surface", "Decal", or "Asset") in the Blender asset browser.
```mermaid
graph TD
A[Start Script] --> B{Iterate Assets};
B --> C[Read metadata.json];
C --> D[Get Category];
D --> E{Category in ["Surface", "Decal"]?};
E -- Yes --> F[Create Nodegroup];
E -- No --> G[Skip Nodegroup];
F --> H[Find Blender Asset];
G --> H[Find Blender Asset];
H --> I[Add Category Tag];
I --> B;
B -- No More Assets --> J[End Script];

View File

@ -1,49 +0,0 @@
# Plan for Adding .rar and .7z Support
**Goal:** Extend the Asset Processor Tool to accept `.rar` and `.7z` files as input sources, in addition to the currently supported `.zip` files and folders.
**Plan:**
1. **Add Required Libraries:**
* Update the `requirements.txt` file to include `py7zr` and `rarfile` as dependencies. This will ensure these libraries are installed when setting up the project.
2. **Modify Input Extraction Logic:**
* Locate the `_extract_input` method within the `AssetProcessor` class in `asset_processor.py`.
* Modify this method to check the file extension of the input source.
* If the extension is `.zip`, retain the existing extraction logic using Python's built-in `zipfile` module.
* If the extension is `.rar`, implement extraction using the `rarfile` library.
* If the extension is `.7z`, implement extraction using the `py7zr` library.
* Include error handling for cases where the archive might be corrupted, encrypted (since we are not implementing password support at this stage, these should likely be skipped or logged as errors), or uses an unsupported compression method. Log appropriate warnings or errors in such cases.
* If the input is a directory, retain the existing logic to copy its contents to the temporary workspace.
3. **Update CLI and Monitor Input Handling:**
* Review `main.py` (CLI entry point) and `monitor.py` (Directory Monitor).
* Ensure that the argument parsing in `main.py` can accept `.rar` and `.7z` file paths as valid inputs.
* In `monitor.py`, modify the `ZipHandler` (or create a new handler) to watch for `.rar` and `.7z` file creation events in the watched directory, in addition to `.zip` files. The logic for triggering processing via `main.run_processing` should then be extended to handle these new file types.
4. **Update Documentation:**
* Edit `Documentation/00_Overview.md` to explicitly mention `.rar` and `.7z` as supported input formats in the overview section.
* Update `Documentation/01_User_Guide/02_Features.md` to list `.rar` and `.7z` alongside `.zip` and folders in the features list.
* Modify `Documentation/01_User_Guide/03_Installation.md` to include instructions for installing the new `py7zr` and `rarfile` dependencies (likely via `pip install -r requirements.txt`).
* Revise `Documentation/02_Developer_Guide/05_Processing_Pipeline.md` to accurately describe the updated `_extract_input` method, detailing how `.zip`, `.rar`, `.7z`, and directories are handled.
5. **Testing:**
* Prepare sample `.rar` and `.7z` files (including nested directories and various file types) to test the extraction logic thoroughly.
* Test processing of these new archive types via both the CLI and the Directory Monitor.
* Verify that the subsequent processing steps (classification, map processing, metadata generation, etc.) work correctly with files extracted from `.rar` and `.7z` archives.
Here is a simplified flow diagram illustrating the updated input handling:
```mermaid
graph TD
A[Input Source] --> B{Is it a file or directory?};
B -- Directory --> C[Copy Contents to Workspace];
B -- File --> D{What is the file extension?};
D -- .zip --> E[Extract using zipfile];
D -- .rar --> F[Extract using rarfile];
D -- .7z --> G[Extract using py7zr];
E --> H[Temporary Workspace];
F --> H;
G --> H;
C --> H;
H --> I[Processing Pipeline Starts];

View File

@ -1,51 +0,0 @@
# Plan: Implement "Force Lossless" Format for Specific Map Types
**Goal:** Modify the asset processor to ensure specific map types ("NRM", "DISP") are always saved in a lossless format (PNG or EXR based on bit depth), overriding the JPG threshold and input format rules. This rule should apply to both individually processed maps and merged maps.
**Steps:**
1. **Add Configuration Setting (`config.py`):**
* Introduce a new list named `FORCE_LOSSLESS_MAP_TYPES` in `config.py`.
* Populate this list: `FORCE_LOSSLESS_MAP_TYPES = ["NRM", "DISP"]`.
2. **Expose Setting in `Configuration` Class (`configuration.py`):**
* Add a default value for `FORCE_LOSSLESS_MAP_TYPES` in the `_load_core_config` method's `default_core_settings` dictionary.
* Add a new property to the `Configuration` class to access this list:
```python
@property
def force_lossless_map_types(self) -> list:
"""Gets the list of map types that must always be saved losslessly."""
return self._core_settings.get('FORCE_LOSSLESS_MAP_TYPES', [])
```
3. **Modify `_process_maps` Method (`asset_processor.py`):**
* Locate the section determining the output format (around line 805).
* **Before** the `if output_bit_depth == 8 and target_dim >= threshold:` check (line 811), insert the new logic:
* Check if the current `map_type` is in `self.config.force_lossless_map_types`.
* If yes, determine the appropriate lossless format (`png` or configured 16-bit format like `exr`) based on `output_bit_depth`, set `output_format`, `output_ext`, `save_params`, and `needs_float16` accordingly, and skip the subsequent `elif` / `else` blocks for format determination.
* Use an `elif` for the existing JPG threshold check and the final `else` for the rule-based logic, ensuring they only run if `force_lossless` is false.
4. **Modify `_merge_maps` Method (`asset_processor.py`):**
* Locate the section determining the output format for the merged map (around line 1151).
* **Before** the `if output_bit_depth == 8 and target_dim >= threshold:` check (line 1158), insert similar logic as in step 3:
* Check if the `output_map_type` (the type of the *merged* map) is in `self.config.force_lossless_map_types`.
* If yes, determine the appropriate lossless format based on the merged map's `output_bit_depth`, set `output_format`, `output_ext`, `save_params`, and `needs_float16`, and skip the subsequent `elif` / `else` blocks.
* Use `elif` and `else` for the existing threshold and hierarchy logic.
**Process Flow Diagram:**
```mermaid
graph TD
subgraph Format Determination (per resolution, _process_maps & _merge_maps)
A[Start] --> B(Get map_type / output_map_type);
B --> C{Determine output_bit_depth};
C --> D{Is map_type in FORCE_LOSSLESS_MAP_TYPES?};
D -- Yes --> E[Set format = Lossless (PNG/EXR based on bit_depth)];
D -- No --> F{Is output_bit_depth == 8 AND target_dim >= threshold?};
F -- Yes --> G[Set format = JPG];
F -- No --> H[Set format based on input/hierarchy/rules];
G --> I[Set Save Params];
H --> I;
E --> I;
I --> J[Save Image];
end

View File

@ -1,17 +0,0 @@
Here is a summary of our session focused on adding OpenEXR support:
Goal:
Enable the Asset Processor Tool to reliably write .exr files for 16-bit maps, compatible with Windows executables and Docker.
Approach:
We decided to use the dedicated openexr Python library for writing EXR files, rather than compiling a custom OpenCV build. The plan involved modifying asset_processor.py to use this library, updating the Dockerfile with necessary system libraries, and outlining steps for PyInstaller on Windows.
Issues & Fixes:
Initial Changes: Modified asset_processor.py and Dockerfile.
NameError: name 'log' is not defined: Fixed by moving the logger initialization earlier in asset_processor.py.
AttributeError: module 'Imath' has no attribute 'Header' & NameError: name 'fallback_fmt' is not defined: Attempted fixes by changing Imath.Header to OpenEXR.Header and correcting one instance of fallback_fmt.
TypeError: __init__(): incompatible constructor arguments... Invoked with: HALF & NameError: name 'fallback_fmt' is not defined (again): Corrected the OpenEXR.Channel constructor call and fixed the remaining fallback_fmt typo.
concurrent.futures.process.BrokenProcessPool: The script crashed when attempting the EXR save, likely due to an internal OpenEXR library error or a Windows dependency issue.
Next Steps (when resuming):
Investigate the BrokenProcessPool error, possibly by testing EXR saving with a simple array or verifying Windows dependencies for the OpenEXR library.

View File

@ -1,52 +0,0 @@
# GUI Blender Integration Plan
## Goal
Add a checkbox and input fields to the GUI (`gui/main_window.py`) to enable/disable Blender script execution and specify the `.blend` file paths, defaulting to `config.py` values. Integrate this control into the processing logic (`gui/processing_handler.py`).
## Proposed Plan
1. **Modify `gui/main_window.py`:**
* Add a `QCheckBox` (e.g., `self.blender_integration_checkbox`) to the processing panel layout.
* Add two pairs of `QLineEdit` widgets and `QPushButton` browse buttons for the nodegroup `.blend` path (`self.nodegroup_blend_path_input`, `self.browse_nodegroup_blend_button`) and the materials `.blend` path (`self.materials_blend_path_input`, `self.browse_materials_blend_button`).
* Initialize the text of the `QLineEdit` widgets by reading the `DEFAULT_NODEGROUP_BLEND_PATH` and `DEFAULT_MATERIALS_BLEND_PATH` values from `config.py` when the GUI starts.
* Connect signals from the browse buttons to new methods that open a `QFileDialog` to select `.blend` files and update the corresponding input fields.
* Modify the slot connected to the "Start Processing" button to:
* Read the checked state of `self.blender_integration_checkbox`.
* Read the text from `self.nodegroup_blend_path_input` and `self.materials_blend_path_input`.
* Pass these three pieces of information (checkbox state, nodegroup path, materials path) to the `ProcessingHandler` when initiating the processing task.
2. **Modify `gui/processing_handler.py`:**
* Add parameters to the method that starts the processing (likely `start_processing`) to accept the Blender integration flag (boolean), the nodegroup `.blend` path (string), and the materials `.blend` path (string).
* Implement the logic for finding the Blender executable (reading `BLENDER_EXECUTABLE_PATH` from `config.py` or checking PATH) within `processing_handler.py`.
* Implement the logic for executing the Blender scripts using `subprocess.run` within `processing_handler.py`. This logic should be similar to the `run_blender_script` function added to `main.py` in the previous step.
* Ensure this Blender script execution logic is conditional based on the received integration flag and runs *after* the main asset processing (handled by the worker pool) is complete.
## Execution Flow Diagram (GUI)
```mermaid
graph TD
A[GUI: User Clicks Start Processing] --> B{Blender Integration Checkbox Checked?};
B -- Yes --> C[Get Blend File Paths from Input Fields];
C --> D[Pass Paths and Flag to ProcessingHandler];
D --> E[ProcessingHandler: Start Asset Processing (Worker Pool)];
E --> F[ProcessingHandler: Asset Processing Complete];
F --> G{Blender Integration Flag True?};
G -- Yes --> H[ProcessingHandler: Find Blender Executable];
H --> I{Blender Executable Found?};
I -- Yes --> J{Nodegroup Blend Path Valid?};
J -- Yes --> K[ProcessingHandler: Run Nodegroup Script in Blender];
K --> L{Script Successful?};
L -- Yes --> M{Materials Blend Path Valid?};
L -- No --> N[ProcessingHandler: Report Nodegroup Error];
N --> M;
M -- Yes --> O[ProcessingHandler: Run Materials Script in Blender];
O --> P{Script Successful?};
P -- Yes --> Q[ProcessingHandler: Report Completion];
P -- No --> R[ProcessingHandler: Report Materials Error];
R --> Q;
M -- No --> Q;
J -- No --> M[Skip Nodegroup Script];
I -- No --> Q[Skip Blender Scripts];
G -- No --> Q;
B -- No --> E;

View File

@ -1,43 +0,0 @@
# GUI Enhancement Plan
## Objective
Implement two new features in the Graphical User Interface (GUI) of the Asset Processor Tool:
1. Automatically switch the preview to "simple view" when more than 10 input files (ZIPs or folders) are added to the queue.
2. Remove the specific visual area labeled "Drag and drop folders here" while keeping the drag-and-drop functionality active for the main processing panel.
## Implementation Plan
The changes will be made in the `gui/main_window.py` file.
1. **Implement automatic preview switch:**
* Locate the `add_input_paths` method.
* After adding the `newly_added_paths` to `self.current_asset_paths`, check the total number of items in `self.current_asset_paths`.
* If the count is greater than 10, programmatically set the state of the "Disable Detailed Preview" menu action (`self.toggle_preview_action`) to `checked=True`. This will automatically trigger the `update_preview` method, which will then render the simple list view.
2. **Remove the "Drag and drop folders here" visual area:**
* Locate the `setup_main_panel_ui` method.
* Find the creation of the `self.drag_drop_area` QFrame and its associated QLabel (`drag_drop_label`).
* Add a line after the creation of `self.drag_drop_area` to hide this widget (`self.drag_drop_area.setVisible(False)`). This will remove the visual box and label while keeping the drag-and-drop functionality enabled for the main window.
## Workflow Diagram
```mermaid
graph TD
A[User drops files/folders] --> B{Call add_input_paths}
B --> C[Add paths to self.current_asset_paths]
C --> D{Count items in self.current_asset_paths}
D{Count > 10?} -->|Yes| E[Set toggle_preview_action.setChecked(True)]
D{Count > 10?} -->|No| F[Keep current preview state]
E --> G[update_preview triggered]
F --> G[update_preview triggered]
G --> H{Check toggle_preview_action state}
H{Checked (Simple)?} -->|Yes| I[Display simple list in preview_table]
H{Checked (Simple)?} -->|No| J[Run PredictionHandler for detailed preview]
J --> K[Display detailed results in preview_table]
L[GUI Initialization] --> M[Call setup_main_panel_ui]
M --> N[Create drag_drop_area QFrame]
N --> O[Hide drag_drop_area QFrame]
O --> P[Main window accepts drops]
P --> B

View File

@ -1,103 +0,0 @@
# GUI Feature Enhancement Plan
**Overall Goal:** Modify the GUI (`gui/main_window.py`, `gui/prediction_handler.py`) to make the output path configurable, improve UI responsiveness during preview generation, and add a toggle to switch between detailed file preview and a simple input path list.
**Detailed Plan:**
1. **Feature: Configurable Output Path**
* **File:** `gui/main_window.py`
* **Changes:**
* **UI Addition:**
* Below the `preset_combo` layout, add a new `QHBoxLayout`.
* Inside this layout, add:
* A `QLabel` with text "Output Directory:".
* A `QLineEdit` (e.g., `self.output_path_edit`) to display/edit the path. Make it read-only initially if preferred, or editable.
* A `QPushButton` (e.g., `self.browse_output_button`) with text "Browse...".
* **Initialization (`__init__` or `setup_main_panel_ui`):**
* Read the default `OUTPUT_BASE_DIR` from `core_config`.
* Resolve this path relative to the project root (`project_root / output_base_dir_config`).
* Set the initial text of `self.output_path_edit` to this resolved default path.
* **Browse Button Logic:**
* Connect the `clicked` signal of `self.browse_output_button` to a new method (e.g., `_browse_for_output_directory`).
* Implement `_browse_for_output_directory`:
* Use `QFileDialog.getExistingDirectory` to let the user select a folder.
* If a directory is selected, update the text of `self.output_path_edit`.
* **Processing Logic (`start_processing`):**
* Instead of reading/resolving the path from `core_config`, get the path string directly from `self.output_path_edit.text()`.
* Convert this string to a `Path` object.
* **Add Validation:** Before passing the path to the handler, check if the directory exists. If not, attempt to create it using `output_dir.mkdir(parents=True, exist_ok=True)`. Handle potential `OSError` exceptions during creation and show an error message if it fails. Also, consider adding a basic writability check if possible.
* Pass the validated `output_dir_str` to `self.processing_handler.run_processing`.
2. **Feature: Responsive UI (Address Prediction Bottleneck)**
* **File:** `gui/prediction_handler.py`
* **Changes:**
* **Import:** Add `from concurrent.futures import ThreadPoolExecutor, as_completed`.
* **Modify `run_prediction`:**
* Inside the `try` block (after loading `config`), create a `ThreadPoolExecutor` (e.g., `with ThreadPoolExecutor(max_workers=...) as executor:`). Determine a reasonable `max_workers` count (e.g., `os.cpu_count() // 2` or a fixed number like 4 or 8).
* Instead of iterating through `input_paths` sequentially, submit a task to the executor for each `input_path_str`.
* The task submitted should be a helper method (e.g., `_predict_single_asset`) that takes `input_path_str` and the loaded `config` object as arguments.
* `_predict_single_asset` will contain the logic currently inside the loop: instantiate `AssetProcessor`, call `get_detailed_file_predictions`, handle exceptions, and return the list of prediction dictionaries for that *single* asset (or an error dictionary).
* Store the `Future` objects returned by `executor.submit`.
* Use `as_completed(futures)` to process results as they become available.
* Append the results from each completed future to the `all_file_results` list.
* Emit `prediction_results_ready` once at the very end with the complete `all_file_results` list.
* **File:** `gui/main_window.py`
* **Changes:**
* No changes needed in the `on_prediction_results_ready` slot itself, as the handler will still emit the full list at the end.
3. **Feature: Preview Toggle**
* **File:** `gui/main_window.py`
* **Changes:**
* **UI Addition:**
* Add a `QCheckBox` (e.g., `self.disable_preview_checkbox`) with text "Disable Detailed Preview". Place it logically, perhaps near the `overwrite_checkbox` or above the `preview_table`. Set its default state to unchecked.
* **Modify `update_preview`:**
* At the beginning of the method, check `self.disable_preview_checkbox.isChecked()`.
* **If Checked (Simple View):**
* Clear the `preview_table`.
* Set simplified table headers (e.g., `self.preview_table.setColumnCount(1); self.preview_table.setHorizontalHeaderLabels(["Input Path"])`). Adjust column resize modes.
* Iterate through `self.current_asset_paths`. For each path, add a row to the table containing just the path string.
* Set status bar message (e.g., "Preview disabled. Showing input list.").
* **Crucially:** `return` from the method here to prevent the `PredictionHandler` from being started.
* **If Unchecked (Detailed View):**
* Ensure the table headers and column count are set back to the detailed view configuration (Status, Original Path, Predicted Name, Details).
* Continue with the rest of the existing `update_preview` logic to start the `PredictionHandler`.
* **Connect Signal:** In `__init__` or `setup_main_panel_ui`, connect the `toggled` signal of `self.disable_preview_checkbox` to the `self.update_preview` slot.
* **Initial State:** Ensure the first call to `update_preview` (if any) respects the initial unchecked state of the checkbox.
**Mermaid Diagram:**
```mermaid
graph TD
subgraph MainWindow
A[User Action: Add Asset / Change Preset / Toggle Preview] --> B{Update Preview Triggered};
B --> C{Is 'Disable Preview' Checked?};
C -- Yes --> D[Show Simple List View in Table];
C -- No --> E[Set Detailed Table Headers];
E --> F[Start PredictionHandler Thread];
F --> G[PredictionHandler Runs];
G --> H[Slot: Populate Table with Detailed Results];
I[User Clicks Start Processing] --> J{Get Output Path from UI LineEdit};
J --> K[Validate/Create Output Path];
K -- Path OK --> L[Start ProcessingHandler Thread];
K -- Path Error --> M[Show Error Message];
L --> N[ProcessingHandler Runs];
N --> O[Update UI (Progress, Status)];
P[User Clicks Browse...] --> Q[Show QFileDialog];
Q --> R[Update Output Path LineEdit];
end
subgraph PredictionHandler [Background Thread]
style PredictionHandler fill:#f9f,stroke:#333,stroke-width:2px
F --> S{Use ThreadPoolExecutor};
S --> T[Run _predict_single_asset Concurrently];
T --> U[Collect Results];
U --> V[Emit prediction_results_ready (Full List)];
V --> H;
end
subgraph ProcessingHandler [Background Thread]
style ProcessingHandler fill:#ccf,stroke:#333,stroke-width:2px
L --> N;
end

View File

@ -1,78 +0,0 @@
# GUI Log Console Feature Plan
**Overall Goal:** Add a log console panel to the GUI's editor panel, controlled by a "View" menu action. Move the "Disable Detailed Preview" control to the same "View" menu.
**Detailed Plan:**
1. **Create Custom Log Handler:**
* **New File/Location:** Potentially add this to a new `gui/log_handler.py` or keep it within `gui/main_window.py` if simple enough.
* **Implementation:**
* Define a class `QtLogHandler(logging.Handler, QObject)` that inherits from both `logging.Handler` and `QObject` (for signals).
* Add a Qt signal, e.g., `log_record_received = Signal(str)`.
* Override the `emit(self, record)` method:
* Format the log record using `self.format(record)`.
* Emit the `log_record_received` signal with the formatted string.
2. **Modify `gui/main_window.py`:**
* **Imports:** Add `QMenuBar`, `QMenu`, `QAction` from `PySide6.QtWidgets`. Import the new `QtLogHandler`.
* **UI Elements (`__init__` / `setup_editor_panel_ui`):**
* **Menu Bar:**
* Create `self.menu_bar = self.menuBar()`.
* Create `view_menu = self.menu_bar.addMenu("&View")`.
* **Log Console:**
* Create `self.log_console_output = QTextEdit()`. Set it to read-only (`self.log_console_output.setReadOnly(True)`).
* Create a container widget, e.g., `self.log_console_widget = QWidget()`. Create a layout for it (e.g., `QVBoxLayout`) and add `self.log_console_output` to this layout.
* In `setup_editor_panel_ui`, insert `self.log_console_widget` into the `editor_layout` *before* adding the `list_layout` (the preset list).
* Initially hide the console: `self.log_console_widget.setVisible(False)`.
* **Menu Actions:**
* Create `self.toggle_log_action = QAction("Show Log Console", self, checkable=True)`. Connect `self.toggle_log_action.toggled.connect(self._toggle_log_console_visibility)`. Add it to `view_menu`.
* Create `self.toggle_preview_action = QAction("Disable Detailed Preview", self, checkable=True)`. Connect `self.toggle_preview_action.toggled.connect(self.update_preview)`. Add it to `view_menu`.
* **Remove Old Checkbox:** Delete the lines creating and adding `self.disable_preview_checkbox`.
* **Logging Setup (`__init__`):**
* Instantiate the custom handler: `self.log_handler = QtLogHandler()`.
* Connect its signal: `self.log_handler.log_record_received.connect(self._append_log_message)`.
* Add the handler to the logger: `log.addHandler(self.log_handler)`. Set an appropriate level if needed (e.g., `self.log_handler.setLevel(logging.INFO)`).
* **New Slots:**
* Implement `_toggle_log_console_visibility(self, checked)`: This slot will simply call `self.log_console_widget.setVisible(checked)`.
* Implement `_append_log_message(self, message)`:
* Append the `message` string to `self.log_console_output`.
* Optional: Add logic to limit the number of lines in the text edit to prevent performance issues.
* Optional: Add basic HTML formatting for colors based on log level.
* **Modify `update_preview`:**
* Replace the check for `self.disable_preview_checkbox.isChecked()` with `self.toggle_preview_action.isChecked()`.
* Update the log messages within this method to reflect checking the action state.
**Mermaid Diagram:**
```mermaid
graph TD
subgraph MainWindow
A[Initialization] --> B(Create Menu Bar);
B --> C(Add View Menu);
C --> D(Add 'Show Log Console' Action);
C --> E(Add 'Disable Detailed Preview' Action);
A --> F(Create Log Console QTextEdit);
F --> G(Place Log Console Widget in Layout [Hidden]);
A --> H(Create & Add QtLogHandler);
H --> I(Connect Log Handler Signal to _append_log_message);
D -- Toggled --> J[_toggle_log_console_visibility];
J --> K(Show/Hide Log Console Widget);
E -- Toggled --> L[update_preview];
M[update_preview] --> N{Is 'Disable Preview' Action Checked?};
N -- Yes --> O[Show Simple List View];
N -- No --> P[Start PredictionHandler];
Q[Any Log Message] -- Emitted by Logger --> H;
I --> R[_append_log_message];
R --> S(Append Message to Log Console QTextEdit);
end
subgraph QtLogHandler
style QtLogHandler fill:#lightgreen,stroke:#333,stroke-width:2px
T1[emit(record)] --> T2(Format Record);
T2 --> T3(Emit log_record_received Signal);
T3 --> I;
end

View File

@ -1,65 +0,0 @@
# GUI Overhaul Plan: Unified Hierarchical View
**Task:** Implement a UI overhaul for the Asset Processor Tool GUI to address usability issues and streamline the workflow for viewing and editing processing rules.
**Context:**
* A hierarchical rule system (`SourceRule`, `AssetRule`, `FileRule` in `rule_structure.py`) is used by the core engine (`asset_processor.py`).
* The current GUI (`gui/main_window.py`, `gui/rule_hierarchy_model.py`, `gui/rule_editor_widget.py`) uses a `QTreeView` for hierarchy, a separate `RuleEditorWidget` for editing selected items, and a `QTableView` (`PreviewTableModel`) for previewing file classifications.
* Relevant files analyzed: `gui/main_window.py`, `gui/rule_editor_widget.py`, `gui/rule_hierarchy_model.py`.
**Identified Issues with Current UI:**
1. **Window Resizing:** Selecting Source/Asset items causes window expansion because `RuleEditorWidget` displays large child lists (`assets`, `files`) as simple labels.
2. **GUI Not Updating on Add:** Potential regression where adding new inputs doesn't reliably update the preview/hierarchy.
3. **Incorrect Source Display:** Tree view shows "Source: None" instead of the input path (likely `SourceRule.input_path` is None when model receives it).
4. **Preview Table Stale:** Changes made in `RuleEditorWidget` (e.g., overrides) are not reflected in the `PreviewTableModel` because the `_on_rule_updated` slot in `main_window.py` doesn't trigger a refresh.
**Agreed-Upon Overhaul Plan:**
The goal is to create a more unified and streamlined experience by merging the hierarchy, editing overrides, and preview aspects into a single view, reducing redundancy.
1. **UI Structure Redesign:**
* **Left Panel:** Retain the existing Preset Editor panel (`main_window.py`'s `editor_panel`) for managing preset files (`.json`) and their complex rules (naming patterns, map type mappings, archetype rules, etc.).
* **Right Panel:** Replace the current three-part splitter (Hierarchy Tree, Rule Editor, Preview Table) with a **single Unified Hierarchical View**.
* Implementation: Use a `QTreeView` with a custom `QAbstractItemModel` and custom `QStyledItemDelegate`s for inline editing.
* Hierarchy Display: Show Input Source(s) -> Assets -> Files.
* Visual Cues: Use distinct background colors for rows representing Inputs, Assets, and Files.
2. **Unified View Columns & Functionality:**
* **Column 1: Name/Hierarchy:** Displays input path, asset name, or file name with indentation.
* **Column 2+: Editable Attributes (Context-Dependent):** Implement inline editors using delegates:
* **Input Row:** Optional editable field for `Supplier` override.
* **Asset Row:** `QComboBox` delegate for `Asset-Type` override (e.g., `GENERIC`, `DECAL`, `MODEL`).
* **File Row:**
* `QLineEdit` delegate for `Target Asset Name` override.
* `QComboBox` delegate for `Item-Type` override (e.g., `MAP-COL`, `MAP-NRM`, `EXTRA`, `MODEL_FILE`).
* **Column X: Status (Optional, Post-Processing):** Non-editable column showing processing status icon/text (Pending, Success, Warning, Error).
* **Column Y: Output Path (Optional, Post-Processing):** Non-editable column showing the final output path after successful processing.
3. **Data Flow and Initialization:**
* When inputs are added and a preset selected, `PredictionHandler` runs.
* `PredictionHandler` generates the `SourceRule` hierarchy *and* predicts initial `Asset-Type`, `Item-Type`, and `Target Asset Name`.
* The Unified View's model is populated with this `SourceRule`.
* *Initial values* in inline editors are set based on these *predicted* values.
* User edits in the Unified View directly modify attributes on the `SourceRule`, `AssetRule`, or `FileRule` objects held by the model.
4. **Dropdown Options Source:**
* Available options in dropdowns (`Asset-Type`, `Item-Type`) should be sourced from globally defined lists or Enums (e.g., in `rule_structure.py` or `config.py`).
5. **Addressing Original Issues (How the Plan Fixes Them):**
* **Window Resizing:** Resolved by removing `RuleEditorWidget`.
* **GUI Not Updating on Add:** Fix requires ensuring `add_input_paths` triggers `PredictionHandler` and updates the new Unified View model correctly.
* **Incorrect Source Display:** Fix requires ensuring `PredictionHandler` correctly populates `SourceRule.input_path`.
* **Preview Table Stale:** Resolved by merging preview/editing; edits are live in the main view.
**Implementation Tasks:**
* Modify `gui/main_window.py`: Remove the right-side splitter, `RuleEditorWidget`, `PreviewTableModel`/`View`. Instantiate the new Unified View. Adapt `add_input_paths`, `start_processing`, `_on_rule_hierarchy_ready`, etc., to interact with the new view/model.
* Create/Modify Model (`gui/rule_hierarchy_model.py` or new file): Implement a `QAbstractItemModel` supporting multiple columns, hierarchical data, and providing data/flags for inline editing.
* Create Delegates (`gui/delegates.py`?): Implement `QStyledItemDelegate` subclasses for `QComboBox` and `QLineEdit` editors in the tree view.
* Modify `gui/prediction_handler.py`: Ensure it predicts initial override values (`Asset-Type`, `Item-Type`, `Target Asset Name`) and includes them in the data passed back to the main window (likely within the `SourceRule` structure or alongside it). Ensure `SourceRule.input_path` is correctly set.
* Modify `gui/processing_handler.py`: Update it to potentially signal back status/output path updates that can be reflected in the new Unified View model's optional columns.
* Define Dropdown Sources: Add necessary Enums or lists to `rule_structure.py` or `config.py`.
This plan provides a clear path forward for implementing the UI overhaul.

View File

@ -1,119 +0,0 @@
# Asset Processor GUI Development Plan
This document outlines the plan for developing a Graphical User Interface (GUI) for the Asset Processor Tool.
**I. Foundation & Framework Choice**
1. **Choose GUI Framework:** PySide6 (LGPL license, powerful features).
2. **Project Structure:** Create `gui/` directory. Core logic remains separate.
3. **Dependencies:** Add `PySide6` to `requirements.txt`.
**II. Core GUI Layout & Basic Functionality**
1. **Main Window:** `gui/main_window.py`.
2. **Layout Elements:**
* Drag & Drop Area (`QWidget` subclass).
* Preset Dropdown (`QComboBox`).
* Preview Area (`QListWidget`).
* Progress Bar (`QProgressBar`).
* Control Buttons (`QPushButton`: Start, Cancel, Manage Presets).
* Status Bar (`QStatusBar`).
**III. Input Handling & Predictive Preview**
1. **Connect Drag & Drop:** Validate drops, add valid paths to Preview List.
2. **Refactor for Prediction:** Create/refactor methods in `AssetProcessor`/`Configuration` to predict output names without full processing.
3. **Implement Preview Update:** On preset change or file add, load config, call prediction logic, update Preview List items (e.g., "Input: ... -> Output: ...").
4. **Responsive Preview:** Utilize PySide6 list widget efficiency (consider model/view for very large lists).
**IV. Processing Integration & Progress Reporting**
1. **Adapt Processing Logic:** Refactor `main.py`'s `ProcessPoolExecutor` loop into a callable function/class (`gui/processing_handler.py`).
2. **Background Execution:** Run processing logic in a `QThread` to keep GUI responsive.
3. **Progress Updates (Signals & Slots):**
* Background thread emits signals: `progress_update(current, total)`, `file_status_update(path, status, msg)`, `processing_finished(stats)`.
* Main window connects slots to these signals to update UI elements (`QProgressBar`, `QListWidget` items, status bar).
4. **Completion/Error Handling:** Re-enable controls, display summary stats, report errors on `processing_finished`.
**V. Preset Management Interface (Sub-Task)**
1. **New Window/Dialog:** `gui/preset_editor.py`.
2. **Functionality:** List, load, edit (tree view/form), create, save/save as presets (`.json` in `presets/`). Basic validation.
**VI. Refinements & Additional Features (Ideas)**
1. **Cancel Button:** Implement cancellation signal to background thread/workers.
2. **Log Viewer:** Add `QTextEdit` to display `logging` output.
3. **Output Directory Selection:** Add browse button/field.
4. **Configuration Options:** Expose key `config.py` settings.
5. **Clearer Error Display:** Tooltips, status bar updates, or error panel.
**VII. Packaging (Deployment)**
1. **Tooling:** PyInstaller or cx_Freeze.
2. **Configuration:** Build scripts (`.spec`) to bundle code, dependencies, assets, and `presets/`.
**High-Level Mermaid Diagram:**
```mermaid
graph TD
subgraph GUI Application (PySide6)
A[Main Window] --> B(Drag & Drop Area);
A --> C(Preset Dropdown);
A --> D(Preview List Widget);
A --> E(Progress Bar);
A --> F(Start Button);
A -- Contains --> SB(Status Bar);
A --> CB(Cancel Button);
A --> MB(Manage Presets Button);
B -- fileDroppedSignal --> X(Handle Input Files);
C -- currentIndexChangedSignal --> Y(Update Preview);
X -- Adds paths --> D;
X -- Triggers --> Y;
Y -- Reads --> C;
Y -- Uses --> J(Prediction Logic);
Y -- Updates --> D;
F -- clickedSignal --> Z(Start Processing);
CB -- clickedSignal --> AA(Cancel Processing);
MB -- clickedSignal --> L(Preset Editor Dialog);
Z -- Starts --> BB(Processing Thread: QThread);
AA -- Signals --> BB;
BB -- progressSignal(curr, total) --> E;
BB -- fileStatusSignal(path, status, msg) --> D;
BB -- finishedSignal(stats) --> AB(Handle Processing Finished);
AB -- Updates --> SB;
AB -- Enables/Disables --> F;
AB -- Enables/Disables --> CB;
AB -- Enables/Disables --> C;
AB -- Enables/Disables --> B;
L -- Modifies --> H(Presets Dir: presets/*.json);
end
subgraph Backend Logic (Existing + Refactored)
H -- Loaded by --> C;
H -- Loaded/Saved by --> L;
J -- Reads --> M(configuration.py / asset_processor.py);
BB -- Runs --> K(Adapted main.py Logic);
K -- Uses --> N(ProcessPoolExecutor);
N -- Runs --> O(process_single_asset_wrapper);
O -- Uses --> M;
O -- Reports Status --> K;
K -- Reports Progress --> BB;
end
classDef gui fill:#f9f,stroke:#333,stroke-width:2px;
classDef backend fill:#ccf,stroke:#333,stroke-width:2px;
classDef thread fill:#ffc,stroke:#333,stroke-width:1px;
class A,B,C,D,E,F,CB,MB,L,SB,X,Y,Z,AA,AB gui;
class H,J,K,M,N,O backend;
class BB thread;
```
This plan provides a roadmap for the GUI development.

View File

@ -1,42 +0,0 @@
# GUI Preset Selection Plan
## Objective
Modify the GUI so that no preset is selected by default, and the preview table is only populated after the user explicitly selects a preset. This aims to prevent accidental processing with an unintended preset and clearly indicate to the user that a preset selection is required for preview.
## Plan
1. **Modify `gui/main_window.py`:**
* Remove the logic that selects a default preset during initialization.
* Initialize the preview table to display the text "please select a preset".
* Disable the mechanism that triggers the `PredictionHandler` for preview generation until a preset is selected.
* Update the slot connected to the preset selection signal:
* When a preset is selected, clear the placeholder text and enable the preview generation mechanism.
* Pass the selected preset configuration to the `PredictionHandler`.
* Trigger the `PredictionHandler` to generate and display the preview.
* (Optional but recommended) Add logic to handle the deselection of a preset, which should clear the preview table and display the "please select a preset" text again, and disable preview generation.
2. **Review `gui/prediction_handler.py`:**
* Verify that the `PredictionHandler`'s methods that generate predictions (`get_detailed_file_predictions`) correctly handle being called only when a valid preset is provided. No major changes are expected here, but it's good practice to confirm.
3. **Update Preview Table Handling (`gui/preview_table_model.py` and `gui/main_window.py`):**
* Ensure the `PreviewTableModel` can gracefully handle having no data when no preset is selected.
* In `gui/main_window.py`, configure the `QTableView` or its parent widget to display the placeholder text "please select a preset" when the model is empty or no preset is active.
## Data Flow Change
The current data flow for preview generation is roughly:
Initialization -> Default Preset Loaded -> Trigger PredictionHandler -> Update Preview Table
The proposed data flow would be:
Initialization -> No Preset Selected -> Preview Table Empty/Placeholder -> User Selects Preset -> Trigger PredictionHandler with Selected Preset -> Update Preview Table
```mermaid
graph TD
A[GUI Initialization] --> B{Is a Preset Selected?};
B -- Yes (Current) --> C[Load Default Preset];
B -- No (Proposed) --> D[Preview Table Empty/Placeholder];
C --> E[Trigger PredictionHandler];
D --> F[User Selects Preset];
F --> E;
E --> G[Update Preview Table];

View File

@ -1,59 +0,0 @@
# Plan: Implement Alternating Row Colors Per Asset Group in GUI Preview Table
## Objective
Modify the GUI preview table to display alternating background colors for rows based on the asset group they belong to, rather than alternating colors for each individual row. The visual appearance should be similar to the default alternating row colors (dark greys, no border, no rounded corners).
## Current State
The preview table in the GUI uses a `QTableView` with `setAlternatingRowColors(True)` enabled, which applies alternating background colors based on the row index. The `PreviewTableModel` groups file prediction data by `source_asset` in its internal `_table_rows` structure and provides data to the view.
## Proposed Plan
To achieve alternating colors per asset group, we will implement custom coloring logic within the `PreviewTableModel`.
1. **Disable Default Alternating Colors:**
* In `gui/main_window.py`, locate the initialization of the `preview_table_view` (a `QTableView`).
* Change `self.preview_table_view.setAlternatingRowColors(True)` to `self.preview_table_view.setAlternatingRowColors(False)`.
2. **Modify `PreviewTableModel.data()`:**
* Open `gui/preview_table_model.py`.
* In the `data()` method, add a case to handle the `Qt.ItemDataRole.BackgroundRole`.
* Inside this case, retrieve the `source_asset` for the current row from the `self._table_rows` structure.
* Maintain a sorted list of unique `source_asset` values. This can be done when the data is set in `set_data()`.
* Find the index of the current row's `source_asset` within this sorted list.
* Based on whether the index is even or odd, return a `QColor` object representing one of the two desired grey colors.
* Ensure that the `Qt.ItemDataRole.BackgroundRole` is handled correctly for all columns in the row.
3. **Define Colors:**
* Define two `QColor` objects within the `PreviewTableModel` class to represent the two grey colors for alternating groups. These should be chosen to be visually similar to the default alternating row colors.
## Visual Representation of Data Flow with Custom Coloring
```mermaid
graph TD
A[QTableView] --> B{Requests Data for Row/Column};
B --> C[PreviewSortFilterProxyModel];
C --> D[PreviewTableModel];
D -- data(index, role) --> E{Check Role};
E -- Qt.ItemDataRole.BackgroundRole --> F{Get source_asset for row};
F --> G{Determine Asset Group Index};
G --> H{Assign Color based on Index Parity};
H --> I[Return QColor];
E -- Other Roles --> J[Return Display/Tooltip/Foreground Data};
I --> C;
J --> C;
C --> A{Displays Data with Custom Background Color};
style D fill:#f9f,stroke:#333,stroke-width:2px
style H fill:#ccf,stroke:#333,stroke-width:1px
style I fill:#ccf,stroke:#333,stroke-width:1px
```
## Implementation Steps (for Code Mode)
1. Modify `gui/main_window.py` to disable default alternating row colors.
2. Modify `gui/preview_table_model.py` to:
* Define the two grey `QColor` objects.
* Update `set_data()` to create and store a sorted list of unique asset groups.
* Implement the `Qt.ItemDataRole.BackgroundRole` logic in the `data()` method to return alternating colors based on the asset group index.

View File

@ -1,49 +0,0 @@
# Plan: Enhance GUI Preview Table Coloring
## Objective
Modify the GUI preview table to apply status-based text coloring to all relevant cells in a row, providing a more consistent visual indication of a file's status.
## Current State
The `PreviewTableModel` in `gui/preview_table_model.py` currently applies status-based text colors only to the "Status" column (based on the main file's status) and the "Additional Files" column (based on the additional file's status). Other cells in the row do not have status-based coloring.
## Proposed Change
Extend the status-based text coloring logic in the `PreviewTableModel`'s `data()` method to apply the relevant status color to any cell that corresponds to either the main file or an additional file in that row.
## Plan
1. **Modify the `data()` method in `gui/preview_table_model.py`:**
* Locate the section handling the `Qt.ItemDataRole.ForegroundRole`.
* Currently, this section checks the column index (`col`) to decide which file's status to use for coloring (main file for `COL_STATUS`, additional file for `COL_ADDITIONAL_FILES`).
* We will change this logic to determine which file (main or additional) the *current row and column* corresponds to, and then use that file's status to look up the color.
* For columns related to the main file (`COL_STATUS`, `COL_PREDICTED_ASSET`, `COL_ORIGINAL_PATH`, `COL_PREDICTED_OUTPUT`, `COL_DETAILS`), if the row contains a `main_file`, use the `main_file`'s status for coloring.
* For the `COL_ADDITIONAL_FILES` column, if the row contains `additional_file_details`, use the `additional_file_details`' status for coloring.
* If a cell does not correspond to a file (e.g., a main file column in a row that only has an additional file), return `None` for the `ForegroundRole` to use the default text color.
## Detailed Steps
1. Open `gui/preview_table_model.py`.
2. Navigate to the `data()` method.
3. Find the `if role == Qt.ItemDataRole.ForegroundRole:` block.
4. Inside this block, modify the logic to determine the `status` variable based on the current `col` and the presence of `main_file` or `additional_file_details` in `row_data`.
5. Use the determined `status` to look up the color in `self.STATUS_COLORS`.
6. Return the color if found, otherwise return `None`.
## Modified Color Logic Flow
```mermaid
graph TD
A[data(index, role)] --> B{role == Qt.ItemDataRole.ForegroundRole?};
B -- Yes --> C{Determine relevant file for cell (row, col)};
C -- Cell corresponds to Main File --> D{Get main_file status};
C -- Cell corresponds to Additional File --> E{Get additional_file_details status};
C -- Cell is empty --> F[status = None];
D --> G{Lookup color in STATUS_COLORS};
E --> G;
F --> H[Return None];
G -- Color found --> I[Return Color];
G -- No color found --> H;
B -- No --> J[Handle other roles];
J --> K[Return data based on role];

View File

@ -1,90 +0,0 @@
# GUI Preview Table Restructure Plan
## Objective
Restructure the Graphical User Interface (GUI) preview table to group files by source asset and display "Ignored" and "Extra" files in a new "Additional Files" column, aligned with the mapped files of the same asset.
## Analysis
Based on the review of `gui/prediction_handler.py` and `gui/preview_table_model.py`:
* The `PredictionHandler` provides a flat list of file prediction dictionaries.
* The `PreviewTableModel` currently stores and displays this flat list directly.
* The `PreviewSortFilterProxyModel` sorts this flat list.
* The data transformation to achieve the desired grouped layout must occur within the `PreviewTableModel`.
## Proposed Plan
1. **Modify `gui/preview_table_model.py`:**
* **Add New Column:**
* Define a new constant: `COL_ADDITIONAL_FILES = 5`.
* Add "Additional Files" to the `_headers_detailed` list.
* **Introduce New Internal Data Structure:**
* Create a new internal list, `self._table_rows`, to store dictionaries representing the final rows to be displayed in the table.
* **Update `set_data(self, data: list)`:**
* Process the incoming flat `data` list (received from `PredictionHandler`).
* Group file dictionaries by their `source_asset`.
* Within each asset group, separate files into two lists:
* `main_files`: Files with status "Mapped", "Model", or "Error".
* `additional_files`: Files with status "Ignored", "Extra", "Unrecognised", or "Unmatched Extra".
* Determine the maximum number of rows needed for this asset block: `max(len(main_files), len(additional_files))`.
* Build the row dictionaries for `self._table_rows` for this asset block:
* For `i` from 0 to `max_rows - 1`:
* Get the `i`-th file from `main_files` (or `None` if `i` is out of bounds).
* Get the `i`-th file from `additional_files` (or `None` if `i` is out of bounds).
* Create a row dictionary containing:
* `source_asset`: The asset name.
* `predicted_asset`: From the `main_file` (if exists).
* `details`: From the `main_file` (if exists).
* `original_path`: From the `main_file` (if exists).
* `additional_file_path`: Path from the `additional_file` (if exists).
* `additional_file_details`: The original dictionary of the `additional_file` (if exists, for tooltips).
* `is_main_row`: Boolean flag (True if this row corresponds to a file in `main_files`, False otherwise).
* Append these row dictionaries to `self._table_rows`.
* After processing all assets, call `self.beginResetModel()` and `self.endResetModel()`.
* **Update `rowCount`:** Return `len(self._table_rows)` when in detailed mode.
* **Update `columnCount`:** Return `len(self._headers_detailed)`.
* **Update `data(self, index, role)`:**
* Retrieve the row dictionary: `row_data = self._table_rows[index.row()]`.
* For `Qt.ItemDataRole.DisplayRole`:
* If `index.column()` is `COL_ADDITIONAL_FILES`, return `row_data.get('additional_file_path', '')`.
* For other columns (`COL_STATUS`, `COL_PREDICTED_ASSET`, `COL_ORIGINAL_PATH`, `COL_DETAILS`), return data from the `main_file` part of `row_data` if `row_data['is_main_row']` is True, otherwise return an empty string or appropriate placeholder.
* For `Qt.ItemDataRole.ToolTipRole`:
* If `index.column()` is `COL_ADDITIONAL_FILES` and `row_data.get('additional_file_details')` exists, generate a tooltip using the status and details from `additional_file_details`.
* For other columns, use the existing tooltip logic based on the `main_file` data.
* For `Qt.ItemDataRole.ForegroundRole`:
* Apply existing color-coding based on the status of the `main_file` if `row_data['is_main_row']` is True.
* For the `COL_ADDITIONAL_FILES` cell and for rows where `row_data['is_main_row']` is False, use neutral styling (default text color).
* **Update `headerData`:** Return the correct header for `COL_ADDITIONAL_FILES`.
2. **Modify `gui/preview_table_model.py` (`PreviewSortFilterProxyModel`):**
* **Update `lessThan(self, left, right)`:**
* Retrieve the row dictionaries for `left` and `right` indices from the source model (`model._table_rows[left.row()]`, etc.).
* **Level 1: Source Asset:** Compare `source_asset` from the row dictionaries.
* **Level 2: Row Type:** If assets are the same, compare `is_main_row` (True sorts before False).
* **Level 3 (Main Rows):** If both are main rows (`is_main_row` is True), compare `original_path`.
* **Level 4 (Additional-Only Rows):** If both are additional-only rows (`is_main_row` is False), compare `additional_file_path`.
## Clarifications & Decisions
* **Error Handling:** "Error" files will remain in the main columns, similar to "Mapped" files, with their current "Error" status.
* **Sorting within Asset:** The proposed sorting logic within an asset block is acceptable (mapped rows by original path, additional-only rows by additional file path).
* **Styling of Additional Column:** Use neutral text and background styling for the "Additional Files" column, relying on tooltips for specific file details.
## Mermaid Diagram (Updated Data Flow)
```mermaid
graph LR
A[PredictionHandler] -- prediction_results_ready(flat_list) --> B(PreviewTableModel);
subgraph PreviewTableModel
C[set_data] -- Processes flat_list --> D{Internal Grouping & Transformation};
D -- Creates --> E[_table_rows (Structured List)];
F[data()] -- Reads from --> E;
end
B -- Provides data via data() --> G(QTableView via Proxy);
style B fill:#f9f,stroke:#333,stroke-width:2px
style C fill:#ccf,stroke:#333,stroke-width:1px
style D fill:#lightgrey,stroke:#333,stroke-width:1px
style E fill:#ccf,stroke:#333,stroke-width:1px
style F fill:#ccf,stroke:#333,stroke-width:1px

View File

@ -1,123 +0,0 @@
# Asset Processor GUI Refactor Plan
This document outlines the plan to refactor the Asset Processor GUI based on user requirements.
## Goals
1. **Improve File Visibility:** Display all files found within an asset in the preview list, including those that don't match the preset, are moved to 'Extra', or have errors, along with their status.
2. **Integrate Preset Editor:** Move the preset editing functionality from the separate dialog into a collapsible panel within the main window.
## Goal 1: Improve File Visibility in Preview List
**Problem:** The current preview (`PredictionHandler` calling `AssetProcessor.predict_output_structure`) only shows files that successfully match a map type rule and get a predicted output name. It doesn't show files that are ignored, moved to 'Extra', or encounter errors during classification.
**Solution:** Leverage the more comprehensive classification logic already present in `AssetProcessor._inventory_and_classify_files` for the GUI preview.
**Plan Steps:**
1. **Modify `asset_processor.py`:**
* Create a new method in `AssetProcessor`, perhaps named `get_detailed_file_predictions()`.
* This new method will perform the core steps of `_setup_workspace()`, `_extract_input()`, and `_inventory_and_classify_files()`.
* It will then iterate through *all* categories in `self.classified_files` ('maps', 'models', 'extra', 'ignored').
* For each file, it will determine a 'status' (e.g., "Mapped", "Model", "Extra", "Ignored", "Error") and attempt to predict the output name (similar to `predict_output_structure` for maps, maybe just the original name for others).
* It will return a more detailed list of dictionaries, each containing: `{'original_path': str, 'predicted_name': str | None, 'status': str, 'details': str | None}`.
* Crucially, this method will *not* perform the actual processing (`_process_maps`, `_merge_maps`, etc.) or file moving, only the classification and prediction. It should also include cleanup (`_cleanup_workspace`).
2. **Modify `gui/prediction_handler.py`:**
* Update `PredictionHandler.run_prediction` to call the new `AssetProcessor.get_detailed_file_predictions()` method instead of `predict_output_structure()`.
* Adapt the code that processes the results to handle the new dictionary format (including the 'status' and 'details' fields).
* Emit this enhanced list via the `prediction_results_ready` signal.
3. **Modify `gui/main_window.py`:**
* In `setup_ui`, add a new column to `self.preview_table` for "Status". Adjust column count and header labels.
* In `on_prediction_results_ready`, populate the new "Status" column using the data received from `PredictionHandler`.
* Consider adding tooltips to the status column to show the 'details' (e.g., the reason for being ignored or moved to extra).
* Optionally, use background colors or icons in the status column for better visual distinction.
## Goal 2: Integrate Preset Editor into Main Window
**Problem:** Preset editing requires opening a separate modal dialog, interrupting the main workflow.
**Solution:** Embed the preset editing controls directly into the main window within a collapsible panel.
**Plan Steps:**
1. **Modify `gui/main_window.py` - UI Changes:**
* Remove the "Manage Presets" button (`self.manage_presets_button`).
* Add a collapsible panel (potentially using a `QFrame` with show/hide logic triggered by a button, or a `QDockWidget` if more appropriate) to the left side of the main layout.
* Inside this panel:
* Add the `QListWidget` for displaying presets (`self.preset_list`).
* Add the "New" and "Delete" buttons below the list. (The "Load" button becomes implicit - selecting a preset in the list loads it into the editor).
* Recreate the `QTabWidget` (`self.preset_editor_tabs`) with the "General & Naming" and "Mapping & Rules" tabs.
* Recreate *all* the widgets currently inside the `PresetEditorDialog` tabs (QLineEdit, QTextEdit, QSpinBox, QListWidget+controls, QTableWidget+controls) within the corresponding tabs in the main window's panel. Give them appropriate instance names (e.g., `self.editor_preset_name`, `self.editor_supplier_name`, etc.).
* Add "Save" and "Save As..." buttons within the collapsible panel, likely at the bottom.
2. **Modify `gui/main_window.py` - Logic Integration:**
* Adapt the `populate_presets` method to populate the new `self.preset_list` in the panel.
* Connect `self.preset_list.currentItemChanged` to a new method `load_selected_preset_for_editing`. This method will handle checking for unsaved changes in the editor panel and then load the selected preset's data into the editor widgets (similar to `PresetEditorDialog.load_preset`).
* Implement `save_preset`, `save_preset_as`, `new_preset`, `delete_preset` methods directly within `MainWindow`, adapting the logic from `PresetEditorDialog`. These will interact with the editor widgets in the panel.
* Implement `check_unsaved_changes` logic for the editor panel, prompting the user if they try to load/create/delete a preset or close the application with unsaved edits in the panel.
* Connect the editor widgets' change signals (`textChanged`, `valueChanged`, `itemChanged`, etc.) to a `mark_editor_unsaved` method in `MainWindow`.
* Ensure the main preset selection `QComboBox` (`self.preset_combo`) is repopulated when presets are saved/deleted via the editor panel.
3. **Cleanup:**
* Delete the `gui/preset_editor_dialog.py` file.
* Remove imports and references to `PresetEditorDialog` from `gui/main_window.py`.
## Visual Plan
**Current Layout:**
```mermaid
graph TD
subgraph "Current Main Window Layout"
A[Preset Combo + Manage Button] --> B(Drag & Drop Area);
B --> C{File Preview Table};
C --> D[Progress Bar];
D --> E[Options + Start/Cancel Buttons];
end
subgraph "Current Preset Editor (Separate Dialog)"
F[Preset List + Load/New/Delete] --> G{Tab Widget};
subgraph "Tab Widget"
G1[General & Naming Tab]
G2[Mapping & Rules Tab]
end
G --> H[Save / Save As / Close Buttons];
end
A -- Manage Button Click --> F;
style F fill:#f9f,stroke:#333,stroke-width:2px
style G fill:#f9f,stroke:#333,stroke-width:2px
style H fill:#f9f,stroke:#333,stroke-width:2px
```
**Proposed Layout:**
```mermaid
graph TD
subgraph "Proposed Main Window Layout"
direction LR
subgraph "Collapsible Preset Editor Panel (Left)"
P_List[Preset List] --> P_Buttons[New / Delete Buttons]
P_Buttons --> P_Tabs{Tab Widget}
subgraph "Editor Tabs"
P_Tab1[General & Naming]
P_Tab2[Mapping & Rules]
end
P_Tabs --> P_Save[Save / Save As Buttons]
end
subgraph "Main Area (Right)"
M_Preset[Preset Combo (for processing)] --> M_DragDrop(Drag & Drop Area)
M_DragDrop --> M_Preview{File Preview Table (with Status Column)}
M_Preview --> M_Progress[Progress Bar]
M_Progress --> M_Controls[Options + Start/Cancel Buttons]
end
P_List -- Selection Loads --> P_Tabs;
P_Save -- Updates --> P_List;
P_List -- Updates --> M_Preset;
style M_Preview fill:#ccf,stroke:#333,stroke-width:2px
style P_List fill:#cfc,stroke:#333,stroke-width:2px
style P_Tabs fill:#cfc,stroke:#333,stroke-width:2px
style P_Save fill:#cfc,stroke:#333,stroke-width:2px
end

View File

@ -1,63 +0,0 @@
# Plan: Update GUI Preview Status
**Objective:** Modify the Asset Processor GUI preview to distinguish between files explicitly marked as "Extra" by preset patterns and those that are simply unclassified.
**Current Statuses:**
* Mapped
* Ignored
* Extra (Includes both explicitly matched and unclassified files)
**Proposed Statuses:**
* Mapped
* Ignored
* Extra (Files explicitly matched by `move_to_extra_patterns` in the preset)
* Unrecognised (Files not matching any map, model, or explicit extra pattern)
**Visual Plan:**
```mermaid
graph TD
A[Start: User Request] --> B{Analyze Request: Split 'Extra' status};
B --> C{Info Gathering};
C --> D[Read gui/prediction_handler.py];
D --> E[Read asset_processor.py];
E --> F[Read Presets/Poliigon.json];
F --> G[Read gui/main_window.py];
G --> H{Identify Code Locations};
H --> I[asset_processor.py: get_detailed_file_predictions()];
H --> J[gui/main_window.py: on_prediction_results_ready()];
I --> K{Plan Code Changes};
J --> K;
K --> L[Modify asset_processor.py: Differentiate status based on 'reason'];
K --> M[Modify gui/main_window.py: Add color rule for 'Unrecognised' (#92371f)];
L --> N{Final Plan};
M --> N;
N --> O[Present Plan to User];
O --> P{User Approval + Color Choice};
P --> Q[Switch to Code Mode for Implementation];
subgraph "Code Modification"
L
M
end
subgraph "Information Gathering"
D
E
F
G
end
```
**Implementation Steps:**
1. **Modify `asset_processor.py` (`get_detailed_file_predictions` method):**
* Locate the loop processing the `self.classified_files["extra"]` list (around line 1448).
* Inside this loop, check the `reason` associated with each file:
* If `reason == 'Unclassified'`, set the output `status` to `"Unrecognised"`.
* Otherwise (if the reason indicates an explicit pattern match), set the output `status` to `"Extra"`.
* Adjust the `details` string provided in the output for clarity (e.g., show pattern match reason for "Extra", maybe just "[Unrecognised]" for the new status).
2. **Modify `gui/main_window.py` (`on_prediction_results_ready` method):**
* Locate the section where text color is applied based on the `status` (around line 673).
* Add a new `elif` condition to handle `status == "Unrecognised"` and assign it the color `QColor("#92371f")`.

View File

@ -1,194 +0,0 @@
# Implementation Plan: GUI User-Friendliness Enhancements
This document outlines the plan for implementing three key GUI improvements for the Asset Processor Tool, focusing on user-friendliness and workflow efficiency.
**Target Audience:** Developers implementing these features.
**Status:** Planning Phase
## Feature 1: Editable Asset Name
**Goal:** Allow users to edit the name of an asset directly in the main view, and automatically update the 'Target Asset' field of all associated child files to reflect the new name.
**Affected Components:**
* `gui/unified_view_model.py` (`UnifiedViewModel`)
* `gui/delegates.py` (`LineEditDelegate`)
* `gui/main_window.py` (or view setup location)
* `rule_structure.py` (`AssetRule`, `FileRule`)
* Potentially a new handler or modifications to `gui/asset_restructure_handler.py`
**Implementation Steps:**
1. **Enable Editing in Model (`UnifiedViewModel`):**
* Modify `flags()`: For an index pointing to an `AssetRule`, return `Qt.ItemIsEditable` in addition to default flags when `index.column()` is `COL_NAME`.
* Modify `setData()`:
* Add logic to handle `isinstance(item, AssetRule)` and `column == self.COL_NAME`.
* Get the `new_asset_name` from the `value`.
* **Validation:** Before proceeding, check if an `AssetRule` with `new_asset_name` already exists within the same parent `SourceRule`. If so, log a warning and return `False` to prevent duplicate names.
* Store the `old_asset_name = item.asset_name`.
* If `new_asset_name` is valid and different from `old_asset_name`:
* Update `item.asset_name = new_asset_name`.
* Set `changed = True`.
* **Crucial - Child Update:** Iterate through *all* `SourceRule`s, `AssetRule`s, and `FileRule`s in the model (`self._source_rules`). For each `FileRule` found where `file_rule.target_asset_name_override == old_asset_name`, update `file_rule.target_asset_name_override = new_asset_name`. Emit `dataChanged` for the `COL_TARGET_ASSET` index of each modified `FileRule`. (See Potential Challenges regarding performance).
* Emit `dataChanged` for the edited `AssetRule`'s `COL_NAME` index.
* Return `changed`.
* **(Alternative Signal Approach):** Instead of performing the child update directly in `setData`, emit a new signal like `assetNameChanged = Signal(QModelIndex, str, str)` carrying the `AssetRule` index, old name, and new name. A dedicated handler would connect to this signal to perform the child updates. This improves separation of concerns.
2. **Assign Delegate (`main_window.py` / View Setup):**
* Ensure the `LineEditDelegate` is assigned to the view for the `COL_NAME` using `view.setItemDelegateForColumn(UnifiedViewModel.COL_NAME, line_edit_delegate_instance)`.
3. **Handling Child Updates (if using Signal Approach):**
* Create a new handler class (e.g., `AssetNameChangeHandler`) or add a slot to `AssetRestructureHandler`.
* Connect the `UnifiedViewModel.assetNameChanged` signal to this slot.
* The slot receives the `AssetRule` index, old name, and new name. It iterates through the model's `FileRule`s, updates their `target_asset_name_override` where it matches the old name, and emits `dataChanged` for those files.
**Data Model Impact:**
* `AssetRule.asset_name` becomes directly mutable via the GUI.
* The relationship between files and their intended parent asset (represented by `FileRule.target_asset_name_override`) is maintained automatically when the parent asset's name changes.
**Potential Challenges/Considerations:**
* **Performance:** The child update logic requires iterating through potentially all files in the model. For very large datasets, this could be slow. Consider optimizing by maintaining an index/lookup map (`Dict[str, List[FileRule]]`) mapping target asset override names to the list of `FileRule`s using them. This map would need careful updating whenever overrides change or files are moved.
* **Duplicate Asset Names:** The plan includes basic validation in `setData`. Robust handling (e.g., user feedback, preventing the edit) is needed.
* **Undo/Redo:** Reversing an asset name change requires reverting the name *and* reverting all the child `target_asset_name_override` changes, adding complexity.
* **Scope of Child Update:** The current plan updates *any* `FileRule` whose override matches the old name. Confirm if this update should be restricted only to files originally under the renamed asset or within the same `SourceRule`. The current approach seems most logical based on how `target_asset_name_override` works.
```mermaid
sequenceDiagram
participant User
participant View
participant LineEditDelegate
participant UnifiedViewModel
participant AssetNameChangeHandler
User->>View: Edits AssetRule Name in COL_NAME
View->>LineEditDelegate: setModelData(editor, model, index)
LineEditDelegate->>UnifiedViewModel: setData(index, new_name, EditRole)
UnifiedViewModel->>UnifiedViewModel: Validate new_name (no duplicates)
UnifiedViewModel->>UnifiedViewModel: Update AssetRule.asset_name
alt Signal Approach
UnifiedViewModel->>AssetNameChangeHandler: emit assetNameChanged(index, old_name, new_name)
AssetNameChangeHandler->>UnifiedViewModel: Iterate through FileRules
loop For each FileRule where target_override == old_name
AssetNameChangeHandler->>UnifiedViewModel: Update FileRule.target_asset_name_override = new_name
UnifiedViewModel->>View: emit dataChanged(file_rule_target_index)
end
else Direct Approach in setData
UnifiedViewModel->>UnifiedViewModel: Iterate through FileRules
loop For each FileRule where target_override == old_name
UnifiedViewModel->>UnifiedViewModel: Update FileRule.target_asset_name_override = new_name
UnifiedViewModel->>View: emit dataChanged(file_rule_target_index)
end
end
UnifiedViewModel->>View: emit dataChanged(asset_rule_name_index)
```
## Feature 2: Item Type Field Conversion
**Goal:** Replace the `QComboBox` delegate for the "Item Type" column (for `FileRule`s) with a `QLineEdit` that provides auto-suggestions based on defined file types, similar to the existing "Supplier" field.
**Affected Components:**
* `gui/main_window.py` (or view setup location)
* `gui/delegates.py` (Requires a new delegate)
* `gui/unified_view_model.py` (`UnifiedViewModel`)
* `config/app_settings.json` (Source of file type definitions)
**Implementation Steps:**
1. **Create New Delegate (`delegates.py`):**
* Create a new class `ItemTypeSearchDelegate(QStyledItemDelegate)`.
* **`createEditor(self, parent, option, index)`:**
* Create a `QLineEdit` instance.
* Get the list of valid item type keys: `item_keys = index.model()._file_type_keys` (add error handling).
* Create a `QCompleter` using `item_keys` and set it on the `QLineEdit` (configure case sensitivity, filter mode, completion mode as in `SupplierSearchDelegate`).
* Return the editor.
* **`setEditorData(self, editor, index)`:**
* Get the current value using `index.model().data(index, Qt.EditRole)`.
* Set the editor's text (`editor.setText(str(value) if value is not None else "")`).
* **`setModelData(self, editor, model, index)`:**
* Get the `final_text = editor.text().strip()`.
* Determine the `value_to_set = final_text if final_text else None`.
* Call `model.setData(index, value_to_set, Qt.EditRole)`.
* **Important:** Unlike `SupplierSearchDelegate`, do *not* add `final_text` to the list of known types or save anything back to config. Suggestions are strictly based on `config/app_settings.json`.
* **`updateEditorGeometry(self, editor, option, index)`:**
* Standard implementation: `editor.setGeometry(option.rect)`.
2. **Assign Delegate (`main_window.py` / View Setup):**
* Instantiate the new `ItemTypeSearchDelegate`.
* Find where delegates are set for the view.
* Replace the `ComboBoxDelegate` assignment for `UnifiedViewModel.COL_ITEM_TYPE` with the new `ItemTypeSearchDelegate` instance: `view.setItemDelegateForColumn(UnifiedViewModel.COL_ITEM_TYPE, item_type_search_delegate_instance)`.
**Data Model Impact:**
* None. The underlying data (`FileRule.item_type_override`) and its handling remain the same. Only the GUI editor changes.
**Potential Challenges/Considerations:**
* None significant. This is a relatively straightforward replacement of one delegate type with another, leveraging existing patterns from `SupplierSearchDelegate` and data loading from `UnifiedViewModel`.
## Feature 3: Drag-and-Drop File Re-parenting
**Goal:** Enable users to drag one or more `FileRule` rows and drop them onto an `AssetRule` row to change the parent asset of the dragged files.
**Affected Components:**
* `gui/main_panel_widget.py` or `gui/main_window.py` (View management)
* `gui/unified_view_model.py` (`UnifiedViewModel`)
**Implementation Steps:**
1. **Enable Drag/Drop in View (`main_panel_widget.py` / `main_window.py`):**
* Get the `QTreeView` instance (`view`).
* `view.setSelectionMode(QAbstractItemView.ExtendedSelection)` (Allow selecting multiple files)
* `view.setDragEnabled(True)`
* `view.setAcceptDrops(True)`
* `view.setDropIndicatorShown(True)`
* `view.setDefaultDropAction(Qt.MoveAction)`
* `view.setDragDropMode(QAbstractItemView.InternalMove)`
2. **Implement Drag/Drop Support in Model (`UnifiedViewModel`):**
* **`flags(self, index)`:**
* Modify to include `Qt.ItemIsDragEnabled` if `index.internalPointer()` is a `FileRule`.
* Modify to include `Qt.ItemIsDropEnabled` if `index.internalPointer()` is an `AssetRule`.
* Return the combined flags.
* **`supportedDropActions(self)`:**
* Return `Qt.MoveAction`.
* **`mimeData(self, indexes)`:**
* Create `QMimeData`.
* Encode information about the dragged rows (which must be `FileRule`s). Store a list of tuples, each containing `(source_parent_row, source_parent_col, source_row)` for each valid `FileRule` index in `indexes`. Use a custom MIME type (e.g., `"application/x-filerule-index-list"`).
* Return the `QMimeData`.
* **`canDropMimeData(self, data, action, row, column, parent)`:**
* Check if `action == Qt.MoveAction`.
* Check if `data.hasFormat("application/x-filerule-index-list")`.
* Check if `parent.isValid()` and `parent.internalPointer()` is an `AssetRule`.
* Return `True` if all conditions met, `False` otherwise.
* **`dropMimeData(self, data, action, row, column, parent)`:**
* Check `action` and MIME type again for safety.
* Get the target `AssetRule` item: `target_asset = parent.internalPointer()`. If not an `AssetRule`, return `False`.
* Decode the `QMimeData` to get the list of source index information.
* Create a list `files_to_move = []` containing the actual `QModelIndex` objects for the source `FileRule`s (reconstruct them using the decoded info and `self.index()`).
* Iterate through `files_to_move`:
* Get the `source_file_index`.
* Get the `file_item = source_file_index.internalPointer()`.
* Get the `old_parent_asset = getattr(file_item, 'parent_asset', None)`.
* If `target_asset != old_parent_asset`:
* Call `self.moveFileRule(source_file_index, parent)`. This handles the actual move within the model structure and emits `beginMoveRows`/`endMoveRows`.
* **After successful move:** Update the file's override: `file_item.target_asset_name_override = target_asset.asset_name`.
* Emit `self.dataChanged.emit(moved_file_index, moved_file_index, [Qt.DisplayRole, Qt.EditRole])` for the `COL_TARGET_ASSET` column of the *now moved* file (get its new index).
* **Cleanup:** After the loop, identify any original parent `AssetRule`s that became empty as a result of the moves. Call `self.removeAssetRule(empty_asset_rule)` for each.
* Return `True`.
**Data Model Impact:**
* Changes the parentage of `FileRule` items within the model's internal structure.
* Updates `FileRule.target_asset_name_override` to match the `asset_name` of the new parent `AssetRule`, ensuring consistency between the visual structure and the override field.
**Potential Challenges/Considerations:**
* **MIME Data Encoding/Decoding:** Ensure the index information is reliably encoded and decoded, especially handling potential model changes between drag start and drop. Using persistent IDs instead of row/column numbers might be more robust if available.
* **Cleanup Logic:** Reliably identifying and removing empty parent assets after potentially moving multiple files from different original parents requires careful tracking.
* **Transactionality:** If moving multiple files and one part fails, should the whole operation roll back? The current plan doesn't explicitly handle this; errors are logged, and subsequent steps might proceed.
* **Interaction with `AssetRestructureHandler`:** The plan suggests handling the move and override update directly within `dropMimeData`. This means the existing `AssetRestructureHandler` won't be triggered by the override change *during* the drop. Ensure the cleanup logic (removing empty parents) is correctly handled either in `dropMimeData` or by ensuring `moveFileRule` emits signals that the handler *can* use for cleanup.

View File

@ -1,34 +0,0 @@
# Plan to Resolve ISSUE-011: Blender nodegroup script creates empty assets for skipped items
**Issue:** The Blender nodegroup creation script (`blenderscripts/create_nodegroups.py`) creates empty asset entries in the target .blend file for assets belonging to categories that the script is designed to skip, even though it correctly identifies them as skippable.
**Root Cause:** The script creates the parent node group, marks it as an asset, and applies tags *before* checking if the asset category is one that should be skipped for full nodegroup generation.
**Plan:**
1. **Analyze `blenderscripts/create_nodegroups.py` (Completed):** Confirmed that parent group creation and asset marking occur before the asset category skip check.
2. **Modify `blenderscripts/create_nodegroups.py`:**
* Relocate the code block responsible for creating/updating the parent node group, marking it as an asset, and applying tags (currently lines 605-645) to *after* the conditional check `if asset_category not in CATEGORIES_FOR_NODEGROUP_GENERATION:` (line 646).
* This ensures that if an asset's category is in the list of categories to be skipped, the `continue` statement will be hit before any actions are taken to create the parent asset entry in the Blender file.
3. **Testing:**
* Use test assets that represent both categories that *should* and *should not* result in full nodegroup generation based on the `CATEGORIES_FOR_NODEGROUP_GENERATION` list.
* Run the asset processor with these test assets, ensuring the Blender script is executed.
* Inspect the resulting `.blend` file to confirm:
* No `PBRSET_` node groups are created for assets belonging to skipped categories.
* `PBRSET_` node groups are correctly created and populated for assets belonging to categories in `CATEGORIES_FOR_NODEGROUP_GENERATION`.
4. **Update Ticket Status:**
* Once the fix is implemented and verified, update the `Status` field in `Tickets/ISSUE-011-blender-nodegroup-empty-assets.md` to `Resolved`.
**Logic Flow:**
```mermaid
graph TD
A[create_nodegroups.py] --> B{Load Asset Metadata};
B --> C{Determine Asset Category};
C --> D{Is Category Skipped?};
D -- Yes --> E[Exit Processing for Asset];
D -- No --> F{Create/Update Parent Group};
F --> G{Mark as Asset & Add Tags};
G --> H{Proceed with Child Group Creation etc.};

View File

@ -1,68 +0,0 @@
# Map Variant Handling Plan (Revised)
**Goal:**
1. Ensure map types listed in a new `RESPECT_VARIANT_MAP_TYPES` config setting (initially just "COL") always receive a numeric suffix (`-1`, `-2`, etc.), based on their order determined by preset keywords and alphabetical sorting within keywords.
2. Ensure all other map types *never* receive a numeric suffix.
3. Correctly prioritize 16-bit map variants (identified by `bit_depth_variants` in presets) over their 8-bit counterparts, ensuring the 8-bit version is ignored/marked as extra and the 16-bit version is correctly classified ("Mapped") in the GUI preview.
**Affected Files:**
* `config.py`: To define the `RESPECT_VARIANT_MAP_TYPES` list.
* `asset_processor.py`: To modify the classification and suffix assignment logic according to the new rule.
* `Presets/Poliigon.json`: To remove the conflicting pattern.
**Plan Details:**
```mermaid
graph TD
A[Start] --> B(Modify config.py);
B --> C(Modify asset_processor.py);
C --> D(Modify Presets/Poliigon.json);
D --> E{Review Revised Plan};
E -- Approve --> F(Optional: Write Plan to MD);
F --> G(Switch to Code Mode);
E -- Request Changes --> B;
G --> H[End Plan];
subgraph Modifications
B[1. Add RESPECT_VARIANT_MAP_TYPES list to config.py]
C[2. Update suffix logic in asset_processor.py (_inventory_and_classify_files)]
D[3. Remove "*_16BIT*" pattern from move_to_extra_patterns in Presets/Poliigon.json]
end
```
1. **Modify `config.py`:**
* **Action:** Introduce a new configuration list named `RESPECT_VARIANT_MAP_TYPES`.
* **Value:** Initialize it as `RESPECT_VARIANT_MAP_TYPES = ["COL"]`.
* **Location:** Add this near other map-related settings like `STANDARD_MAP_TYPES`.
* **Purpose:** To explicitly define which map types should always respect variant numbering via suffixes.
2. **Modify `asset_processor.py`:**
* **File:** `asset_processor.py`
* **Method:** `_inventory_and_classify_files`
* **Location:** Within Step 5, replacing the suffix assignment logic (currently lines ~470-474).
* **Action:** Implement the new conditional logic for assigning the `final_map_type`.
* **New Logic:** Inside the loop iterating through `final_ordered_candidates` (for each `base_map_type`):
```python
# Determine final map type based on the new rule
if base_map_type in self.config.respect_variant_map_types: # Check the new config list
# Always assign suffix for types in the list
final_map_type = f"{base_map_type}-{i + 1}"
else:
# Never assign suffix for types NOT in the list
final_map_type = base_map_type
# Assign to the final map list entry
final_map_list.append({
"map_type": final_map_type,
# ... rest of the dictionary assignment ...
})
```
* **Purpose:** To implement the strict rule: only types in `RESPECT_VARIANT_MAP_TYPES` get a suffix; all others do not.
3. **Modify `Presets/Poliigon.json`:**
* **File:** `Presets/Poliigon.json`
* **Location:** Within the `move_to_extra_patterns` list (currently line ~28).
* **Action:** Remove the string `"*_16BIT*"`.
* **Purpose:** To prevent premature classification of 16-bit variants as "Extra", allowing the specific 16-bit prioritization logic to function correctly.

View File

@ -1,90 +0,0 @@
# Memory Optimization Plan: Strategy 2 - Load Grayscale Directly
This plan outlines the steps to implement memory optimization strategy #2, which involves loading known grayscale map types directly as grayscale images using OpenCV's `IMREAD_GRAYSCALE` flag. This reduces the memory footprint compared to loading them with `IMREAD_UNCHANGED` and then potentially converting later.
## 1. Identify Target Grayscale Map Types
Define a list of map type names (case-insensitive check recommended during implementation) that should always be treated as single-channel grayscale data.
**Initial List:**
```python
GRAYSCALE_MAP_TYPES = ['HEIGHT', 'ROUGH', 'METAL', 'AO', 'OPC', 'MASK']
```
*(Note: This list might need adjustment based on specific preset configurations or workflow requirements.)*
## 2. Modify `_process_maps` Loading Logic
Locate the primary image loading section within the `_process_maps` method in `asset_processor.py` (around line 608).
**Change:** Before calling `cv2.imread`, determine the correct flag based on the `map_type`:
```python
# (Define GRAYSCALE_MAP_TYPES list earlier in the scope or class)
# ... inside the loop ...
full_source_path = self.temp_dir / source_path_rel
# Determine the read flag
read_flag = cv2.IMREAD_GRAYSCALE if map_type.upper() in GRAYSCALE_MAP_TYPES else cv2.IMREAD_UNCHANGED
log.debug(f"Loading source {source_path_rel.name} with flag: {'GRAYSCALE' if read_flag == cv2.IMREAD_GRAYSCALE else 'UNCHANGED'}")
# Load the image using the determined flag
img_loaded = cv2.imread(str(full_source_path), read_flag)
if img_loaded is None:
raise AssetProcessingError(f"Failed to load image file: {full_source_path.name} with flag {read_flag}")
# ... rest of the processing logic ...
```
## 3. Modify `_merge_maps` Loading Logic
Locate the image loading section within the resolution loop in the `_merge_maps` method (around line 881).
**Change:** Apply the same conditional logic to determine the `imread` flag when loading input maps for merging:
```python
# ... inside the loop ...
input_file_path = self.temp_dir / res_details['path']
# Determine the read flag (reuse GRAYSCALE_MAP_TYPES list)
read_flag = cv2.IMREAD_GRAYSCALE if map_type.upper() in GRAYSCALE_MAP_TYPES else cv2.IMREAD_UNCHANGED
log.debug(f"Loading merge input {input_file_path.name} ({map_type}) with flag: {'GRAYSCALE' if read_flag == cv2.IMREAD_GRAYSCALE else 'UNCHANGED'}")
# Load the image using the determined flag
img = cv2.imread(str(input_file_path), read_flag)
if img is None:
raise AssetProcessingError(f"Failed to load merge input {input_file_path.name} with flag {read_flag}")
# ... rest of the merging logic ...
```
## 4. Verification
During implementation in `code` mode:
* Ensure the `GRAYSCALE_MAP_TYPES` list is defined appropriately (e.g., as a class constant or module-level constant).
* Confirm that downstream code (e.g., stats calculation, channel extraction, data type conversions) correctly handles numpy arrays that might be 2D (grayscale) instead of 3D (BGR/BGRA). The existing code appears to handle this, but it's important to verify.
## Mermaid Diagram of Change
```mermaid
graph TD
subgraph _process_maps
A[Loop through map_info] --> B{Is map_type Grayscale?};
B -- Yes --> C[imread(path, GRAYSCALE)];
B -- No --> D[imread(path, UNCHANGED)];
C --> E[Process Image];
D --> E;
end
subgraph _merge_maps
F[Loop through resolutions] --> G[Loop through required_input_types];
G --> H{Is map_type Grayscale?};
H -- Yes --> I[imread(path, GRAYSCALE)];
H -- No --> J[imread(path, UNCHANGED)];
I --> K[Use Image in Merge];
J --> K;
end
style B fill:#f9f,stroke:#333,stroke-width:2px
style H fill:#f9f,stroke:#333,stroke-width:2px

View File

@ -1,103 +0,0 @@
# Plan: Implement Input-Based Output Format Logic
This plan outlines the steps to modify the Asset Processor Tool to determine the output format of texture maps based on the input file format and specific rules.
## Requirements Summary
Based on user clarifications:
1. **JPG Input -> JPG Output:** If the original source map is a JPG, the output for that map (at all processed resolutions) will also be JPG (8-bit).
2. **TIF Input -> PNG/EXR Output:** If the original source map is a TIF, the output will be PNG (if the target bit depth is 8-bit, or if 16-bit PNG is the configured preference) or EXR (if the target bit depth is 16-bit and EXR is the configured preference).
3. **Other Inputs (PNG, etc.) -> Configured Output:** For other input formats, the output will follow the existing logic based on target bit depth (using configured 16-bit or 8-bit formats, typically EXR/PNG).
4. **`force_8bit` Rule:** If a map type has a `force_8bit` rule, it overrides the input format. Even if the input was 16-bit TIF, the output will be 8-bit PNG.
5. **Merged Maps:** The output format is determined by the highest format in the hierarchy (EXR > TIF > PNG > JPG) based on the *original* formats of the input files used in the merge. However, if the highest format is TIF, the actual output will be PNG/EXR based on the target bit depth. The target bit depth itself is determined separately by the merge rule's `output_bit_depth` setting.
6. **JPG Resizing:** Resized JPGs will be saved as JPG.
## Implementation Plan
**Phase 1: Data Gathering Enhancement**
1. **Modify `_inventory_and_classify_files` in `asset_processor.py`:**
* When classifying map files, extract and store the original file extension (e.g., `.jpg`, `.tif`, `.png`) along with the `source_path`, `map_type`, etc., within the `self.classified_files["maps"]` list.
**Phase 2: Implement New Logic for Individual Maps (`_process_maps`)**
1. **Modify `_process_maps` in `asset_processor.py`:**
* Inside the loop processing each `map_info`:
* Retrieve the stored original file extension.
* Determine the target output bit depth (8 or 16) using the existing logic (`config.get_bit_depth_rule`, source data type).
* **Implement New Format Determination:**
* Initialize `output_format` and `output_ext`.
* Check the `force_8bit` rule first: If the rule is `force_8bit`, set `output_format = 'png'` and `output_ext = '.png'`, regardless of input format.
* If not `force_8bit`:
* If `original_extension == '.jpg'` and `target_bit_depth == 8`: Set `output_format = 'jpg'`, `output_ext = '.jpg'`.
* If `original_extension == '.tif'`:
* If `target_bit_depth == 16`: Determine format (EXR/PNG) and extension based on `config.get_16bit_output_formats()`.
* If `target_bit_depth == 8`: Set `output_format = 'png'`, `output_ext = '.png'`.
* If `original_extension` is neither `.jpg` nor `.tif` (e.g., `.png`):
* If `target_bit_depth == 16`: Determine format (EXR/PNG) and extension based on `config.get_16bit_output_formats()`.
* If `target_bit_depth == 8`: Set `output_format = config.get_8bit_output_format()` (likely 'png'), `output_ext = f".{output_format}"`.
* **Remove Old Logic:** Delete the code block that checks `self.config.resolution_threshold_for_jpg`.
* Set `save_params` based on the *newly determined* `output_format` (e.g., `cv2.IMWRITE_JPEG_QUALITY` for JPG, `cv2.IMWRITE_PNG_COMPRESSION` for PNG).
* Proceed with data type conversion (if needed based on target bit depth and format requirements like EXR needing float16) and saving using `cv2.imwrite` with the determined `output_path_temp` (using the new `output_ext`) and `save_params`. Ensure fallback logic (e.g., EXR -> PNG) still functions correctly if needed.
**Phase 3: Implement New Logic for Merged Maps (`_merge_maps`)**
1. **Modify `_merge_maps` in `asset_processor.py`:**
* Inside the loop for each `current_res_key`:
* When loading input maps (`loaded_inputs`), also retrieve and store their *original* file extensions (obtained during classification and now available via `self.classified_files["maps"]`, potentially needing a lookup based on `res_details['path']` or storing it earlier).
* **Determine Highest Input Format:** Iterate through the original extensions of the loaded inputs for this resolution. Use the hierarchy (EXR > TIF > PNG > JPG) to find the highest format present.
* **Determine Final Output Format:**
* Start with the `highest_input_format`.
* If `highest_input_format == 'tif'`:
* Check the target bit depth determined by the merge rule (`output_bit_depth`).
* If `target_bit_depth == 16`: Set final format based on `config.get_16bit_output_formats()` (EXR/PNG).
* If `target_bit_depth == 8`: Set final format to `png`.
* Otherwise (JPG, PNG, EXR), the final format is the `highest_input_format`.
* Set `output_ext` based on the `final_output_format`.
* Set `save_params` based on the `final_output_format`.
* Proceed with merging channels, converting the merged data to the target bit depth specified by the *merge rule*, and saving using `cv2.imwrite` with the determined `merged_output_path_temp` (using the new `output_ext`) and `save_params`.
**Phase 4: Configuration and Documentation**
1. **Modify `config.py`:**
* Comment out or remove the `RESOLUTION_THRESHOLD_FOR_JPG` variable as it's no longer used. Add a comment explaining why it was removed.
2. **Update `readme.md`:**
* Modify the "Features" section (around line 21) and the "Configuration" section (around lines 36-40, 86) to accurately describe the new output format logic:
* Explain that JPG inputs result in JPG outputs.
* Explain that TIF inputs result in PNG/EXR outputs based on target bit depth and config.
* Explain the merged map format determination based on input hierarchy (with the TIF->PNG/EXR adjustment).
* Mention the removal of the JPG resolution threshold.
## Visual Plan (Mermaid)
```mermaid
graph TD
A[Start] --> B(Phase 1: Enhance Classification);
B --> B1(Store original extension in classified_files['maps']);
B1 --> C(Phase 2: Modify _process_maps);
C --> C1(Get original extension);
C --> C2(Determine target bit depth);
C --> C3(Apply New Format Logic);
C3 -- force_8bit --> C3a[Format=PNG];
C3 -- input=.jpg, 8bit --> C3b[Format=JPG];
C3 -- input=.tif, 16bit --> C3c[Format=EXR/PNG (Config)];
C3 -- input=.tif, 8bit --> C3d[Format=PNG];
C3 -- input=other, 16bit --> C3c;
C3 -- input=other, 8bit --> C3e[Format=PNG (Config)];
C3a --> C4(Remove JPG Threshold Check);
C3b --> C4;
C3c --> C4;
C3d --> C4;
C3e --> C4;
C4 --> C5(Set Save Params & Save);
C5 --> D(Phase 3: Modify _merge_maps);
D --> D1(Get original extensions of inputs);
D --> D2(Find highest format via hierarchy);
D2 --> D3(Adjust TIF -> PNG/EXR based on target bit depth);
D3 --> D4(Determine target bit depth from rule);
D4 --> D5(Set Save Params & Save Merged);
D5 --> E(Phase 4: Config & Docs);
E --> E1(Update config.py - Remove threshold);
E --> E2(Update readme.md);
E2 --> F[End];

View File

@ -1,127 +0,0 @@
# Revised Plan: Implement Input-Based Output Format Logic with JPG Threshold Override
This plan outlines the steps to modify the Asset Processor Tool to determine the output format of texture maps based on the input file format, specific rules, and a JPG resolution threshold override.
## Requirements Summary (Revised)
Based on user clarifications:
1. **JPG Threshold Override:** If the target output bit depth is 8-bit AND the image resolution is greater than or equal to `RESOLUTION_THRESHOLD_FOR_JPG` (defined in `config.py`), the output format **must** be JPG.
2. **Input-Based Logic (if threshold not met):**
* **JPG Input -> JPG Output:** If the original source map is JPG and the target is 8-bit (and below threshold), output JPG.
* **TIF Input -> PNG/EXR Output:** If the original source map is TIF:
* If target is 16-bit, output EXR or PNG based on `OUTPUT_FORMAT_16BIT_PRIMARY` config.
* If target is 8-bit (and below threshold), output PNG.
* **Other Inputs (PNG, etc.) -> Configured Output:** For other input formats (and below threshold if 8-bit):
* If target is 16-bit, output EXR or PNG based on `OUTPUT_FORMAT_16BIT_PRIMARY` config.
* If target is 8-bit, output PNG (or format specified by `OUTPUT_FORMAT_8BIT`).
3. **`force_8bit` Rule:** If a map type has a `force_8bit` rule, the target bit depth is 8-bit. The output format will then be determined by the JPG threshold override or the input-based logic (resulting in JPG or PNG).
4. **Merged Maps:**
* Determine target `output_bit_depth` from the merge rule (`respect_inputs`, `force_8bit`, etc.).
* **Check JPG Threshold Override:** If target `output_bit_depth` is 8-bit AND resolution >= threshold, the final output format is JPG.
* **Else (Hierarchy Logic):** Determine the highest format among original inputs (EXR > TIF > PNG > JPG).
* If highest was TIF, adjust based on target bit depth (16-bit -> EXR/PNG config; 8-bit -> PNG).
* Otherwise, use the highest format found (EXR, PNG, JPG).
* **JPG 8-bit Check:** If the final format is JPG but the target bit depth was 16, force the merged data to 8-bit before saving.
5. **JPG Resizing:** Resized JPGs will be saved as JPG if the logic determines JPG as the output format.
## Implementation Plan (Revised)
**Phase 1: Data Gathering Enhancement** (Already Done & Correct)
* `_inventory_and_classify_files` stores the original file extension in `self.classified_files["maps"]`.
**Phase 2: Modify `_process_maps`**
1. Retrieve the `original_extension` from `map_info`.
2. Determine the target `output_bit_depth` (8 or 16).
3. Get the `threshold = self.config.resolution_threshold_for_jpg`.
4. Get the `target_dim` for the current resolution loop iteration.
5. **New Format Logic (Revised):**
* Initialize `output_format`, `output_ext`, `save_params`, `needs_float16`.
* **Check JPG Threshold Override:**
* If `output_bit_depth == 8` AND `target_dim >= threshold`: Set format to JPG, set JPG params.
* **Else (Apply Input/Rule-Based Logic):**
* If `bit_depth_rule == 'force_8bit'`: Set format to PNG (8-bit), set PNG params.
* Else if `original_extension == '.jpg'` and `output_bit_depth == 8`: Set format to JPG, set JPG params.
* Else if `original_extension == '.tif'`:
* If `output_bit_depth == 16`: Set format to EXR/PNG (16-bit config), set params, set `needs_float16` if EXR.
* If `output_bit_depth == 8`: Set format to PNG, set PNG params.
* Else (other inputs like `.png`):
* If `output_bit_depth == 16`: Set format to EXR/PNG (16-bit config), set params, set `needs_float16` if EXR.
* If `output_bit_depth == 8`: Set format to PNG (8-bit config), set PNG params.
6. Proceed with data type conversion and saving.
**Phase 3: Modify `_merge_maps`**
1. Retrieve original extensions of inputs (`input_original_extensions`).
2. Determine target `output_bit_depth` from the merge rule.
3. Get the `threshold = self.config.resolution_threshold_for_jpg`.
4. Get the `target_dim` for the current resolution loop iteration.
5. **New Format Logic (Revised):**
* Initialize `final_output_format`, `output_ext`, `save_params`, `needs_float16`.
* **Check JPG Threshold Override:**
* If `output_bit_depth == 8` AND `target_dim >= threshold`: Set `final_output_format = 'jpg'`.
* **Else (Apply Hierarchy/Rule-Based Logic):**
* Determine `highest_input_format` (EXR > TIF > PNG > JPG).
* Start with `final_output_format = highest_input_format`.
* If `highest_input_format == 'tif'`: Adjust based on target bit depth (16->EXR/PNG config; 8->PNG).
* Set `output_format = final_output_format`.
* Set `output_ext`, `save_params`, `needs_float16` based on `output_format`.
* **JPG 8-bit Check:** If `output_format == 'jpg'` and `output_bit_depth == 16`, force final merged data to 8-bit before saving and update `output_bit_depth` variable.
6. Proceed with merging, data type conversion, and saving.
**Phase 4: Configuration and Documentation**
1. **Modify `config.py`:** Ensure `RESOLUTION_THRESHOLD_FOR_JPG` is uncommented and set correctly (revert previous change).
2. **Update `readme.md`:** Clarify the precedence: 8-bit maps >= threshold become JPG, otherwise the input-based logic applies.
## Visual Plan (Mermaid - Revised)
```mermaid
graph TD
A[Start] --> B(Phase 1: Enhance Classification - Done);
B --> C(Phase 2: Modify _process_maps);
C --> C1(Get original extension);
C --> C2(Determine target bit depth);
C --> C3(Get target_dim & threshold);
C --> C4{8bit AND >= threshold?};
C4 -- Yes --> C4a[Format=JPG];
C4 -- No --> C5(Apply Input/Rule Logic);
C5 -- force_8bit --> C5a[Format=PNG];
C5 -- input=.jpg, 8bit --> C5b[Format=JPG];
C5 -- input=.tif, 16bit --> C5c[Format=EXR/PNG (Config)];
C5 -- input=.tif, 8bit --> C5d[Format=PNG];
C5 -- input=other, 16bit --> C5c;
C5 -- input=other, 8bit --> C5e[Format=PNG (Config)];
C4a --> C6(Set Save Params & Save);
C5a --> C6;
C5b --> C6;
C5c --> C6;
C5d --> C6;
C5e --> C6;
C6 --> D(Phase 3: Modify _merge_maps);
D --> D1(Get original extensions of inputs);
D --> D2(Determine target bit depth from rule);
D --> D3(Get target_dim & threshold);
D --> D4{8bit AND >= threshold?};
D4 -- Yes --> D4a[FinalFormat=JPG];
D4 -- No --> D5(Apply Hierarchy Logic);
D5 --> D5a(Find highest input format);
D5a --> D5b{Highest = TIF?};
D5b -- Yes --> D5c{Target 16bit?};
D5c -- Yes --> D5d[FinalFormat=EXR/PNG (Config)];
D5c -- No --> D5e[FinalFormat=PNG];
D5b -- No --> D5f[FinalFormat=HighestInput];
D4a --> D6(Set Save Params);
D5d --> D6;
D5e --> D6;
D5f --> D6;
D6 --> D7{Format=JPG AND Target=16bit?};
D7 -- Yes --> D7a(Force data to 8bit);
D7 -- No --> D8(Save Merged);
D7a --> D8;
D8 --> E(Phase 4: Config & Docs);
E --> E1(Uncomment threshold in config.py);
E --> E2(Update readme.md);
E2 --> F[End];

View File

@ -1,52 +0,0 @@
3. Tab Breakdown and Widget Specifications:
Tab 1: General
OUTPUT_BASE_DIR: QLineEdit + QPushButton (opens QFileDialog.getExistingDirectory). Label: "Output Base Directory".
EXTRA_FILES_SUBDIR: QLineEdit. Label: "Subdirectory for Extra Files".
METADATA_FILENAME: QLineEdit. Label: "Metadata Filename".
Tab 2: Output & Naming
TARGET_FILENAME_PATTERN: QLineEdit. Label: "Output Filename Pattern". (Tooltip explaining placeholders recommended).
STANDARD_MAP_TYPES: QListWidget + "Add"/"Remove" QPushButtons. Label: "Standard Map Types".
RESPECT_VARIANT_MAP_TYPES: QLineEdit. Label: "Map Types Respecting Variants (comma-separated)".
ASPECT_RATIO_DECIMALS: QSpinBox (Min: 0, Max: ~6). Label: "Aspect Ratio Precision (Decimals)".
Tab 3: Image Processing
IMAGE_RESOLUTIONS: QTableWidget (Columns: "Name", "Resolution (px)") + "Add Row"/"Remove Row" QPushButtons. Label: "Defined Image Resolutions".
CALCULATE_STATS_RESOLUTION: QComboBox (populated from IMAGE_RESOLUTIONS keys). Label: "Resolution for Stats Calculation".
PNG_COMPRESSION_LEVEL: QSpinBox (Range: 0-9). Label: "PNG Compression Level".
JPG_QUALITY: QSpinBox (Range: 1-100). Label: "JPG Quality".
RESOLUTION_THRESHOLD_FOR_JPG: QComboBox (populated from IMAGE_RESOLUTIONS keys + "Never"/"Always"). Label: "Use JPG Above Resolution".
OUTPUT_FORMAT_8BIT: QComboBox (Options: "png", "jpg"). Label: "Output Format (8-bit)".
OUTPUT_FORMAT_16BIT_PRIMARY: QComboBox (Options: "png", "exr", "tif"). Label: "Primary Output Format (16-bit+)".
OUTPUT_FORMAT_16BIT_FALLBACK: QComboBox (Options: "png", "exr", "tif"). Label: "Fallback Output Format (16-bit+)".
Tab 4: Definitions (Overall QVBoxLayout)
Top Widget: DEFAULT_ASSET_CATEGORY: QComboBox (populated dynamically from Asset Types table below). Label: "Default Asset Category".
Bottom Widget: Inner QTabWidget:
Inner Tab 1: Asset Types
ASSET_TYPE_DEFINITIONS: QTableWidget (Columns: "Type Name", "Description", "Color", "Examples (comma-sep.)") + "Add Row"/"Remove Row" QPushButtons.
"Color" cell: QPushButton opening QColorDialog, button background shows color. Use QStyledItemDelegate.
"Examples" cell: Editable QLineEdit.
Inner Tab 2: File Types
FILE_TYPE_DEFINITIONS: QTableWidget (Columns: "Type ID", "Description", "Color", "Examples (comma-sep.)", "Standard Type", "Bit Depth Rule") + "Add Row"/"Remove Row" QPushButtons.
"Color" cell: QPushButton opening QColorDialog. Use QStyledItemDelegate.
"Examples" cell: Editable QLineEdit.
"Standard Type" cell: QComboBox (populated from STANDARD_MAP_TYPES + empty option). Use QStyledItemDelegate.
"Bit Depth Rule" cell: QComboBox (Options: "respect", "force_8bit"). Use QStyledItemDelegate.
Tab 5: Map Merging
Layout: QHBoxLayout.
Left Side: QListWidget displaying output_map_type for each rule. "Add Rule"/"Remove Rule" QPushButtons below. Label: "Merge Rules".
Right Side: QStackedWidget or dynamically populated QWidget showing details for the selected rule.
Rule Detail Form:
output_map_type: QLineEdit. Label: "Output Map Type Name".
inputs: QTableWidget (Fixed Rows: R, G, B, A. Columns: "Channel", "Input Map Type"). Label: "Channel Inputs". "Input Map Type" cell: QComboBox (populated from STANDARD_MAP_TYPES).
defaults: QTableWidget (Fixed Rows: R, G, B, A. Columns: "Channel", "Default Value"). Label: "Channel Defaults (if input missing)". "Default Value" cell: QDoubleSpinBox (Range: 0.0 - 1.0).
output_bit_depth: QComboBox (Options: "respect_inputs", "force_8bit", "force_16bit"). Label: "Output Bit Depth".
Tab 6: Postprocess Scripts
DEFAULT_NODEGROUP_BLEND_PATH: QLineEdit + QPushButton (opens QFileDialog.getOpenFileName, filter: "*.blend"). Label: "Default Node Group Library (.blend)".
DEFAULT_MATERIALS_BLEND_PATH: QLineEdit + QPushButton (opens QFileDialog.getOpenFileName, filter: "*.blend"). Label: "Default Materials Library (.blend)".
BLENDER_EXECUTABLE_PATH: QLineEdit + QPushButton (opens QFileDialog.getOpenFileName). Label: "Blender Executable Path".

View File

@ -1,139 +0,0 @@
---
ID: FEAT-008
Type: Feature
Status: Backlog
Priority: Medium
Labels: [gui, enhancement]
Created: 2025-04-22
Updated: 2025-04-22
Related: gui/main_window.py, gui/prediction_handler.py
---
# [FEAT-008]: Refine GUI Preview Table Display and Sorting
## Description
Enhance the usability and clarity of the GUI's detailed file preview table. This involves improving the default sorting to group by asset and then status, removing redundant information, simplifying status text, and rearranging columns.
## Current Behavior
* The preview table lists all files from all assets together without clear grouping.
* The table does not sort by status by default, or the sort order isn't optimized for clarity.
* The table includes a "Predicted Output" column which is considered redundant.
* Status text can be verbose (e.g., "unmatched extra", "[Unmatched Extra (Regex match: #####)]", "Ignored (Superseed by 16bit variant for ####)").
* The "Original Path" column is not necessarily the rightmost column.
## Desired Behavior / Goals
* The preview table should group files by the asset they belong to (e.g., based on the input ZIP/folder name or derived asset name).
* Within each asset group, the table should sort rows by 'Status' by default.
* The default secondary sort order for 'Status' should prioritize actionable or problematic statuses, grouping models with their maps: Error > (Mapped & Model) > Ignored > Extra. (Note: Assumes 'Unrecognised' files are displayed as 'Extra').
* Within the 'Mapped & Model' group, files should be sorted alphabetically by original path or filename to keep related items together.
* The "Predicted Output" column should be removed from the table view.
* Status display text should be made more concise:
* "unmatched extra" should be displayed as "Extra".
* "[Unmatched Extra (Regex match: #####)]" should be displayed as "[Extra=#####]".
* "Ignored (Superseed by 16bit variant for ####)" should be displayed as "Superseeded by 16bit ####".
* Other statuses ("Mapped", "Model", "Error") should remain clear.
* The "Original Path" column should be positioned as the rightmost column in the table.
## Implementation Notes (Optional)
* Modifications will likely be needed in `gui/main_window.py` (table view setup, column management, data population, sorting proxy model) and `gui/prediction_handler.py` (ensure prediction results include the source asset identifier).
* The table model (`QAbstractTableModel` or similar) needs to store the source asset identifier for each file row.
* Implement a custom multi-level sorting logic using `QSortFilterProxyModel`.
* The primary sort key will be the asset identifier.
* The secondary sort key will be the status, mapping 'Mapped' and 'Model' to the same priority level.
* The tertiary sort key will be the original path/filename.
* Update the column hiding/showing logic to remove the "Predicted Output" column.
* Implement string formatting or replacement logic for the status display text.
* Adjust the column order.
* Consider the performance implications of grouping and multi-level sorting, especially with a large number of assets/files. Add necessary optimizations (e.g., efficient data structures, potentially deferring sorting until needed).
## Acceptance Criteria (Optional)
* [ ] When assets are added to the queue and detailed preview is active, the table visually groups files by their source asset.
* [ ] Within each asset group, the table automatically sorts by the Status column according to the specified multi-level order.
* [ ] Clicking the Status header cycles through ascending/descending sort based on the custom multi-level order: Asset > Status (Error > (Mapped & Model) > Ignored > Extra) > Filename.
* [ ] The "Predicted Output" column is not present in the detailed preview table.
* [ ] Status text for relevant items displays concisely as: "Extra", "[Extra=#####]", "Superseeded by 16bit ####".
* [ ] The "Original Path" column is visually the last column on the right side of the table.
---
## Implementation Plan (Generated 2025-04-22)
This plan outlines the steps to implement the GUI preview refinements described in this ticket.
**Goal:** Enhance the GUI's detailed file preview table for better usability and clarity by implementing grouping, refined sorting, simplified status text, and adjusted column layout.
**Affected Files:**
* `gui/main_window.py`: Handles table view setup, data population trigger, and column management.
* `gui/preview_table_model.py`: Contains the table model (`PreviewTableModel`) and the sorting proxy model (`PreviewSortFilterProxyModel`).
**Plan Steps:**
1. **Correct Data Population in `main_window.py`:**
* **Action:** Modify the `on_prediction_results_ready` slot (around line 893).
* **Change:** Update the code to call `self.preview_model.set_data(results)` instead of populating the old `self.preview_table`.
* **Remove:** Delete code manually setting headers, rows, and items on `self.preview_table`.
* **Keep:** Retain the initial setup of `self.preview_table_view` (lines 400-424).
2. **Implement Status Text Simplification in `preview_table_model.py`:**
* **Action:** Modify the `data()` method within the `PreviewTableModel` class (around line 56).
* **Change:** Inside the `if role == Qt.ItemDataRole.DisplayRole:` block for `COL_STATUS`, add logic to transform the raw status string:
* "Unmatched Extra" -> "Extra"
* "[Unmatched Extra (Regex match: PATTERN)]" -> "[Extra=PATTERN]"
* "Ignored (Superseed by 16bit variant for FILENAME)" -> "Superseeded by 16bit FILENAME"
* Otherwise, return original status.
* **Note:** Ensure `ROLE_RAW_STATUS` still returns the original status for sorting.
3. **Refine Sorting Logic in `preview_table_model.py`:**
* **Action:** Modify the `lessThan` method within the `PreviewSortFilterProxyModel` class (around line 166).
* **Change:** Adjust the "Level 2: Sort by Status" logic to use a priority mapping where "Mapped" and "Model" have the same priority index, causing the sort to fall through to Level 3 (Path) for items within this group.
```python
# Example Priority Mapping
STATUS_PRIORITY = {
"Error": 0,
"Mapped": 1,
"Model": 1,
"Ignored": 2,
"Extra": 3,
"Unrecognised": 3,
"Unmatched Extra": 3,
"[No Status]": 99
}
# ... comparison logic using STATUS_PRIORITY ...
```
* **Remove/Update:** Replace the old `STATUS_ORDER` list with this priority dictionary logic.
4. **Adjust Column Order in `main_window.py`:**
* **Action:** Modify the `setup_main_panel_ui` method (around lines 404-414).
* **Change:** After setting up `self.preview_table_view`, explicitly move the "Original Path" column to the last visual position using `header.moveSection()`.
* **Verify:** Ensure the "Predicted Output" column remains hidden.
**Visual Plan (Mermaid):**
```mermaid
graph TD
A[Start FEAT-008 Implementation] --> B(Refactor `main_window.py::on_prediction_results_ready`);
B --> C{Use `self.preview_model.set_data()`?};
C -- Yes --> D(Remove manual `QTableWidget` population);
C -- No --> E[ERROR: Incorrect data flow];
D --> F(Modify `preview_table_model.py::PreviewTableModel::data()`);
F --> G{Implement Status Text Simplification?};
G -- Yes --> H(Add formatting logic for DisplayRole);
G -- No --> I[ERROR: Status text not simplified];
H --> J(Modify `preview_table_model.py::PreviewSortFilterProxyModel::lessThan()`);
J --> K{Implement Mapped/Model Grouping & Custom Sort Order?};
K -- Yes --> L(Use priority mapping for status comparison);
K -- No --> M[ERROR: Sorting incorrect];
L --> N(Modify `main_window.py::setup_main_panel_ui()`);
N --> O{Move 'Original Path' Column to End?};
O -- Yes --> P(Use `header.moveSection()`);
O -- No --> Q[ERROR: Column order incorrect];
P --> R(Verify All Acceptance Criteria);
R --> S[End FEAT-008 Implementation];
subgraph main_window.py Modifications
B; D; N; P;
end
subgraph preview_table_model.py Modifications
F; H; J; L;
end

View File

@ -1,76 +0,0 @@
# FEAT-009: GUI - Unify Preset Editor Selection and Processing Preset
**Status:** Resolved
**Priority:** Medium
**Assigned:** TBD
**Reporter:** Roo (Architect Mode)
**Date:** 2025-04-22
## Description
This ticket proposes a GUI modification to streamline the preset handling workflow by unifying the preset selection mechanism. Currently, the preset selected for editing in the left panel can be different from the preset selected for processing/previewing in the right panel. This change aims to eliminate this duality, making the preset loaded in the editor the single source of truth for both editing and processing actions.
## Current Behavior
1. **Preset Editor Panel (Left):** Users select a preset from the 'Preset List'. This action loads the selected preset's details into the 'Preset Editor Tabs' for viewing or modification (See `readme.md` lines 257-260).
2. **Processing Panel (Right):** A separate 'Preset Selector' dropdown exists. Users select a preset from this dropdown *specifically* for processing the queued assets and generating the file preview in the 'Preview Table' (See `readme.md` lines 262, 266).
3. This allows for a scenario where the preset being edited is different from the preset used for previewing and processing, potentially causing confusion.
## Proposed Behavior
1. **Unified Selection:** Selecting a preset from the 'Preset List' in the left panel will immediately make it the active preset for *both* editing (loading its details into the 'Preset Editor Tabs') *and* processing/previewing.
2. **Dropdown Removal:** The separate 'Preset Selector' dropdown in the right 'Processing Panel' will be removed.
3. **Dynamic Updates:** The 'Preview Table' in the right panel will dynamically update based on the preset selected in the left panel's 'Preset List'.
4. **Processing Logic:** The 'Start Processing' action will use the currently active preset (selected from the left panel's list).
## Rationale
* **Improved User Experience:** Simplifies the UI by removing a redundant control and creates a more intuitive workflow.
* **Reduced Complexity:** Eliminates the need to manage two separate preset states (editing vs. processing).
* **Consistency:** Ensures that the preview and processing actions always reflect the preset currently being viewed/edited.
## Implementation Notes/Tasks
* **UI Modification (`gui/main_window.py`):**
* Remove the `QComboBox` (or similar widget) used for the 'Preset Selector' from the right 'Processing Panel' layout.
* **Signal/Slot Connection (`gui/main_window.py`):**
* Ensure the signal emitted when a preset is selected in the left panel's 'Preset List' (`QListWidget` or similar) is connected to slots responsible for:
* Loading the preset into the editor tabs.
* Updating the application's state to reflect the new active preset for processing.
* Triggering an update of the 'Preview Table'.
* **State Management:**
* Modify how the currently active preset for processing is stored and accessed. It should now directly reference the preset selected in the left panel list.
* **Handler Updates (`gui/prediction_handler.py`, `gui/processing_handler.py`):**
* Ensure these handlers correctly retrieve and use the single, unified active preset state when generating previews or initiating processing.
## Acceptance Criteria
1. The 'Preset Selector' dropdown is no longer visible in the right 'Processing Panel'.
2. Selecting a preset in the 'Preset List' (left panel) successfully loads its details into the 'Preset Editor Tabs'.
3. Selecting a preset in the 'Preset List' (left panel) updates the 'Preview Table' (right panel) to reflect the rules of the newly selected preset.
4. Initiating processing via the 'Start Processing' button uses the preset currently selected in the 'Preset List' (left panel).
5. The application remains stable and performs processing correctly with the unified preset selection.
## Workflow Diagram (Mermaid)
```mermaid
graph TD
subgraph "Current GUI"
A[Preset List (Left Panel)] -- Selects --> B(Preset Editor Tabs);
C[Preset Selector Dropdown (Right Panel)] -- Selects --> D(Active Processing Preset);
D -- Affects --> E(Preview Table);
D -- Used by --> F(Processing Logic);
end
subgraph "Proposed GUI"
G[Preset List (Left Panel)] -- Selects --> H(Preset Editor Tabs);
G -- Also Sets --> I(Active Preset for Editing & Processing);
I -- Affects --> J(Preview Table);
I -- Used by --> K(Processing Logic);
L(Preset Selector Dropdown Removed);
end
A --> G;
B --> H;
E --> J;
F --> K;

View File

@ -1,49 +0,0 @@
# FEAT-GUI-NoDefaultPreset: Prevent Default Preset Selection in GUI
## Objective
Modify the Graphical User Interface (GUI) to prevent any preset from being selected by default on application startup. Instead, the GUI should prompt the user to explicitly select a preset from the list before displaying the detailed file preview. This aims to avoid accidental processing with an unintended preset.
## Problem Description
Currently, when the GUI application starts, a preset from the available list is automatically selected. This can lead to user confusion if they are not aware of this behavior and proceed to add assets and process them using a preset they did not intend to use. The preview table also populates automatically based on this default selection, which might not be desired until a conscious preset choice is made.
## Failed Attempts
1. **Attempt 1: Remove Default Selection Logic and Add Placeholder Text:**
* **Approach:** Removed code that explicitly set a default selected item in the preset list during initialization. Added a `QLabel` with placeholder text ("Please select a preset...") to the preview area and attempted to use `setPlaceholderText` on the `QTableView` (this was incorrect as `QTableView` does not have this method). Managed visibility of the placeholder label and table view.
* **Result:** The `setPlaceholderText` call failed with an `AttributeError`. Even after removing the erroneous line and adding a dedicated `QLabel`, a preset was still being selected automatically in the list on startup, and the placeholder was not consistently shown. This suggested that simply populating the `QListWidget` might implicitly trigger a selection.
2. **Attempt 2: Explicitly Clear Selection and Refine Visibility Logic:**
* **Approach:** Added explicit calls (`setCurrentItem(None)`, `clearSelection()`) after populating the preset list to ensure no item was selected. Refined the visibility logic for the placeholder label and table view in `_clear_editor` and `_load_selected_preset_for_editing`. Added logging to track selection and visibility changes.
* **Result:** Despite explicitly clearing the selection, testing indicated that a preset was still being selected on startup, and the placeholder was not consistently displayed. This reinforced the suspicion that the `QListWidget`'s behavior upon population was automatically triggering a selection and the associated signal.
## Proposed Plan: Implement "-- Select a Preset --" Placeholder Item
This approach makes the "no selection" state an explicit, selectable item in the preset list, giving us more direct control over the initial state and subsequent behavior.
1. **Modify `populate_presets` Method:**
* Add a `QListWidgetItem` with the text "-- Select a Preset --" at the very beginning of the list (index 0) after clearing the list but before adding actual preset items.
* Store a special, non-`Path` value (e.g., `None` or a unique string like `"__PLACEHOLDER__"`) in this placeholder item's `UserRole` data to distinguish it from real presets.
* After adding all real preset items, explicitly set the current item to this placeholder item using `self.editor_preset_list.setCurrentRow(0)`.
2. **Modify `_load_selected_preset_for_editing` Method (Slot for `currentItemChanged`):**
* At the beginning of the method, check if the `current_item` is the placeholder item by examining its `UserRole` data.
* If the placeholder item is selected:
* Call `self._clear_editor()` to reset all editor fields.
* Call `self.preview_model.clear_data()` to ensure the preview table model is empty.
* Explicitly set `self.preview_placeholder_label.setVisible(True)` and `self.preview_table_view.setVisible(False)`.
* Return from the method without proceeding to load a preset or call `update_preview`.
* If a real preset item is selected, proceed with the existing logic: get the `Path` from the item's data, call `_load_preset_for_editing(preset_path)`, call `self.update_preview()`, set `self.preview_placeholder_label.setVisible(False)` and `self.preview_table_view.setVisible(True)`.
3. **Modify `start_processing` Method:**
* Before initiating the processing, check if the currently selected item in `editor_preset_list` is the placeholder item.
* If the placeholder item is selected, display a warning message to the user (e.g., "Please select a valid preset before processing.") using the status bar and return from the method.
* If a real preset is selected, proceed with the existing processing logic.
4. **Modify `update_preview` Method:**
* Add a check at the beginning of the method. Get the `current_item` from `editor_preset_list`. If it is the placeholder item, clear the preview model (`self.preview_model.clear_data()`) and return immediately. This prevents the prediction handler from running when no valid preset is selected.
## Next Steps
Implement the proposed plan by modifying the specified methods in `gui/main_window.py`. Test the GUI on startup and when selecting different items in the preset list to ensure the desired behavior is achieved.

View File

@ -1,38 +0,0 @@
---
ID: ISSUE-004
Type: Issue
Status: Resolved
Priority: High
Labels: [bug, core, image-processing]
Created: 2025-04-22
Updated: 2025-04-22
Related:
---
# [ISSUE-004]: Color channel swapping in image processing (Normal Maps, Stats)
## Description
There appears to be a general issue with how color channels (specifically Red and Blue) are handled in several parts of the image processing pipeline. This has been observed in normal map channel packing and potentially in the calculation of image statistics per channel, where the Red and Blue channels seem to be swapped relative to their intended order.
## Current Behavior
When processing images where individual color channels are accessed or manipulated (e.g., during normal map channel packing or calculating per-channel image statistics), the Red and Blue channels appear to be swapped. For example, in normal map packing, the channel intended for Blue might contain Red data, and vice versa, while Green remains correct. This suggests a consistent R/B channel inversion issue in the underlying image processing logic.
## Desired Behavior / Goals
The tool should correctly handle color channels according to standard RGB ordering in all image processing operations, including channel packing and image statistics calculation. The Red, Green, and Blue channels should consistently correspond to their intended data.
## Implementation Notes (Optional)
(This likely points to an issue in the image loading, channel splitting, merging, or processing functions, possibly related to the library used for image manipulation (e.g., OpenCV). Need to investigate how channels are accessed and ordered in relevant code sections like `_process_maps`, `_merge_maps`, and image statistics calculation.)
## Acceptance Criteria (Optional)
* [ ] Process an asset with a normal map and a map requiring channel packing (e.g., NRMRGH).
* [ ] Verify that the channels in the output normal map and packed map are in the correct R, G, B order.
* [ ] Verify that the calculated image statistics (Min/Max/Mean) for Red and Blue channels accurately reflect the data in those specific channels, not the swapped data.
## Resolution
The root cause was identified as a mismatch between OpenCV's default BGR channel order upon image loading (`cv2.imread`) and subsequent code assuming an RGB channel order, particularly in channel indexing during map merging (`_merge_maps`) and potentially in statistics calculation (`_calculate_image_stats`).
The fix involved the following changes in `asset_processor.py`:
1. **BGR to RGB Conversion:** Immediately after loading a 3-channel image using `cv2.imread` in the `_process_maps` function, the image is converted to RGB color space using `img_processed = cv2.cvtColor(img_loaded, cv2.COLOR_BGR2RGB)`. Grayscale or 4-channel images are handled appropriately without conversion.
2. **Updated Channel Indexing:** The channel indexing logic within the `_merge_maps` function was updated to reflect the RGB order (Red = index 0, Green = index 1, Blue = index 2) when extracting channels from the now-RGB source images for merging.
3. **Statistics Assumption Update:** Comments in `_calculate_image_stats` were updated to reflect that the input data is now expected in RGB order.
This ensures consistent RGB channel ordering throughout the processing pipeline after the initial load.

View File

@ -1,83 +0,0 @@
---
ID: ISSUE-010
Type: Issue
Status: Resolved
Priority: High
Labels: [bug, core, image-processing, regression, resolved]
Created: 2025-04-22
Updated: 2025-04-22
Related: #ISSUE-004, asset_processor.py
---
# [ISSUE-010]: Color Channel Swapping Regression for RGB/Merged Maps after ISSUE-004 Fix
## Description
The resolution implemented for `ISSUE-004` successfully corrected the BGR/RGB channel handling for image statistics calculation and potentially normal map packing. However, this fix appears to have introduced a regression where standard RGB color maps and maps generated through merging operations are now being saved with swapped Blue and Red channels (BGR order) instead of the expected RGB order.
## Current Behavior
- Standard RGB texture maps (e.g., Diffuse, Albedo) loaded and processed are saved with BGR channel order.
- Texture maps created by merging channels (e.g., ORM, NRMRGH) are saved with BGR channel order for their color components.
- Image statistics (`metadata.json`) are calculated correctly based on RGB data.
- Grayscale maps are handled correctly.
- RGBA maps are assumed to be handled correctly (needs verification).
## Desired Behavior / Goals
- All processed color texture maps (both original RGB and merged maps) should be saved with the standard RGB channel order.
- Image statistics should continue to be calculated correctly based on RGB data.
- Grayscale and RGBA maps should retain their correct handling.
- The fix should not reintroduce the problems solved by `ISSUE-004`.
## Implementation Notes (Optional)
The issue likely stems from the universal application of `cv2.cvtColor(img_loaded, cv2.COLOR_BGR2RGB)` in `_process_maps` introduced in the `ISSUE-004` fix. While this ensures consistent RGB data for internal operations like statistics and merging (which now expects RGB), the final saving step (`cv2.imwrite`) might implicitly expect BGR data for color images, or the conversion needs to be selectively undone before saving certain map types.
Investigation needed in `asset_processor.py`:
- Review `_process_maps`: Where is the BGR->RGB conversion happening? Is it applied to all 3-channel images?
- Review `_process_maps` saving logic: How are different map types saved? Does `cv2.imwrite` expect RGB or BGR?
- Review `_merge_maps`: How are channels combined? Does the saving logic here also need adjustment?
- Determine if the BGR->RGB conversion should be conditional or if a final RGB->BGR conversion is needed before saving specific map types.
## Acceptance Criteria (Optional)
* [x] Process an asset containing standard RGB maps (e.g., Color/Albedo). Verify the output map has correct RGB channel order.
* [x] Process an asset requiring map merging (e.g., ORM from Roughness, Metallic, AO). Verify the output merged map has correct RGB(A) channel order.
* [x] Verify image statistics in `metadata.json` remain correct (reflecting RGB values).
* [x] Verify grayscale maps are processed and saved correctly.
* [x] Verify normal maps are processed and saved correctly (as per ISSUE-004 fix).
* [ ] (Optional) Verify RGBA maps are processed and saved correctly.
---
## Resolution
The issue was resolved by implementing conditional RGB to BGR conversion immediately before saving images using `cv2.imwrite` within the `_process_maps` and `_merge_maps` methods in `asset_processor.py`.
The logic checks if the image is 3-channel and if the target output format is *not* EXR. If both conditions are true, the image data is converted from the internal RGB representation back to BGR, which is the expected channel order for `cv2.imwrite` when saving formats like PNG, JPG, and TIF.
This approach ensures that color maps are saved with the correct channel order in standard formats while leaving EXR files (which handle RGB correctly) and grayscale/single-channel images unaffected. It also preserves the internal RGB representation used for accurate image statistics calculation and channel merging, thus not reintroducing the issues fixed by `ISSUE-004`.
## Implementation Plan (Generated 2025-04-22)
**Goal:** Correct the BGR/RGB channel order regression for saved color maps (introduced by the `ISSUE-004` fix) while maintaining the correct handling for statistics, grayscale maps, and EXR files.
**Core Idea:** Convert the image data back from RGB to BGR *just before* saving with `cv2.imwrite`, but only for 3-channel images and formats where this conversion is necessary (e.g., PNG, JPG, TIF, but *not* EXR).
**Plan Steps:**
1. **Modify `_process_maps` Saving Logic (`asset_processor.py`):**
* Before the primary `cv2.imwrite` call (around line 1182) and the fallback call (around line 1206), add logic to check if the image is 3-channel and the output format is *not* 'exr'. If true, convert the image from RGB to BGR using `cv2.cvtColor` and add a debug log message.
2. **Modify `_merge_maps` Saving Logic (`asset_processor.py`):**
* Apply the same conditional RGB to BGR conversion logic before the primary `cv2.imwrite` call (around line 1505) and the fallback call (around line 1531), including a debug log message.
3. **Verification Strategy:**
* Test with various 3-channel color maps (Albedo, Emission, etc.) and merged maps (NRMRGH, etc.) saved as PNG/JPG/TIF.
* Verify correct RGB order in outputs.
* Verify grayscale, EXR, and statistics calculation remain correct.
**Mermaid Diagram:**
```mermaid
graph TD
subgraph "_process_maps / _merge_maps Saving Step"
A[Prepare Final Image Data (in RGB)] --> B{Is it 3-Channel?};
B -- Yes --> C{Output Format != 'exr'?};
B -- No --> E[Save Image using cv2.imwrite];
C -- Yes --> D(Convert Image RGB -> BGR);
C -- No --> E;
D --> E;
end

View File

@ -1,29 +0,0 @@
---
ID: ISSUE-011
Type: Issue
Status: Backlog
Priority: Medium
Labels: [blender, bug]
Created: 2025-04-22
Updated: 2025-04-22
Related:
---
# [ISSUE-011]: Blender nodegroup script creates empty assets for skipped items
## Description
The Blender nodegroup creation script (`blenderscripts/create_nodegroups.py`) incorrectly generates empty asset entries in the target .blend file for assets that were skipped during the main processing pipeline. This occurs even though the main asset processor correctly identifies and skips these assets based on the overwrite flag.
## Current Behavior
When running the asset processor with the `--overwrite` flag set to false, if an asset's output directory and metadata.json already exist, the main processing pipeline correctly skips processing that asset. However, the subsequent Blender nodegroup creation script still attempts to create a nodegroup for this skipped asset, resulting in an empty or incomplete asset entry in the target .blend file.
## Desired Behavior / Goals
The Blender nodegroup creation script should only attempt to create nodegroups for assets that were *actually processed* by the main asset processor, not those that were skipped. It should check if the asset was processed successfully before attempting nodegroup creation.
## Implementation Notes (Optional)
The `main.py` script, which orchestrates the processing and calls the Blender scripts, needs to pass information about which assets were successfully processed to the Blender nodegroup script. The Blender script should then filter its operations based on this information.
## Acceptance Criteria (Optional)
* [ ] Running the asset processor with `--overwrite` false on inputs that already have processed outputs should result in the main processing skipping the asset.
* [ ] The Blender nodegroup script, when run after a skipped processing run, should *not* create or update a nodegroup for the skipped asset.
* [ ] Only assets that were fully processed should have corresponding nodegroups created/updated by the Blender script.

View File

@ -1,29 +0,0 @@
---
ID: ISSUE-012
Type: Issue
Status: Backlog
Priority: High
Labels: [core, bug, image processing]
Created: 2025-04-22
Updated: 2025-04-22
Related:
---
# [ISSUE-012]: MASK map processing fails to extract alpha channel from RGBA images
## Description
When processing texture sets that include MASK maps provided as RGBA images, the asset processor is expected to extract the alpha channel to represent the mask. However, the current implementation appears to be converting the RGBA image to grayscale instead of isolating the alpha channel. This results in incorrect MASK maps in the processed output.
## Current Behavior
The asset processor, when encountering an RGBA image classified as a MASK map, converts the image to grayscale. The alpha channel information is lost or ignored during this process.
## Desired Behavior / Goals
For RGBA images classified as MASK maps, the asset processor should extract the alpha channel and use it as the content for the output MASK map. The resulting MASK map should be a single-channel (grayscale) image representing the transparency or mask information from the original alpha channel.
## Implementation Notes (Optional)
The image processing logic within `asset_processor.py` (likely in the `_process_maps` method or a related helper function) needs to be updated to specifically handle RGBA input images for MASK map types. It should identify the alpha channel and save it as a single-channel output image. Libraries like OpenCV or Pillow should provide functionality for accessing individual channels.
## Acceptance Criteria (Optional)
* [ ] Process an asset with an RGBA image designated as a MASK map according to the preset.
* [ ] Verify that the output MASK map is a single-channel grayscale image.
* [ ] Confirm that the grayscale values in the output MASK map correspond to the alpha values of the original RGBA input image.

View File

@ -1,53 +0,0 @@
# Plan for ISSUE-012: MASK map processing fails to extract alpha channel from RGBA images
## Issue Description
When processing texture sets that include MASK maps provided as RGBA images, the asset processor is expected to extract the alpha channel to represent the mask. However, the current implementation appears to be converting the RGBA image to grayscale instead of isolating the alpha channel, resulting in incorrect MASK maps. This issue affects all RGBA images classified as MASK, including plain 'MASK' and MASK variants (e.g., MASK-1).
## Analysis
Based on the code in `asset_processor.py`, specifically the `_load_and_transform_source` method, the issue likely stems from the order of operations. The current logic converts 4-channel BGRA images to 3-channel RGB *before* checking specifically for MASK types and attempting to extract the alpha channel. This means the alpha channel is lost before the code gets a chance to extract it. The condition `if map_type.upper() == 'MASK':` is too strict and does not cover MASK variants.
## Detailed Plan
1. **Analyze `_load_and_transform_source`:** Re-examine the `_load_and_transform_source` method in `asset_processor.py` to confirm the exact sequence of image loading, color space conversions, and MASK-specific handling. (Completed during initial analysis).
2. **Modify MASK Handling Condition:** Change the current condition `if map_type.upper() == 'MASK':` to use the `_get_base_map_type` helper function, so it correctly identifies all MASK variants (e.g., 'MASK', 'MASK-1', 'MASK-2') and applies the special handling logic to them. The condition will become `if _get_base_map_type(map_type) == 'MASK':`.
3. **Reorder and Refine Logic:**
* The image will still be loaded with `cv2.IMREAD_UNCHANGED` for MASK types to ensure the alpha channel is initially present.
* Immediately after loading, and *before* any general color space conversions (like BGR->RGB), check if the base map type is 'MASK' and if the loaded image is 4-channel (RGBA/BGRA).
* If both conditions are true, extract the alpha channel (`img_loaded[:, :, 3]`) and use this single-channel data for subsequent processing steps (`img_prepared`).
* If the base map type is 'MASK' but the loaded image is 3-channel (RGB/BGR), convert it to grayscale (`cv2.cvtColor(img_loaded, cv2.COLOR_BGR2GRAY)`) and use this for `img_prepared`.
* If the base map type is 'MASK' and the loaded image is already 1-channel (grayscale), keep it as is.
* If the base map type is *not* 'MASK', the existing BGR->RGB conversion logic for 3/4 channel images will be applied as before.
4. **Review Subsequent Steps:** Verify that the rest of the `_load_and_transform_source` method (Gloss->Rough inversion, resizing, dtype conversion) correctly handles the single-channel image data that will now be produced for RGBA MASK inputs.
5. **Testing:** Use the acceptance criteria outlined in `ISSUE-012` to ensure the fix works correctly.
## Proposed Code Logic Flow
```mermaid
graph TD
A[Load Image (IMREAD_UNCHANGED for MASK)] --> B{Loading Successful?};
B -- No --> C[Handle Load Error];
B -- Yes --> D[Get Original Dtype & Shape];
D --> E{Base Map Type is MASK?};
E -- Yes --> F{Loaded Image is 4-Channel?};
F -- Yes --> G[Extract Alpha Channel];
F -- No --> H{Loaded Image is 3-Channel?};
H -- Yes --> I[Convert BGR/RGB to Grayscale];
H -- No --> J[Keep as is (Assume Grayscale)];
G --> K[Set img_prepared to Mask Data];
I --> K;
J --> K;
E -- No --> L{Loaded Image is 3 or 4-Channel?};
L -- Yes --> M[Convert BGR/BGRA to RGB];
L -- No --> N[Keep as is];
M --> O[Set img_prepared to RGB Data];
N --> O;
K --> P[Proceed with other transformations (Gloss, Resize, etc.)];
O --> P;
P --> Q[Cache Result & Return];
C --> Q;
```
## Acceptance Criteria (from ISSUE-012)
* [ ] Process an asset with an RGBA image designated as a MASK map according to the preset.
* [ ] Verify that the output MASK map is a single-channel grayscale image.
* [ ] Confirm that the grayscale values in the output MASK map correspond to the alpha values of the original RGBA input image.

View File

@ -1,30 +0,0 @@
---
ID: ISSUE-013
Type: Issue
Status: Backlog
Priority: Medium
Labels: [core, bug, refactor, image processing]
Created: 2025-04-22
Updated: 2025-04-22
Related: #REFACTOR-001-merge-from-source
---
# [ISSUE-013]: Image statistics calculation missing for roughness maps after merged map refactor
## Description
Following the recent refactor of the merged map processing logic, the calculation of image statistics (Min/Max/Mean) for roughness maps appears to have been omitted or broken. This data is valuable for quality control and metadata, and needs to be reimplemented for roughness maps, particularly when they are part of a merged texture like NRMRGH.
## Current Behavior
Image statistics (Min/Max/Mean) are not being calculated or stored for roughness maps after the merged map refactor.
## Desired Behavior / Goals
Reimplement the image statistics calculation for roughness maps. Ensure that statistics are calculated correctly for standalone roughness maps and for roughness data when it is part of a merged map (e.g., the green channel in an NRMRGH map). The calculated statistics should be included in the `metadata.json` file for the asset.
## Implementation Notes (Optional)
Review the changes made during the merged map refactor (`REFACTOR-001-merge-from-source`). Identify where the image statistics calculation for roughness maps was previously handled and integrate it into the new merged map processing flow. Special consideration may be needed to extract the correct channel (green for NRMRGH) for calculation. The `_process_maps` and `_merge_maps` methods in `asset_processor.py` are likely relevant areas.
## Acceptance Criteria (Optional)
* [ ] Process an asset that includes a roughness map (standalone or as part of a merged map).
* [ ] Verify that Min/Max/Mean statistics for the roughness data are calculated.
* [ ] Confirm that the calculated roughness statistics are present and correct in the output `metadata.json` file.

View File

@ -1,67 +0,0 @@
# Plan to Resolve ISSUE-013: Merged Map Roughness Statistics
**Objective:** Reimplement the calculation and inclusion of image statistics (Min/Max/Mean) for roughness data, covering both standalone roughness maps and roughness data used as a source channel in merged maps (specifically the green channel for NRMRGH), and ensure these statistics are present in the output `metadata.json` file.
**Proposed Changes:**
1. **Modify `_merge_maps_from_source`:**
* Within the loop that processes each resolution for a merge rule, after successfully loading and transforming the source images into `loaded_inputs_data`, iterate through the `inputs_mapping` for the current rule.
* Identify if any of the source map types in the `inputs_mapping` is 'ROUGH'.
* If 'ROUGH' is used as a source, retrieve the corresponding loaded image data from `loaded_inputs_data`.
* Calculate image statistics (Min/Max/Mean) for this ROUGH source image data using the existing `_calculate_image_stats` helper function.
* Store these calculated statistics temporarily, associated with the output merged map type (e.g., 'NRMRGH') and the target channel it's mapped to (e.g., 'G').
2. **Update Asset Metadata Structure:**
* Introduce a new key in the asset's metadata dictionary (e.g., `merged_map_channel_stats`) to store statistics for specific channels within merged maps. This structure will hold the stats per merged map type, per channel, and per resolution (specifically the stats resolution defined in the config).
3. **Modify `_generate_metadata_file`:**
* When generating the final `metadata.json` file, retrieve the accumulated `merged_map_channel_stats` from the asset's metadata dictionary.
* Include these statistics under a dedicated section in the output JSON, alongside the existing `image_stats_1k` for individual maps.
**Data Flow for Statistics Calculation:**
```mermaid
graph TD
A[Source Image Files] --> B{_load_and_transform_source};
B --> C[Resized/Transformed Image Data (float32)];
C --> D{Is this source for a ROUGH map?};
D -- Yes --> E{_calculate_image_stats};
E --> F[Calculated Stats (Min/Max/Mean)];
F --> G[Store in merged_map_channel_stats (in asset metadata)];
C --> H{_merge_maps_from_source};
H --> I[Merged Image Data];
I --> J[_save_image];
J --> K[Saved Merged Map File];
G --> L[_generate_metadata_file];
L --> M[metadata.json];
subgraph Individual Map Processing
A --> B;
B --> C;
C --> N{Is this an individual map?};
N -- Yes --> E;
E --> O[Store in image_stats_1k (in asset metadata)];
C --> P[_save_image];
P --> Q[Saved Individual Map File];
O --> L;
Q --> S[Organize Output Files];
end
subgraph Merged Map Processing
A --> B;
B --> C;
C --> D;
D -- Yes --> E;
E --> G;
C --> H;
H --> I;
I --> J;
J --> K;
K --> S;
end
L --> M;
M --> S;
```
This plan ensures that statistics are calculated for the roughness data at the appropriate stage (after loading and transformation, before merging) and correctly included in the metadata file, addressing the issue described in ISSUE-013.

View File

@ -1,49 +0,0 @@
# Issue: GUI Preview Not Updating on File Drop Without Preset Selection
**ID:** ISSUE-GUI-PreviewNotUpdatingOnDrop
**Date:** 2025-04-28
**Status:** Open
**Priority:** High
**Description:**
The file preview list (`QTableView` in the main panel) does not populate when files or folders are dropped onto the application if a valid preset has not been explicitly selected *after* the application starts and *before* the drop event.
**Root Cause:**
- The `add_input_paths` method in `gui/main_window.py` correctly calls `update_preview` after files are added via drag-and-drop.
- However, the `update_preview` method checks `self.editor_preset_list.currentItem()` to get the selected preset.
- If no preset is selected, or if the placeholder "--- Select a Preset ---" is selected, `update_preview` correctly identifies this and returns *before* starting the `PredictionHandler` thread.
- This prevents the file prediction from running and the preview table from being populated, even if assets have been added.
**Expected Behavior:**
Dropping files should trigger a preview update based on the last valid preset selected by the user, or potentially prompt the user to select a preset if none has ever been selected. The preview should not remain empty simply because the user hasn't re-clicked the preset list immediately before dropping files.
**Proposed Solution:**
1. Modify `MainWindow` to store the last validly selected preset path.
2. Update the `update_preview` method:
- Instead of relying solely on `currentItem()`, use the stored last valid preset path if `currentItem()` is invalid or the placeholder.
- If no valid preset has ever been selected, display a clear message in the status bar or a dialog box prompting the user to select a preset first.
- Ensure the prediction handler is triggered with the correct preset name based on this updated logic.
**Affected Files:**
- `gui/main_window.py`
**Debugging Steps Taken:**
- Confirmed `AssetProcessor.__init__` error was fixed.
- Added logging to `PredictionHandler` and `MainWindow`.
- Logs confirmed `update_preview` is called on drop, but exits early due to no valid preset being selected via `currentItem()`.
## Related Changes (Session 2025-04-28)
During a recent session, work was done to implement a feature allowing GUI editing of the file preview list, specifically the file status and predicted output name.
This involved defining a data interface (`ProjectNotes/Data_Structures/Preview_Edit_Interface.md`) and modifying several GUI and backend components.
Key files modified for this feature include:
- `gui/prediction_handler.py`
- `gui/preview_table_model.py`
- `gui/main_window.py`
- `gui/processing_handler.py`
- Core `asset_processor` logic
The changes involved passing the editable `file_list` and `asset_properties` data structure through the prediction handler, to the preview table model, and finally to the processing handler. The preview table model was made editable to allow user interaction. The backend logic was updated to utilize this editable data.
Debugging steps taken during this implementation included fixing indentation errors and resolving an `AssetProcessor` instantiation issue that occurred during the prediction phase.

View File

@ -1,91 +0,0 @@
---
ID: REFACTOR-001
Type: Refactor
Status: Resolved
Priority: Medium
Labels: [refactor, core, image-processing, quality]
Created: 2025-04-22
Updated: 2025-04-22 # Keep original update date, or use today's? Using today's for now.
Related: #ISSUE-010 #asset_processor.py #config.py
---
# [REFACTOR-001]: Refactor Map Merging to Use Source Files Directly
## Description
Currently, the `_merge_maps` function in `asset_processor.py` loads map data from temporary files that have already been processed by `_process_maps` (resized, format converted, etc.). This intermediate save/load step can introduce quality degradation, especially if the intermediate files are saved using lossy compression (e.g., JPG) or if bit depth conversions occur before merging. This is particularly noticeable in merged maps like NRMRGH where subtle details from the source Normal map might be lost or altered due to recompression.
## Goals
1. **Improve Quality:** Modify the map merging process to load channel data directly from the *selected original source files* (after classification and 16-bit prioritization) instead of intermediate processed files, thus avoiding potential quality loss from recompression.
2. **Maintain Modularity:** Refactor the processing logic to avoid significant code duplication between individual map processing and the merging process.
3. **Preserve Functionality:** Ensure all existing functionality (resizing, gloss inversion, format/bit depth rules, merging logic) is retained and applied correctly in the new structure.
## Proposed Solution: Restructure with Helper Functions
Introduce two helper functions within the `AssetProcessor` class:
1. **`_load_and_transform_source(source_path_rel, map_type, target_resolution_key)`:**
* Responsible for loading the specified source file.
* Performs initial preparation: BGR->RGB conversion, Gloss->Roughness inversion (if applicable), MASK extraction (if applicable).
* Resizes the prepared data to the target resolution.
* Returns the resized NumPy array and original source dtype.
2. **`_save_image(image_data, map_type, resolution_key, asset_base_name, source_info, output_bit_depth_rule, temp_dir)`:**
* Encapsulates all logic for saving an image.
* Determines final output format and bit depth based on rules and source info.
* Performs final data type conversions (e.g., to uint8, uint16, float16).
* Performs final color space conversion (RGB->BGR for non-EXR).
* Constructs the output filename.
* Saves the image using `cv2.imwrite`, including fallback logic (e.g., EXR->PNG).
* Returns details of the saved temporary file.
**Modified Workflow:**
```mermaid
graph TD
A[Input Files] --> B(_inventory_and_classify_files);
B --> C{Selected Source Maps Info};
subgraph Core Processing Logic
C --> PIM(_process_individual_map);
C --> MFS(_merge_maps_from_source);
PIM --> LTS([_load_and_transform_source]);
MFS --> LTS;
end
LTS --> ImgData{Loaded, Prepared, Resized Image Data};
subgraph Saving Logic
PIM --> SI([_save_image]);
MFS --> SI;
ImgData --> SI; // Pass image data to save helper
end
SI --> SaveResults{Saved Temp File Details};
SaveResults --> Results(Processing Results); // Collect results from saving
Results --> Meta(_generate_metadata_file);
Meta --> Org(_organize_output_files);
Org --> Final[Final Output Structure];
```
**Function Changes:**
* **`_process_maps`** was renamed to `_process_individual_map`, responsible only for maps *not* used in merges. It calls `_load_and_transform_source` and `_save_image`.
* **`_merge_maps`** was replaced by `_merge_maps_from_source`. It identifies required source paths, calls `_load_and_transform_source` for each input at each target resolution, merges the results, determines saving parameters, and calls `_save_image`.
* The main `process` loop coordinates calls to the new functions and handles caching.
## Resolution (2025-04-22)
The refactoring described above was implemented in `asset_processor.py`. The `_merge_maps_from_source` function now utilizes the `_load_and_transform_source` and `_save_image` helpers, loading data directly from the classified source files instead of intermediate processed files. User testing confirmed the changes work correctly and improve merged map quality.
## Acceptance Criteria (Optional)
* [X] Merged maps (e.g., NRMRGH) are generated correctly using data loaded directly from selected source files.
* [X] Visual inspection confirms improved quality/reduced artifacts in merged maps compared to the previous method.
* [X] Individual maps (not part of any merge rule) are still processed and saved correctly.
* [X] All existing configuration options (resolutions, bit depth rules, format rules, gloss inversion) function as expected within the new structure.
* [X] Processing time remains within acceptable limits (potential performance impact of repeated source loading/resizing needs monitoring).
* [X] Code remains modular and maintainable, with minimal duplication of core logic.

View File

@ -1,68 +0,0 @@
# Refactoring Plan for REFACTOR-001: Merge Maps From Source
This plan details the steps to implement the refactoring described in `Tickets/REFACTOR-001-merge-from-source.md`. The goal is to improve merged map quality by loading data directly from original source files, avoiding intermediate compression artifacts.
## Final Plan Summary:
1. **Implement `_load_and_transform_source` Helper:**
* Loads source image, performs initial prep (BGR->RGB, gloss inversion), resizes to target resolution.
* Includes an in-memory cache (passed from `process`) using a `(source_path, resolution_key)` key to store and retrieve resized results, avoiding redundant loading and resizing within a single `process` call.
2. **Implement `_save_image` Helper:**
* Encapsulates all saving logic: determining output format/bit depth based on rules, final data type/color space conversions, filename construction, saving with `cv2.imwrite`, and fallback logic (e.g., EXR->PNG).
3. **Refactor `_process_maps` (Potential Rename):**
* Modify to process only maps *not* used as inputs for any merge rule.
* Calls `_load_and_transform_source` (passing cache) and `_save_image`.
4. **Replace `_merge_maps` with `_merge_maps_from_source`:**
* Iterates through merge rules.
* Calls `_load_and_transform_source` (passing cache) for each required *source* input at target resolutions.
* Merges the resulting channel data.
* Calls `_save_image` to save the final merged map.
5. **Update `process` Method:**
* Initializes an empty cache dictionary (`loaded_data_cache = {}`) at the beginning of the method.
* Passes this cache dictionary to all calls to `_load_and_transform_source` within its scope.
* Coordinates calls to the refactored/new processing and merging functions.
* Ensures results are collected correctly for metadata generation.
## New Workflow Visualization:
```mermaid
graph TD
A2[Input Files] --> B2(_inventory_and_classify_files);
B2 --> C2{Classified Maps Info};
subgraph Processing Logic
C2 --> D2(_process_individual_map);
C2 --> E2(_merge_maps_from_source);
D2 --> F2([_load_and_transform_source w/ Cache]);
E2 --> F2;
end
F2 --> G2{Loaded/Transformed Data (Cached)};
subgraph Saving Logic
G2 --> H2([_save_image]);
D2 --> H2;
E2 --> H2;
end
H2 -- Saves Temp Files --> I2{Processed/Merged Map Details};
I2 --> J2(_generate_metadata_file);
J2 --> K2(_organize_output_files);
## Current Status (as of 2025-04-22 ~19:45 CET)
* **DONE:** Step 1: Implemented `_load_and_transform_source` helper function (including caching logic) and inserted into `asset_processor.py`.
* **DONE:** Step 2: Implemented `_save_image` helper function and inserted into `asset_processor.py`.
* **DONE:** Step 5 (Partial): Updated `process` method to initialize `loaded_data_cache` and updated calls to use new function names (`_process_individual_maps`, `_merge_maps_from_source`) and pass the cache.
* **DONE:** Renamed function definitions: `_process_maps` -> `_process_individual_maps`, `_merge_maps` -> `_merge_maps_from_source`, and added `loaded_data_cache` parameter.
* **DONE:** Corrected syntax errors introduced during previous `apply_diff` operations (related to docstrings).
## Remaining Steps:
1. **DONE:** Modify `_process_individual_maps` Logic: Updated the internal logic to correctly utilize `_load_and_transform_source` (with cache) and `_save_image` for maps not involved in merging.
2. **DONE:** Modify `_merge_maps_from_source` Logic: Updated the internal logic to correctly utilize `_load_and_transform_source` (with cache) for *source* files, perform the channel merge, and then use `_save_image` for the merged result.
3. **DONE:** Testing: User confirmed the refactored code works correctly.

View File

@ -1,43 +0,0 @@
# BUG: GUI - Persistent Crash When Toggling "Disable Detailed Preview"
**Ticket Type:** Bug
**Priority:** High
**Status:** Resolved
**Description:**
The GUI application crashes with a `Fatal Python error: _PyThreadState_Attach: non-NULL old thread state` when toggling the "Disable Detailed Preview" option in the View menu. This issue persisted despite attempted fixes aimed at resolving potential threading conflicts.
This was a follow-up to a previous ticket regarding the "Disable Detailed Preview" feature regression (refer to ISSUE-GUI-DisableDetailedPreview-Regression.md). While the initial fix addressed the preview display logic, it did not eliminate the crash.
**Symptoms:**
The application terminates unexpectedly with the fatal Python error traceback when the "Disable Detailed Preview" menu item is toggled on or off, particularly after assets have been added to the queue and the detailed preview has been generated or is in the process of being generated.
**Steps to Reproduce:**
1. Launch the GUI (`python -m gui.main_window`).
2. (Optional but recommended for diagnosis) Check the "Verbose Logging (DEBUG)" option in the View menu.
3. Add one or more asset files (ZIPs or folders) to the drag and drop area.
4. Wait for the detailed preview to populate (or start populating).
5. Toggle the "Disable Detailed Preview" option in the View menu. The crash should occur.
6. Toggle the option again if the first toggle didn't cause the crash.
**Attempted Fixes:**
1. Modified `gui/preview_table_model.py` to introduce a `_simple_mode` flag and `set_simple_mode` method to control the data and column presentation for detailed vs. simple views.
2. Modified `gui/main_window.py` (`update_preview` method) to:
* Utilize the `PreviewTableModel.set_simple_mode` method based on the "Disable Detailed Preview" menu action state.
* Configure the `QTableView`'s column visibility and resize modes according to the selected preview mode.
* Request cancellation of the `PredictionHandler` via `prediction_handler.request_cancel()` if it is running when `update_preview` is called. (Note: `request_cancel` did not exist in `PredictionHandler`).
3. Added extensive logging with timestamps and thread IDs to `gui/main_window.py`, `gui/preview_table_model.py`, and `gui/prediction_handler.py` to diagnose threading behavior.
**Diagnosis:**
Analysis of logs revealed that the crash occurred consistently when toggling the preview back ON, specifically during the `endResetModel` call within `PreviewTableModel.set_simple_mode(False)`. The root cause was identified as a state inconsistency in the `QTableView` (or associated models) caused by a redundant call to `PreviewTableModel.set_data` immediately following `PreviewTableModel.set_simple_mode(True)` within the `MainWindow.update_preview` method when switching *to* simple mode. This resulted in two consecutive `beginResetModel`/`endResetModel` calls on the main thread, leaving the model/view in an unstable state that triggered the crash on the subsequent toggle. Additionally, it was found that `PredictionHandler` lacked a `request_cancel` method, although this was not the direct cause of the crash.
**Resolution:**
1. Removed the redundant call to `self.preview_model.set_data(list(self.current_asset_paths))` within the `if simple_mode_enabled:` block in `MainWindow.update_preview`. The `set_simple_mode(True)` call is sufficient to switch the model's internal mode.
2. Added an explicit call to `self.preview_model.set_data(list(self.current_asset_paths))` within the `MainWindow.add_input_paths` method, specifically for the case when the GUI is in simple preview mode. This ensures the simple view is updated correctly when new files are added without relying on the problematic `set_data` call in `update_preview`.
3. Corrected instances of `QThread.currentThreadId()` to `QThread.currentThread()` in logging statements across the relevant files.
4. Added the missing `QThread` import in `gui/prediction_handler.py`.
**Relevant Files/Components:**
* `gui/main_window.py`
* `gui/preview_table_model.py`
* `gui/prediction_handler.py`

View File

@ -1,32 +0,0 @@
---
ID: FEAT-003
Type: Feature
Status: Complete
Priority: Medium
Labels: [feature, blender, metadata]
Created: 2025-04-22
Updated: 2025-04-22
Related:
---
# [FEAT-003]: Selective Nodegroup Generation and Category Tagging in Blender
## Description
Enhance the Blender nodegroup creation script (`blenderscripts/create_nodegroups.py`) to only generate nodegroups for assets classified as "Surface" or "Decal" based on the `category` field in their `metadata.json` file. Additionally, store the asset's category (Surface, Decal, or Asset) as a tag on the generated Blender asset for better organization and filtering within Blender.
## Current Behavior
The current nodegroup generation script processes all assets found in the processed asset library root, regardless of their `category` specified in `metadata.json`. It does not add the asset's category as a tag in Blender.
## Desired Behavior / Goals
1. The script should read the `category` field from the `metadata.json` file for each processed asset.
2. If the `category` is "Surface" or "Decal", the script should proceed with generating the nodegroup.
3. If the `category` is "Asset" (or any other category), the script should skip nodegroup generation for that asset.
4. The script should add the asset's `category` (e.g., "Surface", "Decal", "Asset") as a tag to the corresponding generated Blender asset.
## Implementation Notes (Optional)
(This will require modifying `blenderscripts/create_nodegroups.py` to read the `metadata.json` file, check the `category` field, and use the Blender Python API (`bpy`) to add tags to the created asset.)
## Acceptance Criteria (Optional)
* [x] Run the nodegroup generation script on a processed asset library containing assets of different categories (Surface, Decal, Asset).
* [x] Verify that nodegroups are created only for Surface and Decal assets.
* [x] Verify that assets in the Blender file (both those with generated nodegroups and those skipped) have a tag corresponding to their category from `metadata.json`.

View File

@ -1,162 +0,0 @@
---
ID: FEAT-004
Type: Feature
Status: Complete
Priority: Medium
Labels: [core, gui, cli, feature, enhancement]
Created: 2025-04-22
Updated: 2025-04-22
Related: #ISSUE-001
---
# [FEAT-004]: Handle Multi-Asset Inputs Based on Source Naming Index
## Description
Currently, when an input ZIP or folder contains files from multiple distinct assets (as identified by the `source_naming.part_indices.base_name` rule in the preset), the tool's fallback logic uses `os.path.commonprefix` to determine a single, often incorrect, asset name. This prevents the tool from correctly processing inputs containing multiple assets and leads to incorrect predictions in the GUI.
## Current Behavior
When processing an input containing files from multiple assets (e.g., `3-HeartOak...` and `3-Oak-Classic...` in the same ZIP), the `_determine_base_metadata` method identifies multiple potential base names based on the configured index. It then falls back to calculating the common prefix of all relevant file stems, resulting in a truncated or incorrect asset name (e.g., "3-"). The processing pipeline and GUI prediction then proceed using this incorrect name.
## Desired Behavior / Goals
The tool should accurately detect when a single input (ZIP/folder) contains files belonging to multiple distinct assets, as defined by the `source_naming.part_indices.base_name` rule. For each distinct base name identified, the tool should process the corresponding subset of files as a separate, independent asset. This includes generating a correct output directory structure and a complete `metadata.json` file for each detected asset within the input. The GUI preview should also accurately reflect the presence of multiple assets and their predicted names.
## Implementation Notes (Optional)
* Modify `AssetProcessor._determine_base_metadata` to return a list of distinct base names and a mapping of files to their determined base names.
* Adjust the main processing orchestration (`main.py`, `gui/processing_handler.py`) to iterate over the list of distinct base names returned by `_determine_base_metadata`.
* For each distinct base name, create a new processing context (potentially a new `AssetProcessor` instance or a modified approach) that operates only on the files associated with that specific base name.
* Ensure temporary workspace handling and cleanup correctly manage files for multiple assets from a single input.
* Update `AssetProcessor.get_detailed_file_predictions` to correctly identify and group files by distinct base names for accurate GUI preview display.
* Consider edge cases: what if some files don't match any determined base name? (They should likely still go to 'Extra/'). What if the index method yields no names? (Fallback to input name as currently).
## Acceptance Criteria (Optional)
* [ ] Processing a ZIP file containing files for two distinct assets (e.g., 'AssetA' and 'AssetB') using a preset with `base_name_index` results in two separate output directories (`<output_base>/<supplier>/AssetA/` and `<output_base>/<supplier>/AssetB/`), each containing the correctly processed files and metadata for that asset.
* [ ] The GUI preview accurately lists the files from the multi-asset input and shows the correct predicted asset name for each file based on its determined base name (e.g., files belonging to 'AssetA' show 'AssetA' as the predicted name).
* [ ] The CLI processing of a multi-asset input correctly processes and outputs each asset separately.
* [ ] The tool handles cases where some files in a multi-asset input do not match any determined base name (e.g., they are correctly classified as 'Unrecognised' or 'Extra').
---
## Implementation Plan (Generated by Architect Mode)
**Goal:** Modify the tool to correctly identify and process multiple distinct assets within a single input (ZIP/folder) based on the `source_naming.part_indices.base_name` rule, placing unmatched files into the `Extra/` folder of each processed asset.
**Phase 1: Core Logic Refactoring (`asset_processor.py`)**
1. **Refactor `_determine_base_metadata`:**
* **Input:** Takes the list of all file paths (relative to temp dir) found after extraction.
* **Logic:**
* Iterates through relevant file stems (maps, models).
* Uses the `source_naming_separator` and `source_naming_indices['base_name']` to extract potential base names for each file stem.
* Identifies the set of *distinct* base names found across all files.
* Creates a mapping: `Dict[Path, Optional[str]]` where keys are relative file paths and values are the determined base name string (or `None` if a file doesn't match any base name according to the index rule).
* **Output:** Returns a tuple: `(distinct_base_names: List[str], file_to_base_name_map: Dict[Path, Optional[str]])`.
* **Remove:** Logic setting `self.metadata["asset_name"]`, `asset_category`, and `archetype`.
2. **Create New Method `_determine_single_asset_metadata`:**
* **Input:** Takes a specific `asset_base_name` (string) and the list of `classified_files` *filtered* for that asset.
* **Logic:** Contains the logic previously in `_determine_base_metadata` for determining `asset_category` and `archetype` based *only* on the files associated with the given `asset_base_name`.
* **Output:** Returns a dictionary containing `{"asset_category": str, "archetype": str}` for the specific asset.
3. **Modify `_inventory_and_classify_files`:**
* No major changes needed here initially, as it classifies based on file patterns independent of the final asset name. However, ensure the `classified_files` structure remains suitable for later filtering.
4. **Refactor `AssetProcessor.process` Method:**
* Change the overall flow to handle multiple assets.
* **Steps:**
1. `_setup_workspace()`
2. `_extract_input()`
3. `_inventory_and_classify_files()` -> Get initial `self.classified_files` (all files).
4. Call the *new* `_determine_base_metadata()` using all relevant files -> Get `distinct_base_names` list and `file_to_base_name_map`.
5. Initialize an overall status dictionary (e.g., `{"processed": [], "skipped": [], "failed": []}`).
6. **Loop** through each `current_asset_name` in `distinct_base_names`:
* Log the start of processing for `current_asset_name`.
* **Filter Files:** Create temporary filtered lists of maps, models, etc., from `self.classified_files` based on the `file_to_base_name_map` for the `current_asset_name`.
* **Determine Metadata:** Call `_determine_single_asset_metadata(current_asset_name, filtered_files)` -> Get category/archetype for this asset. Store these along with `current_asset_name` and supplier name in a temporary `current_asset_metadata` dict.
* **Skip Check:** Perform the skip check logic specifically for `current_asset_name` using the `output_base_path`, supplier name, and `current_asset_name`. If skipped, update overall status and `continue` to the next asset name.
* **Process:** Call `_process_maps()`, `_merge_maps()`, passing the *filtered* file lists and potentially the `current_asset_metadata`. These methods need to operate only on the provided subset of files.
* **Generate Metadata:** Call `_generate_metadata_file()`, passing the `current_asset_metadata` and the results from map/merge processing for *this asset*. This method will now write `metadata.json` specific to `current_asset_name`.
* **Organize Output:** Call `_organize_output_files()`, passing the `current_asset_name`. This method needs modification:
* It will move the processed files for the *current asset* to the correct subfolder (`<output_base>/<supplier>/<current_asset_name>/`).
* It will also identify files from the *original* input whose base name was `None` in the `file_to_base_name_map` (the "unmatched" files).
* It will copy these "unmatched" files into the `Extra/` subfolder for the *current asset being processed in this loop iteration*.
* Update overall status based on the success/failure of this asset's processing.
7. `_cleanup_workspace()` (only after processing all assets from the input).
8. **Return:** Return the overall status dictionary summarizing results across all detected assets.
5. **Adapt `_process_maps`, `_merge_maps`, `_generate_metadata_file`, `_organize_output_files`:**
* Ensure these methods accept and use the filtered file lists and the specific `asset_name` for the current iteration.
* `_organize_output_files` needs the logic to handle copying the "unmatched" files into the current asset's `Extra/` folder.
**Phase 2: Update Orchestration (`main.py`, `gui/processing_handler.py`)**
1. **Modify `main.process_single_asset_wrapper`:**
* The call `processor.process()` will now return the overall status dictionary.
* The wrapper needs to interpret this dictionary to return a single representative status ("processed" if any succeeded, "skipped" if all skipped, "failed" if any failed) and potentially a consolidated error message for the main loop/GUI.
2. **Modify `gui.processing_handler.ProcessingHandler.run`:**
* No major changes needed here, as it relies on `process_single_asset_wrapper`. The status updates emitted back to the GUI might need slight adjustments if more detailed per-asset status is desired in the future, but for now, the overall status from the wrapper should suffice.
**Phase 3: Update GUI Prediction (`asset_processor.py`, `gui/prediction_handler.py`, `gui/main_window.py`)**
1. **Modify `AssetProcessor.get_detailed_file_predictions`:**
* This method must now perform the multi-asset detection:
* Call the refactored `_determine_base_metadata` to get the `distinct_base_names` and `file_to_base_name_map`.
* Iterate through all classified files (maps, models, extra, ignored).
* For each file, look up its corresponding base name in the `file_to_base_name_map`.
* The returned dictionary for each file should now include:
* `original_path`: str
* `predicted_asset_name`: str | None (The base name determined for this file, or None if unmatched)
* `predicted_output_name`: str | None (The predicted final filename, e.g., `AssetName_Color_4K.png`, or original name for models/extra)
* `status`: str ("Mapped", "Model", "Extra", "Unrecognised", "Ignored", **"Unmatched Extra"** - new status for files with `None` base name).
* `details`: str | None
2. **Update `gui.prediction_handler.PredictionHandler`:**
* Ensure it correctly passes the results from `get_detailed_file_predictions` (including the new `predicted_asset_name` and `status` values) back to the main window via signals.
3. **Update `gui.main_window.MainWindow`:**
* Modify the preview table model/delegate to display the `predicted_asset_name`. A new column might be needed.
* Update the logic that colors rows or displays status icons to handle the new "Unmatched Extra" status distinctly from regular "Extra" or "Unrecognised".
**Visual Plan (`AssetProcessor.process` Sequence)**
```mermaid
sequenceDiagram
participant Client as Orchestrator (main.py / GUI Handler)
participant AP as AssetProcessor
participant Config as Configuration
participant FS as File System
Client->>AP: process(input_path, config, output_base, overwrite)
AP->>AP: _setup_workspace()
AP->>FS: Create temp_dir
AP->>AP: _extract_input()
AP->>FS: Extract/Copy files to temp_dir
AP->>AP: _inventory_and_classify_files()
AP-->>AP: self.classified_files (all files)
AP->>AP: _determine_base_metadata()
AP-->>AP: distinct_base_names, file_to_base_name_map
AP->>AP: Initialize overall_status = {}
loop For each current_asset_name in distinct_base_names
AP->>AP: Log start for current_asset_name
AP->>AP: Filter self.classified_files using file_to_base_name_map
AP-->>AP: filtered_files_for_asset
AP->>AP: _determine_single_asset_metadata(current_asset_name, filtered_files_for_asset)
AP-->>AP: current_asset_metadata (category, archetype)
AP->>AP: Perform Skip Check for current_asset_name
alt Skip Check == True
AP->>AP: Update overall_status (skipped)
AP->>AP: continue loop
end
AP->>AP: _process_maps(filtered_files_for_asset, current_asset_metadata)
AP-->>AP: processed_map_details_asset
AP->>AP: _merge_maps(filtered_files_for_asset, current_asset_metadata)
AP-->>AP: merged_map_details_asset
AP->>AP: _generate_metadata_file(current_asset_metadata, processed_map_details_asset, merged_map_details_asset)
AP->>FS: Write metadata.json for current_asset_name
AP->>AP: _organize_output_files(current_asset_name, file_to_base_name_map)
AP->>FS: Move processed files for current_asset_name
AP->>FS: Copy unmatched files to Extra/ for current_asset_name
AP->>AP: Update overall_status (processed/failed for this asset)
end
AP->>AP: _cleanup_workspace()
AP->>FS: Delete temp_dir
AP-->>Client: Return overall_status dictionary

View File

@ -1,56 +0,0 @@
# Ticket: FEAT-011 - Implement Power-of-Two Texture Resizing
**Status:** Open
**Priority:** High
**Assignee:** TBD
**Reporter:** Roo (Architect Mode)
## Description
The current asset processing pipeline resizes textures based on a target maximum dimension (e.g., 4K = 4096px) while maintaining the original aspect ratio. This results in non-power-of-two (NPOT) dimensions for non-square textures, which is suboptimal for rendering performance and compatibility with certain systems.
This feature implements a "Stretch/Squash" approach to ensure all output textures have power-of-two (POT) dimensions for each target resolution key.
## Proposed Solution
1. **Resizing Logic Change:**
* Modify the `calculate_target_dimensions` helper function in `asset_processor.py`.
* **Step 1:** Calculate intermediate dimensions (`scaled_w`, `scaled_h`) by scaling the original image (orig_w, orig_h) to fit within the target resolution key's maximum dimension (e.g., 4096 for "4K") while maintaining the original aspect ratio (using existing logic).
* **Step 2:** Implement a new helper function `get_nearest_pot(value: int) -> int` to find the closest power-of-two value for a given integer.
* **Step 3:** Apply `get_nearest_pot()` to `scaled_w` to get the final target power-of-two width (`pot_w`).
* **Step 4:** Apply `get_nearest_pot()` to `scaled_h` to get the final target power-of-two height (`pot_h`).
* **Step 5:** Return `(pot_w, pot_h)` from `calculate_target_dimensions`. The `_process_maps` function will then use these POT dimensions in `cv2.resize`.
2. **Helper Function `get_nearest_pot`:**
* This function will take an integer `value`.
* It will find the powers of two immediately below (`lower_pot`) and above (`upper_pot`) the value.
* It will return the power of two that is numerically closer to the original `value`. (e.g., `get_nearest_pot(1365)` would return 1024, as `1365 - 1024 = 341` and `2048 - 1365 = 683`).
3. **Filename Convention:**
* The original resolution tag (e.g., `_4K`, `_2K`) defined in `config.py` will be kept in the output filename, even though the final dimensions are POT. This maintains consistency with the processing target.
4. **Metadata:**
* The existing aspect ratio change metadata calculation (`_normalize_aspect_ratio_change`) will remain unchanged. This metadata can be used downstream to potentially correct the aspect ratio distortion introduced by the stretch/squash resizing.
## Implementation Diagram
```mermaid
graph TD
A[Original Dimensions (W, H)] --> B{Target Resolution Key (e.g., "4K")};
B --> C{Get Max Dimension (e.g., 4096)};
A & C --> D[Calculate Scaled Dimensions (scaled_w, scaled_h) - Maintain Aspect Ratio];
D --> E[scaled_w];
D --> F[scaled_h];
E --> G[Find Nearest POT(scaled_w) -> pot_w];
F --> H[Find Nearest POT(scaled_h) -> pot_h];
G & H --> I[Final POT Dimensions (pot_w, pot_h)];
I --> J[Use (pot_w, pot_h) in cv2.resize];
```
## Acceptance Criteria
* All textures output by the `_process_maps` function have power-of-two dimensions (width and height are both powers of 2).
* The resizing uses the "Stretch/Squash" method based on the nearest POT value for each dimension calculated *after* initial aspect-preserving scaling.
* The output filename retains the original resolution key (e.g., `_4K`).
* The `get_nearest_pot` helper function correctly identifies the closest power of two.
* The aspect ratio metadata calculation remains unchanged.

View File

@ -1,29 +0,0 @@
---
ID: ISSUE-001
Type: Issue
Status: Backlog
Priority: Medium
Labels: [bug, config, gui]
Created: 2025-04-22
Updated: 2025-04-22
Related:
---
# [ISSUE-001]: Source file naming rules from JSON are not respected
## Description
The tool is not correctly applying the "Source file naming rules" defined in the JSON presets. Specifically, the "base Name index" and "Map type index" values within the `source_naming` section of the preset JSON are not being respected during file processing.
## Current Behavior
When processing assets, the tool (observed in the GUI) does not use the specified "base Name index" and "Map type index" from the active preset's `source_naming` rules to determine the asset's base name and individual map types from the source filenames.
## Desired Behavior / Goals
The tool should accurately read and apply the "base Name index" and "Map type index" values from the selected preset's `source_naming` rules to correctly parse asset base names and map types from source filenames.
## Implementation Notes (Optional)
(Add any thoughts on how this could be implemented, technical challenges, relevant code sections, or ideas for a solution.)
## Acceptance Criteria (Optional)
(Define clear, testable criteria that must be met for the ticket to be considered complete.)
* [ ] Processing an asset with a preset that uses specific `base_name_index` and `map_type_index` values results in the correct asset name and map types being identified according to those indices.
* [ ] This behavior is consistent in both the GUI and CLI.

View File

@ -1,28 +0,0 @@
---
ID: ISSUE-002
Type: Issue
Status: Mostly Resolved
Priority: High
Labels: [bug, core, file-classification]
Created: 2025-04-22
Updated: 2025-04-22
Related:
---
# [ISSUE-002]: Incorrect COL-# numbering with multiple assets in one directory
## Description
When processing a directory containing multiple distinct asset sets (e.g., `Assetname1` and `Assetname2`), the numbering for map types that require variant suffixes (like "COL") is incorrectly incremented across all assets in the directory rather than being reset for each individual asset.
## Current Behavior
If an input directory contains files for `Assetname1` and `Assetname2`, and both have multiple "COL" maps, the numbering continues sequentially across both sets. For example, `Assetname1` might get `_COL-1`, `_COL-2`, while `Assetname2` incorrectly gets `_COL-3`, `_COL-4`, instead of starting its own sequence (`_COL-1`, `_COL-2`). The "COL value accumulates across directory, not comparing names".
## Desired Behavior / Goals
The tool should correctly identify distinct asset sets within a single input directory and apply variant numbering (like "COL-#") independently for each asset set. The numbering should reset for each new asset encountered in the directory.
## Implementation Notes (Optional)
(This likely requires adjusting the file classification or inventory logic to group files by asset name before applying variant numbering rules.)
## Acceptance Criteria (Optional)
* [ ] Processing a directory containing multiple asset sets with variant map types results in correct, independent numbering for each asset set (e.g., `Assetname1_COL-1`, `Assetname1_COL-2`, `Assetname2_COL-1`, `Assetname2_COL-2`).
* [ ] The numbering is based on the files belonging to a specific asset name, not the overall count of variant maps in the entire input directory.

View File

@ -1,29 +0,0 @@
---
ID: ISSUE-005
Type: Issue
Status: Resolved
Priority: High
Labels: [bug, core, image-processing]
Created: 2025-04-22
Updated: 2025-04-22
Related:
---
# [ISSUE-005]: Alpha Mask channel not processed correctly
## Description
When processing source images that contain an alpha channel intended for use as a MASK map, the tool's output for the MASK map is an RGBA image instead of a grayscale image derived solely from the alpha channel.
## Current Behavior
If a source image (e.g., a PNG or TIF) has an alpha channel and is classified as a MASK map type, the resulting output MASK file retains the RGB channels (potentially with incorrect data or black/white values) in addition to the alpha channel, resulting in an RGBA output image.
## Desired Behavior / Goals
When processing a source image with an alpha channel for a MASK map type, the tool should extract only the alpha channel data and output a single-channel (grayscale) image representing the mask. The RGB channels from the source should be discarded for the MASK output.
## Implementation Notes (Optional)
(This requires modifying the image processing logic for MASK map types to specifically isolate and save only the alpha channel as a grayscale image. Need to check the relevant sections in `asset_processor.py` related to map processing and saving.)
## Acceptance Criteria (Optional)
* [ ] Process an asset containing a source image with an alpha channel intended as a MASK map.
* [ ] Verify that the output MASK file is a grayscale image (single channel) and accurately represents the alpha channel data from the source.
* [ ] Verify that the output MASK file does not contain redundant or incorrect RGB channel data.

View File

@ -1,32 +0,0 @@
---
ID: ISSUE-006 # e.g., FEAT-001, ISSUE-002
Type: Issue # Choose one: Issue or Feature
Status: Mostly Resolved # Choose one
Priority: Medium # Choose one
Labels: [core, bug, map_processing, multi_asset] # Add relevant labels from the list or define new ones
Created: 2025-04-22
Updated: 2025-04-22
Related: #FEAT-004-handle-multi-asset-inputs.md # Links to other tickets (e.g., #ISSUE-YYY), relevant files, or external URLs
---
# [ISSUE-006]: COL-# Suffixes Incorrectly Increment Across Multi-Asset Inputs
## Description
When processing an input (ZIP or folder) that contains files for multiple distinct assets, the numeric suffixes applied to map types listed in `RESPECT_VARIANT_MAP_TYPES` (such as "COL") are currently incremented globally across all files in the input, rather than being reset and incremented independently for each detected asset group.
## Current Behavior
If an input contains files for AssetA (e.g., AssetA_COL.png, AssetA_COL_Variant.png) and AssetB (e.g., AssetB_COL.png), the output might incorrectly number them as AssetA_COL-1.png, AssetA_COL-2.png, and AssetB_COL-3.png. The expectation is that numbering should restart for each asset, resulting in AssetA_COL-1.png, AssetA_COL-2.png, and AssetB_COL-1.png.
## Desired Behavior / Goals
The numeric suffix for map types in `RESPECT_VARIANT_MAP_TYPES` should be determined and applied independently for each distinct asset detected within a multi-asset input. The numbering should start from -1 for each asset group.
## Implementation Notes (Optional)
- The logic for assigning suffixes is primarily within `AssetProcessor._inventory_and_classify_files`.
- This method currently classifies all files from the input together before determining asset groups.
- The classification logic needs to be adjusted to perform suffix assignment *after* files have been grouped by their determined asset name.
- This might require modifying the output of `_inventory_and_classify_files` or adding a new step after `_determine_base_metadata` to re-process or re-structure the classified files per asset for suffix assignment.
## Acceptance Criteria (Optional)
* [ ] Processing a multi-asset input containing multiple "COL" variants for different assets results in correct COL-# suffixes starting from -1 for each asset's output files.
* [ ] The GUI preview accurately reflects the correct COL-# numbering for each file based on its predicted asset name.
* [ ] The CLI processing output confirms the correct numbering in the generated filenames.

View File

@ -1,30 +0,0 @@
---
ID: ISSUE-007 # e.g., FEAT-001, ISSUE-002
Type: Issue # Choose one: Issue or Feature
Status: Resolved # Choose one
Priority: Medium # Choose one
Labels: [core, bug, map_processing, suffix, regression] # Add relevant labels from the list or define new ones
Created: 2025-04-22
Updated: 2025-04-22
Related: #ISSUE-006-col-increment-multi-asset.md # Links to other tickets (e.g., #ISSUE-YYY), relevant files, or external URLs
---
# [ISSUE-007]: Suffix Not Applied to Single Maps in RESPECT_VARIANT_MAP_TYPES
## Description
Map types listed in `config.py`'s `RESPECT_VARIANT_MAP_TYPES` (e.g., "COL") are expected to always receive a numeric suffix (e.g., "-1"), even if only one map of that type exists for a given asset. Following the fix for #ISSUE-006, this behavior is no longer occurring. Single maps of these types are now output without a suffix.
## Current Behavior
An asset containing only one map file designated as "COL" (e.g., `AssetA_COL.png`) results in processed output files named like `AssetA_COL_4K.png`, without the `-1` suffix.
## Desired Behavior / Goals
An asset containing only one map file designated as "COL" (or any other type listed in `RESPECT_VARIANT_MAP_TYPES`) should result in processed output files named like `AssetA_COL-1_4K.png`, correctly applying the numeric suffix even when it's the sole variant.
## Implementation Notes (Optional)
- The per-asset suffix assignment logic added in `AssetProcessor.process` (around line 233 in the previous diff) likely needs adjustment.
- The condition `if respect_variants:` might need to be modified, or the loop/enumeration logic needs to explicitly handle the case where `len(maps_in_group) == 1` for types listed in `RESPECT_VARIANT_MAP_TYPES`.
## Acceptance Criteria (Optional)
* [ ] Processing an asset with a single "COL" map results in output files with the `COL-1` suffix.
* [ ] Processing an asset with multiple "COL" maps still results in correctly incremented suffixes (`COL-1`, `COL-2`, etc.).
* [ ] Map types *not* listed in `RESPECT_VARIANT_MAP_TYPES` continue to receive no suffix if only one exists.

View File

@ -1,24 +0,0 @@
# ISSUE: GUI - "Disable Detailed Preview" feature regression
**Ticket Type:** Issue
**Priority:** Medium
**Description:**
The "Disable Detailed Preview" feature in the GUI is currently not functioning correctly. When attempting to disable the detailed file preview (via the View menu), the GUI does not switch to the simpler asset list view. This regression prevents users from using the less detailed preview mode, which may impact performance or usability, especially when dealing with inputs containing a large number of files.
**Steps to Reproduce:**
1. Launch the GUI (`python -m gui.main_window`).
2. Load an asset (ZIP or folder) into the drag and drop area. Observe the detailed preview table populating.
3. Go to the "View" menu.
4. Select/Deselect the "Detailed File Preview" option.
**Expected Result:**
The preview table should switch between the detailed file list view and the simple asset list view when the menu option is toggled.
**Actual Result:**
The preview table remains in the detailed file list view regardless of the "Detailed File Preview" menu option state.
**Relevant Files/Components:**
* `gui/main_window.py`: Likely contains the logic for the View menu and handling the toggle state.
* `gui/prediction_handler.py`: Manages the background process that generates the detailed preview data. The GUI needs to be able to stop or not request this process when detailed preview is disabled.
* `gui/preview_table_model.py`: Manages the data and display logic for the preview table. It should adapt its display based on whether detailed preview is enabled or disabled.

View File

@ -1,75 +0,0 @@
# Issue and Feature Tracking
This directory is used to track issues and feature ideas for the Asset Processor Tool using Markdown files.
## Structure
All ticket files are stored directly within the `Tickets/` directory.
```mermaid
graph TD
A[Asset_processor_tool] --> B(Tickets);
A --> C(...other files/dirs...);
B --> D(ISSUE-XXX-....md);
B --> E(FEAT-XXX-....md);
B --> F(_template.md);
```
## File Naming Convention
Ticket files should follow the convention: `TYPE-ID-short-description.md`
* `TYPE`: `ISSUE` for bug reports or problems, `FEAT` for new features or enhancements.
* `ID`: A sequential three-digit number (e.g., `001`, `002`).
* `short-description`: A brief, hyphenated summary of the ticket's content.
Examples:
* `ISSUE-001-gui-preview-bug.md`
* `FEAT-002-add-dark-mode.md`
## Ticket Template (`_template.md`)
Use the `_template.md` file as a starting point for creating new tickets. It includes YAML front matter for structured metadata and standard Markdown headings for the ticket content.
```markdown
---
ID: TYPE-XXX # e.g., FEAT-001, ISSUE-002
Type: Issue | Feature # Choose one: Issue or Feature
Status: Backlog | Planned | In Progress | Blocked | Needs Review | Done | Won't Fix # Choose one
Priority: Low | Medium | High # Choose one
Labels: [gui, cli, core, blender, bug, feature, enhancement, docs, config] # Add relevant labels from the list or define new ones
Created: YYYY-MM-DD
Updated: YYYY-MM-DD
Related: # Links to other tickets (e.g., #ISSUE-YYY), relevant files, or external URLs
---
# [TYPE-XXX]: Brief Title of Issue/Feature
## Description
(Provide a detailed explanation of the issue or feature request. What is the problem you are trying to solve, or what is the new functionality you are proposing?)
## Current Behavior
(Describe what happens currently. If reporting a bug, explain the steps to reproduce it. If proposing a feature, describe the current state without the feature.)
## Desired Behavior / Goals
(Describe what *should* happen if the issue is resolved, or what the feature aims to achieve. Be specific about the desired outcome.)
## Implementation Notes (Optional)
(Add any thoughts on how this could be implemented, potential technical challenges, relevant code sections, or ideas for a solution.)
## Acceptance Criteria (Optional)
(Define clear, testable criteria that must be met for the ticket to be considered complete.)
* [ ] Criterion 1: The first condition that must be satisfied.
* [ ] Criterion 2: The second condition that must be satisfied.
* [ ] Add more criteria as needed.
```
## How to Use
1. Create a new Markdown file in the `Tickets/` directory following the naming convention (`TYPE-ID-short-description.md`).
2. Copy the content from `_template.md` into your new file.
3. Fill in the YAML front matter and the Markdown sections with details about the issue or feature.
4. Update the `Status` and `Updated` fields as you work on the ticket.
5. Use the `Related` field to link to other relevant tickets or project files.
```

View File

@ -1,30 +0,0 @@
---
ID: TYPE-XXX # e.g., FEAT-001, ISSUE-002
Type: Issue | Feature # Choose one: Issue or Feature
Status: Backlog | Planned | In Progress | Blocked | Needs Review | Done | Won't Fix # Choose one
Priority: Low | Medium | High # Choose one
Labels: [gui, cli, core, blender, bug, feature, enhancement, docs, config] # Add relevant labels from the list or define new ones
Created: YYYY-MM-DD
Updated: YYYY-MM-DD
Related: # Links to other tickets (e.g., #ISSUE-YYY), relevant files, or external URLs
---
# [TYPE-XXX]: Brief Title of Issue/Feature
## Description
(Provide a detailed explanation of the issue or feature request. What is the problem you are trying to solve, or what is the new functionality you are proposing?)
## Current Behavior
(Describe what happens currently. If reporting a bug, explain the steps to reproduce it. If proposing a feature, describe the current state without the feature.)
## Desired Behavior / Goals
(Describe what *should* happen if the issue is resolved, or what the feature aims to achieve. Be specific about the desired outcome.)
## Implementation Notes (Optional)
(Add any thoughts on how this could be implemented, potential technical challenges, relevant code sections, or ideas for a solution.)
## Acceptance Criteria (Optional)
(Define clear, testable criteria that must be met for the ticket to be considered complete.)
* [ ] Criterion 1: The first condition that must be satisfied.
* [ ] Criterion 2: The second condition that must be satisfied.
* [ ] Add more criteria as needed.

View File

@ -12,18 +12,15 @@ bl_info = {
import bpy
# Import other modules (will be created later)
from . import operator
from . import panel
def register():
# Register classes from imported modules
operator.register()
panel.register()
print("Material Merger Addon Registered")
def unregister():
# Unregister classes from imported modules
panel.unregister()
operator.unregister()
print("Material Merger Addon Unregistered")

View File

@ -10,7 +10,6 @@ MATERIAL_MERGE_NODEGROUP_NAME = "MaterialMerge"
HANDLER_NODEGROUP_NAME = "PBR_Handler" # Assumption from plan
BSDF_NODEGROUP_NAME = "PBR_BSDF" # Assumption from plan
# Helper function to copy nodes and identify outputs
def copy_material_nodes(source_mat, target_tree, location_offset=(0, 0)):
"""
Copies nodes from source_mat's node tree to target_tree, applying an offset.
@ -25,7 +24,7 @@ def copy_material_nodes(source_mat, target_tree, location_offset=(0, 0)):
return None, None, None
source_tree = source_mat.node_tree
copied_node_map = {} # Map original node to copied node
copied_node_map = {}
copied_final_bsdf_node = None
copied_final_disp_node = None
@ -50,7 +49,6 @@ def copy_material_nodes(source_mat, target_tree, location_offset=(0, 0)):
print(f" Identified top-level '{MATERIAL_MERGE_NODEGROUP_NAME}' in '{source_mat.name}'. Using its outputs.")
source_final_bsdf_node = top_merge_node
source_final_disp_node = top_merge_node # Both outputs come from the merge node
# Ensure the sockets exist before proceeding
if 'BSDF' not in source_final_bsdf_node.outputs or 'Displacement' not in source_final_disp_node.outputs:
print(f" Error: Identified merge node in '{source_mat.name}' lacks required BSDF/Displacement outputs.")
return None, None, None
@ -65,7 +63,6 @@ def copy_material_nodes(source_mat, target_tree, location_offset=(0, 0)):
if not source_final_disp_node:
print(f" Error: Could not find base Handler node '{HANDLER_NODEGROUP_NAME}' in '{source_mat.name}'.")
return None, None, None
# Ensure sockets exist
if 'BSDF' not in source_final_bsdf_node.outputs:
print(f" Error: Identified BSDF node '{BSDF_NODEGROUP_NAME}' lacks BSDF output.")
return None, None, None
@ -152,7 +149,6 @@ class MATERIAL_OT_merge_materials(Operator):
bl_label = "Merge Selected Materials"
bl_options = {'REGISTER', 'UNDO'}
# Properties to hold the names of the selected materials
# These will be set by the UI panel
material_a_name: StringProperty(
name="Material A",
@ -195,14 +191,14 @@ class MATERIAL_OT_merge_materials(Operator):
# Add Material Output node
output_node = new_node_tree.nodes.new(type='ShaderNodeOutputMaterial')
output_node.location = (400, 0) # Basic positioning
output_node.location = (400, 0)
# 2. Copy nodes from source materials
print("Copying nodes for Material A...")
copied_map_a, copied_bsdf_a, copied_disp_a = copy_material_nodes(mat_a, new_node_tree, location_offset=(0, 0))
if not copied_bsdf_a or not copied_disp_a:
self.report({'ERROR'}, f"Failed to copy nodes or identify outputs for material '{mat_a.name}'. Check console for details.")
bpy.data.materials.remove(new_mat) # Clean up
bpy.data.materials.remove(new_mat)
return {'CANCELLED'}
print("Copying nodes for Material B...")
@ -216,7 +212,7 @@ class MATERIAL_OT_merge_materials(Operator):
copied_map_b, copied_bsdf_b, copied_disp_b = copy_material_nodes(mat_b, new_node_tree, location_offset=(offset_x, 0))
if not copied_bsdf_b or not copied_disp_b:
self.report({'ERROR'}, f"Failed to copy nodes or identify outputs for material '{mat_b.name}'. Check console for details.")
bpy.data.materials.remove(new_mat) # Clean up
bpy.data.materials.remove(new_mat)
return {'CANCELLED'}
@ -255,17 +251,14 @@ class MATERIAL_OT_merge_materials(Operator):
# Add the linked/appended group to the new material's node tree
merge_node = new_node_tree.nodes.new(type='ShaderNodeGroup')
merge_node.node_tree = merge_group
merge_node.label = MATERIAL_MERGE_NODEGROUP_NAME # Set label for clarity
merge_node.location = (200, 0) # Basic positioning
merge_node.label = MATERIAL_MERGE_NODEGROUP_NAME
merge_node.location = (200, 0)
# 4. Make Connections
links = new_node_tree.links
# Connect BSDFs to Merge node
# NOTE: Using original nodes here as placeholder. Needs to use *copied* nodes.
# NOTE: Using *copied* nodes now.
# Ensure the sockets exist before linking
bsdf_output_socket_a = copied_bsdf_a.outputs.get('BSDF')
shader_input_socket_a = merge_node.inputs.get('Shader A')
bsdf_output_socket_b = copied_bsdf_b.outputs.get('BSDF')
@ -273,16 +266,13 @@ class MATERIAL_OT_merge_materials(Operator):
if not all([bsdf_output_socket_a, shader_input_socket_a, bsdf_output_socket_b, shader_input_socket_b]):
self.report({'ERROR'}, "Could not find required BSDF/Shader sockets for linking.")
bpy.data.materials.remove(new_mat) # Clean up
bpy.data.materials.remove(new_mat)
return {'CANCELLED'}
link_bsdf_a = links.new(bsdf_output_socket_a, shader_input_socket_a)
link_bsdf_b = links.new(bsdf_output_socket_b, shader_input_socket_b)
# Connect Displacements to Merge node
# NOTE: Using original nodes here as placeholder. Needs to use *copied* nodes.
# NOTE: Using *copied* nodes now.
# Ensure the sockets exist before linking
disp_output_socket_a = copied_disp_a.outputs.get('Displacement')
disp_input_socket_a = merge_node.inputs.get('Displacement A')
disp_output_socket_b = copied_disp_b.outputs.get('Displacement')
@ -290,14 +280,13 @@ class MATERIAL_OT_merge_materials(Operator):
if not all([disp_output_socket_a, disp_input_socket_a, disp_output_socket_b, disp_input_socket_b]):
self.report({'ERROR'}, "Could not find required Displacement sockets for linking.")
bpy.data.materials.remove(new_mat) # Clean up
bpy.data.materials.remove(new_mat)
return {'CANCELLED'}
link_disp_a = links.new(disp_output_socket_a, disp_input_socket_a)
link_disp_b = links.new(disp_output_socket_b, disp_input_socket_b)
# Connect Merge node outputs to Material Output
# Ensure the sockets exist before linking
merge_bsdf_output = merge_node.outputs.get('BSDF')
output_surface_input = output_node.inputs.get('Surface')
merge_disp_output = merge_node.outputs.get('Displacement')
@ -305,7 +294,7 @@ class MATERIAL_OT_merge_materials(Operator):
if not all([merge_bsdf_output, output_surface_input, merge_disp_output, output_disp_input]):
self.report({'ERROR'}, "Could not find required Merge/Output sockets for linking.")
bpy.data.materials.remove(new_mat) # Clean up
bpy.data.materials.remove(new_mat)
return {'CANCELLED'}
link_merge_bsdf = links.new(merge_bsdf_output, output_surface_input)
@ -315,7 +304,6 @@ class MATERIAL_OT_merge_materials(Operator):
# 5. Layout (Optional)
# TODO: Implement better node layout
# Update node tree to apply changes
new_node_tree.nodes.update()
self.report({'INFO'}, f"Successfully merged '{mat_a.name}' and '{mat_b.name}' into '{new_mat.name}'")

View File

@ -1,6 +1,6 @@
import bpy
from bpy.types import Panel
from .operator import MATERIAL_OT_merge_materials # Import the operator
from .operator import MATERIAL_OT_merge_materials
class MATERIAL_PT_material_merger_panel(Panel):
"""Creates a Panel in the Shader Editor sidebar"""
@ -13,9 +13,6 @@ class MATERIAL_PT_material_merger_panel(Panel):
def draw(self, context):
layout = self.layout
# Get the active material in the Shader Editor
# This might be useful for defaulting one of the selectors
# mat = context.material
row = layout.row()
row.label(text="Select Materials to Merge:")
@ -25,18 +22,14 @@ class MATERIAL_PT_material_merger_panel(Panel):
# We'll use StringProperty for simplicity in the UI for now.
# A more advanced UI might use PointerProperty to bpy.data.materials
# Material A selection
row = layout.row()
row.prop(context.scene, "material_merger_mat_a", text="Material A")
# Material B selection
row = layout.row()
row.prop(context.scene, "material_merger_mat_b", text="Material B")
# Merge button
row = layout.row()
# Pass the selected material names to the operator when button is clicked
row.operator(MATERIAL_OT_merge_materials.bl_idname, text=MATERIAL_OT_merge_materials.bl_label).material_a_name = context.scene.material_merger_mat_a
row.operator(MATERIAL_OT_merge_materials.bl_idname, text=MATERIAL_OT_merge_materials.bl_label).material_b_name = context.scene.material_merger_mat_b

View File

@ -20,7 +20,7 @@ import json
from pathlib import Path
import time
import base64 # Although not directly used here, keep for consistency if reusing more code later
import sys # <<< ADDED IMPORT
import sys
# --- USER CONFIGURATION ---
@ -133,9 +133,7 @@ def reconstruct_image_path_with_fallback(asset_dir_path, asset_name, map_type, r
)
primary_path = asset_dir_path / filename
if primary_path.is_file():
# print(f" Found primary path: {str(primary_path)}") # Verbose
return str(primary_path)
# else: print(f" Primary path not found: {str(primary_path)}") # Verbose
except KeyError as e:
print(f" !!! ERROR: Missing key '{e}' in IMAGE_FILENAME_PATTERN. Cannot reconstruct path.")
return None # Cannot proceed without valid pattern
@ -144,7 +142,6 @@ def reconstruct_image_path_with_fallback(asset_dir_path, asset_name, map_type, r
# Continue to fallback
# 2. Try fallback extensions
# print(f" Trying fallback extensions for {map_type}/{resolution}...") # Verbose
for ext in FALLBACK_IMAGE_EXTENSIONS:
# Skip if we already tried this extension as primary (and it failed)
if primary_format and ext.lower() == primary_format.lower():
@ -198,9 +195,7 @@ def get_stat_value(stats_dict, map_type_list, stat_key):
if isinstance(map_stats, dict) and stat_key in map_stats:
return map_stats[stat_key] # Return the value for the first match
else:
# print(f" Debug: Stats for '{map_type}' found but key '{stat_key}' or format is invalid.") # Optional debug
pass # Continue checking other map types in the list
# else: print(f" Debug: Map type '{map_type}' not found in stats_dict.") # Optional debug
return None # Return None if no matching map type or stat key was found
@ -214,11 +209,11 @@ def process_library_for_materials(context, asset_library_root_override=None, nod
Scans the library, reads metadata, finds PBRSET node groups in the specified
.blend file, and creates/updates materials linking to them.
"""
print("DEBUG: Script started.") # DEBUG LOG
print("DEBUG: Script started.")
start_time = time.time()
print(f"\n--- Starting Material Creation from Node Groups ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---")
print(f" DEBUG: Received asset_library_root_override: {asset_library_root_override}") # DEBUG LOG (Indented)
print(f" DEBUG: Received nodegroup_blend_file_path_override: {nodegroup_blend_file_path_override}") # DEBUG LOG (Indented)
print(f" DEBUG: Received asset_library_root_override: {asset_library_root_override}")
print(f" DEBUG: Received nodegroup_blend_file_path_override: {nodegroup_blend_file_path_override}")
# --- Determine Asset Library Root ---
@ -229,7 +224,7 @@ def process_library_for_materials(context, asset_library_root_override=None, nod
print("!!! ERROR: Processed asset library root not set in script and not provided via argument.")
print("--- Script aborted. ---")
return False
print(f" DEBUG: Using final PROCESSED_ASSET_LIBRARY_ROOT: {PROCESSED_ASSET_LIBRARY_ROOT}") # DEBUG LOG (Indented)
print(f" DEBUG: Using final PROCESSED_ASSET_LIBRARY_ROOT: {PROCESSED_ASSET_LIBRARY_ROOT}")
# --- Determine Nodegroup Blend File Path ---
if nodegroup_blend_file_path_override:
@ -239,7 +234,7 @@ def process_library_for_materials(context, asset_library_root_override=None, nod
print("!!! ERROR: Nodegroup blend file path not set in script and not provided via argument.")
print("--- Script aborted. ---")
return False
print(f" DEBUG: Using final NODEGROUP_BLEND_FILE_PATH: {NODEGROUP_BLEND_FILE_PATH}") # DEBUG LOG (Indented)
print(f" DEBUG: Using final NODEGROUP_BLEND_FILE_PATH: {NODEGROUP_BLEND_FILE_PATH}")
# --- Pre-run Checks ---
@ -281,8 +276,8 @@ def process_library_for_materials(context, asset_library_root_override=None, nod
else:
placeholder_node_found_in_template = True
print(f" Found Template Material: '{TEMPLATE_MATERIAL_NAME}' with placeholder '{PLACEHOLDER_NODE_LABEL}'")
print(f" DEBUG: Template Material Found: {template_mat is not None}") # DEBUG LOG (Indented)
print(f" DEBUG: Placeholder Node Found in Template: {placeholder_node_found_in_template}") # DEBUG LOG (Indented)
print(f" DEBUG: Template Material Found: {template_mat is not None}")
print(f" DEBUG: Placeholder Node Found in Template: {placeholder_node_found_in_template}")
if not valid_setup:
@ -296,7 +291,6 @@ def process_library_for_materials(context, asset_library_root_override=None, nod
assets_processed = 0
assets_skipped = 0
materials_created = 0
# materials_updated = 0 # Not updating existing materials anymore
node_groups_linked = 0
previews_set = 0
viewport_colors_set = 0
@ -322,7 +316,7 @@ def process_library_for_materials(context, asset_library_root_override=None, nod
metadata_files_found = len(metadata_paths)
print(f"Found {metadata_files_found} metadata.json files.")
print(f" DEBUG: Metadata paths found: {metadata_paths}") # DEBUG LOG (Indented)
print(f" DEBUG: Metadata paths found: {metadata_paths}")
if metadata_files_found == 0:
@ -331,11 +325,11 @@ def process_library_for_materials(context, asset_library_root_override=None, nod
return True # No work needed is considered success
# --- Process Each Metadata File ---
print(f" DEBUG: Starting metadata file loop. Found {len(metadata_paths)} files.") # DEBUG LOG (Indented)
print(f" DEBUG: Starting metadata file loop. Found {len(metadata_paths)} files.")
for metadata_path in metadata_paths:
asset_dir_path = metadata_path.parent
print(f"\n--- Processing Metadata: {metadata_path.relative_to(root_path)} ---")
print(f" DEBUG: Processing file: {metadata_path}") # DEBUG LOG (Indented)
print(f" DEBUG: Processing file: {metadata_path}")
try:
with open(metadata_path, 'r', encoding='utf-8') as f:
metadata = json.load(f)
@ -355,7 +349,7 @@ def process_library_for_materials(context, asset_library_root_override=None, nod
print(f" !!! ERROR: Metadata file is missing 'asset_name'. Skipping.")
errors_encountered += 1
continue
print(f" DEBUG: Valid metadata loaded for asset: {asset_name}") # DEBUG LOG (Indented)
print(f" DEBUG: Valid metadata loaded for asset: {asset_name}")
print(f" Asset Name: {asset_name}")
@ -363,8 +357,8 @@ def process_library_for_materials(context, asset_library_root_override=None, nod
# --- Determine Target Names ---
target_material_name = f"{MATERIAL_NAME_PREFIX}{asset_name}"
target_pbrset_group_name = f"{PBRSET_GROUP_PREFIX}{asset_name}"
print(f" DEBUG: Target Material Name: {target_material_name}") # DEBUG LOG (Indented)
print(f" DEBUG: Target PBRSET Group Name: {target_pbrset_group_name}") # DEBUG LOG (Indented)
print(f" DEBUG: Target Material Name: {target_material_name}")
print(f" DEBUG: Target PBRSET Group Name: {target_pbrset_group_name}")
# --- Check if Material Already Exists (Skip Logic) ---
@ -372,12 +366,12 @@ def process_library_for_materials(context, asset_library_root_override=None, nod
print(f" Skipping asset '{asset_name}': Material '{target_material_name}' already exists.")
assets_skipped += 1
continue # Move to the next metadata file
print(f" DEBUG: Material '{target_material_name}' does not exist. Proceeding with creation.") # DEBUG LOG (Indented)
print(f" DEBUG: Material '{target_material_name}' does not exist. Proceeding with creation.")
# --- Create New Material ---
print(f" Creating new material: '{target_material_name}'")
print(f" DEBUG: Copying template material '{TEMPLATE_MATERIAL_NAME}'") # DEBUG LOG (Indented)
print(f" DEBUG: Copying template material '{TEMPLATE_MATERIAL_NAME}'")
material = template_mat.copy()
if not material:
print(f" !!! ERROR: Failed to copy template material '{TEMPLATE_MATERIAL_NAME}'. Skipping asset '{asset_name}'.")
@ -385,7 +379,7 @@ def process_library_for_materials(context, asset_library_root_override=None, nod
continue
material.name = target_material_name
materials_created += 1
print(f" DEBUG: Material '{material.name}' created.") # DEBUG LOG (Indented)
print(f" DEBUG: Material '{material.name}' created.")
# --- Find Placeholder Node ---
@ -400,13 +394,13 @@ def process_library_for_materials(context, asset_library_root_override=None, nod
placeholder_node = None # Ensure it's None
else:
placeholder_node = placeholder_nodes[0] # Assume first is correct
print(f" DEBUG: Found placeholder node '{placeholder_node.label}' in material '{material.name}'.") # DEBUG LOG (Indented)
print(f" DEBUG: Found placeholder node '{placeholder_node.label}' in material '{material.name}'.")
# --- Find and Link PBRSET Node Group from Library ---
linked_pbrset_group = None
if placeholder_node and pbrset_blend_file_path: # Only proceed if placeholder exists and library file is known
print(f" DEBUG: Placeholder node exists and PBRSET library file path is known: {pbrset_blend_file_path}") # DEBUG LOG (Indented)
print(f" DEBUG: Placeholder node exists and PBRSET library file path is known: {pbrset_blend_file_path}")
# Check if the group is already linked in the current file
existing_linked_group = bpy.data.node_groups.get(target_pbrset_group_name)
# Check if the existing group's library filepath matches the target blend file path
@ -440,7 +434,7 @@ def process_library_for_materials(context, asset_library_root_override=None, nod
# --- Link Linked Node Group to Placeholder ---
if placeholder_node and linked_pbrset_group:
print(f" DEBUG: Attempting to link PBRSET group '{linked_pbrset_group.name}' to placeholder '{placeholder_node.label}'.") # DEBUG LOG (Indented)
print(f" DEBUG: Attempting to link PBRSET group '{linked_pbrset_group.name}' to placeholder '{placeholder_node.label}'.")
if placeholder_node.node_tree != linked_pbrset_group:
try:
placeholder_node.node_tree = linked_pbrset_group
@ -459,7 +453,7 @@ def process_library_for_materials(context, asset_library_root_override=None, nod
# --- Mark Material as Asset ---
if not material.asset_data:
print(f" DEBUG: Marking material '{material.name}' as asset.") # DEBUG LOG (Indented)
print(f" DEBUG: Marking material '{material.name}' as asset.")
try:
material.asset_mark()
print(f" Marked material '{material.name}' as asset.")
@ -468,7 +462,7 @@ def process_library_for_materials(context, asset_library_root_override=None, nod
# --- Copy Asset Tags ---
if material.asset_data and linked_pbrset_group and linked_pbrset_group.asset_data:
print(f" DEBUG: Copying asset tags from PBRSET group to material.") # DEBUG LOG (Indented)
print(f" DEBUG: Copying asset tags from PBRSET group to material.")
tags_copied_count = 0
if supplier_name:
if add_tag_if_new(material.asset_data, supplier_name): tags_copied_count += 1
@ -477,8 +471,6 @@ def process_library_for_materials(context, asset_library_root_override=None, nod
# Copy other tags from PBRSET group
for ng_tag in linked_pbrset_group.asset_data.tags:
if add_tag_if_new(material.asset_data, ng_tag.name): tags_copied_count += 1
# if tags_copied_count > 0: print(f" Copied {tags_copied_count} asset tags to material.") # Optional info
# else: print(f" Warn: Cannot copy tags. Material asset_data: {material.asset_data is not None}, Linked Group: {linked_pbrset_group}, Group asset_data: {linked_pbrset_group.asset_data if linked_pbrset_group else None}") # Debug
# --- Set Custom Preview ---
@ -525,12 +517,12 @@ def process_library_for_materials(context, asset_library_root_override=None, nod
# --- Set Viewport Properties from Stats ---
if image_stats_1k and isinstance(image_stats_1k, dict):
print(f" DEBUG: Applying viewport properties from stats.") # DEBUG LOG (Indented)
print(f" DEBUG: Applying viewport properties from stats.")
# Viewport Color
color_mean = get_stat_value(image_stats_1k, VIEWPORT_COLOR_MAP_TYPES, 'mean')
if isinstance(color_mean, list) and len(color_mean) >= 3:
color_rgba = (*color_mean[:3], 1.0)
print(f" Debug: Raw color_mean from metadata: {color_mean[:3]}") # Added logging
print(f" Debug: Raw color_mean from metadata: {color_mean[:3]}")
if tuple(material.diffuse_color[:3]) != tuple(color_rgba[:3]):
material.diffuse_color = color_rgba
print(f" Set viewport color: {color_rgba[:3]}")
@ -594,14 +586,13 @@ def process_library_for_materials(context, asset_library_root_override=None, nod
print(f"Assets Processed/Attempted: {assets_processed}")
print(f"Assets Skipped (Already Exist): {assets_skipped}")
print(f"Materials Created: {materials_created}")
# print(f"Materials Updated: {materials_updated}") # Removed as we skip existing
print(f"PBRSET Node Groups Linked: {node_groups_linked}")
print(f"Material Previews Set: {previews_set}")
print(f"Viewport Colors Set: {viewport_colors_set}")
print(f"Viewport Roughness Set: {viewport_roughness_set}")
print(f"Viewport Metallic Set: {viewport_metallic_set}")
if pbrset_groups_missing_in_library > 0:
print(f"!!! PBRSET Node Groups Missing in Library File: {pbrset_groups_missing_in_library} !!!") # Updated message
print(f"!!! PBRSET Node Groups Missing in Library File: {pbrset_groups_missing_in_library} !!!")
if library_link_errors > 0:
print(f"!!! Library Link Errors: {library_link_errors} !!!")
if placeholder_nodes_missing > 0:
@ -611,7 +602,7 @@ def process_library_for_materials(context, asset_library_root_override=None, nod
print("---------------------------------------")
# --- Explicit Save ---
print(f" DEBUG: Attempting explicit save for file: {bpy.data.filepath}") # DEBUG LOG (Indented)
print(f" DEBUG: Attempting explicit save for file: {bpy.data.filepath}")
try:
bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)
print("\n--- Explicitly saved the .blend file. ---")
@ -649,7 +640,6 @@ if __name__ == "__main__":
print(f"Found nodegroup blend file path argument: {nodegroup_blend_file_arg}")
else:
print("Info: '--' found but not enough arguments after it for nodegroup blend file.")
# else: print("Info: No '--' found in arguments.") # Optional debug
except Exception as e:
print(f"Error parsing command line arguments: {e}")
# --- End Argument Parsing ---

View File

@ -28,7 +28,7 @@ from pathlib import Path
import time
import re # For parsing aspect ratio string
import base64 # For encoding node group names
import sys # <<< ADDED IMPORT
import sys
# --- USER CONFIGURATION ---
@ -36,7 +36,7 @@ import sys # <<< ADDED IMPORT
# Example: r"G:\Assets\Processed"
# IMPORTANT: This should point to the base directory containing supplier folders (e.g., Poliigon)
# This will be overridden by command-line arguments if provided.
PROCESSED_ASSET_LIBRARY_ROOT = None # Set to None initially
PROCESSED_ASSET_LIBRARY_ROOT = None
# Names of the required node group templates in the Blender file
PARENT_TEMPLATE_NAME = "Template_PBRSET"
@ -109,7 +109,6 @@ CATEGORIES_FOR_NODEGROUP_GENERATION = ["Surface", "Decal"]
def encode_name_b64(name_str):
"""Encodes a string using URL-safe Base64 for node group names."""
try:
# Ensure the input is a string
name_str = str(name_str)
return base64.urlsafe_b64encode(name_str.encode('utf-8')).decode('ascii')
except Exception as e:
@ -174,7 +173,6 @@ def get_color_space(map_type):
return PBR_COLOR_SPACE_MAP[short_type]
# Fallback if no specific rule found
# print(f" Debug: Color space for '{map_type}' (candidates: '{map_type_upper}', '{base_type_candidate}') not found in PBR_COLOR_SPACE_MAP. Using default: {DEFAULT_COLOR_SPACE}")
return DEFAULT_COLOR_SPACE
def calculate_aspect_correction_factor(image_width, image_height, aspect_string):
@ -188,13 +186,11 @@ def calculate_aspect_correction_factor(image_width, image_height, aspect_string)
print(" Warn: Invalid image dimensions for aspect ratio calculation. Returning 1.0.")
return 1.0
# Calculate the actual aspect ratio of the image file
current_aspect_ratio = image_width / image_height
if not aspect_string or aspect_string.upper() == "EVEN":
# If scaling was even, the correction factor is just the image's aspect ratio
# to make UVs match the image proportions.
# print(f" Aspect string is EVEN. Correction factor = current aspect ratio: {current_aspect_ratio:.4f}")
return current_aspect_ratio
# Handle non-uniform scaling cases ("Xnnn", "Ynnn")
@ -216,7 +212,7 @@ def calculate_aspect_correction_factor(image_width, image_height, aspect_string)
# Apply the non-uniform correction formula based on original script logic
scaling_factor_percent = amount / 100.0
correction_factor = current_aspect_ratio # Default
correction_factor = current_aspect_ratio
try:
if axis == 'X':
@ -235,7 +231,6 @@ def calculate_aspect_correction_factor(image_width, image_height, aspect_string)
print(f" Error calculating aspect correction factor: {e}. Returning current ratio {current_aspect_ratio:.4f}.")
return current_aspect_ratio
# print(f" Calculated aspect correction factor: {correction_factor:.4f} (from {image_width}x{image_height}, Scaling='{aspect_string}')")
return correction_factor
@ -256,16 +251,14 @@ def reconstruct_image_path_with_fallback(asset_dir_path, asset_name, map_type, r
if primary_format:
try:
filename = IMAGE_FILENAME_PATTERN.format(
assetname=asset_name, # Token is 'assetname'
maptype=map_type, # Token is 'maptype'
resolution=resolution, # Token is 'resolution'
ext=primary_format.lower() # Token is 'ext'
assetname=asset_name,
maptype=map_type,
resolution=resolution,
ext=primary_format.lower()
)
primary_path = asset_dir_path / filename
if primary_path.is_file():
# print(f" Found primary path: {str(primary_path)}") # Verbose
return str(primary_path)
# else: print(f" Primary path not found: {str(primary_path)}") # Verbose
except KeyError as e:
print(f" !!! ERROR: Missing key '{e}' in IMAGE_FILENAME_PATTERN. Cannot reconstruct path.")
return None # Cannot proceed without valid pattern
@ -274,17 +267,16 @@ def reconstruct_image_path_with_fallback(asset_dir_path, asset_name, map_type, r
# Continue to fallback
# 2. Try fallback extensions
# print(f" Trying fallback extensions for {map_type}/{resolution}...") # Verbose
for ext in FALLBACK_IMAGE_EXTENSIONS:
# Skip if we already tried this extension as primary (and it failed)
if primary_format and ext.lower() == primary_format.lower():
continue
try:
fallback_filename = IMAGE_FILENAME_PATTERN.format(
assetname=asset_name, # Token is 'assetname'
maptype=map_type, # Token is 'maptype'
resolution=resolution, # Token is 'resolution'
ext=ext.lower() # Token is 'ext'
assetname=asset_name,
maptype=map_type,
resolution=resolution,
ext=ext.lower()
)
fallback_path = asset_dir_path / fallback_filename
if fallback_path.is_file():
@ -400,13 +392,13 @@ def update_manifest(manifest_data, asset_name, map_type=None, resolution=None):
# --- Core Logic ---
def process_library(context, asset_library_root_override=None): # Add override parameter
def process_library(context, asset_library_root_override=None):
global ENABLE_MANIFEST # Declare intent to modify global if needed
global PROCESSED_ASSET_LIBRARY_ROOT # Allow modification of global
"""Scans the library, reads metadata, creates/updates node groups."""
start_time = time.time()
print(f"\n--- Starting Node Group Processing ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---")
print(f" DEBUG: Received asset_library_root_override: {asset_library_root_override}") # DEBUG LOG (Indented)
print(f" DEBUG: Received asset_library_root_override: {asset_library_root_override}")
# --- Determine Asset Library Root ---
if asset_library_root_override:
@ -416,7 +408,7 @@ def process_library(context, asset_library_root_override=None): # Add override p
print("!!! ERROR: Processed asset library root not set in script and not provided via argument.")
print("--- Script aborted. ---")
return False
print(f" DEBUG: Using final PROCESSED_ASSET_LIBRARY_ROOT: {PROCESSED_ASSET_LIBRARY_ROOT}") # DEBUG LOG (Indented)
print(f" DEBUG: Using final PROCESSED_ASSET_LIBRARY_ROOT: {PROCESSED_ASSET_LIBRARY_ROOT}")
# --- Pre-run Checks ---
print("Performing pre-run checks...")
@ -429,7 +421,7 @@ def process_library(context, asset_library_root_override=None): # Add override p
valid_setup = False
else:
print(f" Asset Library Root: '{root_path}'")
print(f" DEBUG: Checking for templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'") # DEBUG LOG (Indented)
print(f" DEBUG: Checking for templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'")
# 2. Check Templates
template_parent = bpy.data.node_groups.get(PARENT_TEMPLATE_NAME)
@ -442,8 +434,8 @@ def process_library(context, asset_library_root_override=None): # Add override p
valid_setup = False
if template_parent and template_child:
print(f" Found Templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'")
print(f" DEBUG: Template Parent Found: {template_parent is not None}") # DEBUG LOG (Indented)
print(f" DEBUG: Template Child Found: {template_child is not None}") # DEBUG LOG (Indented)
print(f" DEBUG: Template Parent Found: {template_parent is not None}")
print(f" DEBUG: Template Child Found: {template_child is not None}")
# 3. Check Blend File Saved (if manifest enabled)
if ENABLE_MANIFEST and not context.blend_data.filepath:
@ -495,7 +487,7 @@ def process_library(context, asset_library_root_override=None): # Add override p
metadata_files_found = len(metadata_paths)
print(f"Found {metadata_files_found} metadata.json files.")
print(f" DEBUG: Metadata paths found: {metadata_paths}") # DEBUG LOG (Indented)
print(f" DEBUG: Metadata paths found: {metadata_paths}")
if metadata_files_found == 0:
print("No metadata files found. Nothing to process.")
@ -504,9 +496,9 @@ def process_library(context, asset_library_root_override=None): # Add override p
# --- Process Each Metadata File ---
for metadata_path in metadata_paths:
asset_dir_path = metadata_path.parent # Get the directory containing the metadata file
asset_dir_path = metadata_path.parent
print(f"\n--- Processing Metadata: {metadata_path.relative_to(root_path)} ---")
print(f" DEBUG: Processing file: {metadata_path}") # DEBUG LOG (Indented)
print(f" DEBUG: Processing file: {metadata_path}")
try:
with open(metadata_path, 'r', encoding='utf-8') as f:
metadata = json.load(f)
@ -515,8 +507,7 @@ def process_library(context, asset_library_root_override=None): # Add override p
asset_name = metadata.get("asset_name")
supplier_name = metadata.get("supplier_name")
archetype = metadata.get("archetype")
asset_category = metadata.get("category", "Unknown") # Read "category" key from metadata
# Get map info from the correct keys
asset_category = metadata.get("category", "Unknown")
processed_resolutions = metadata.get("processed_map_resolutions", {}) # Default to empty dict
merged_resolutions = metadata.get("merged_map_resolutions", {}) # Get merged maps too
map_details = metadata.get("map_details", {}) # Default to empty dict
@ -536,7 +527,7 @@ def process_library(context, asset_library_root_override=None): # Add override p
errors_encountered += 1
continue
# map_details check remains a warning as merged maps won't be in it
print(f" DEBUG: Valid metadata loaded for asset: {asset_name}") # DEBUG LOG (Indented)
print(f" DEBUG: Valid metadata loaded for asset: {asset_name}")
print(f" Asset Name: {asset_name}")
@ -625,8 +616,8 @@ def process_library(context, asset_library_root_override=None): # Add override p
# Conditional skip based on asset_category
if asset_category not in CATEGORIES_FOR_NODEGROUP_GENERATION: # Check asset_category
print(f" Skipping nodegroup content generation for asset '{asset_name}' (Category: '{asset_category}'). Tag added.") # Use asset_category in log
if asset_category not in CATEGORIES_FOR_NODEGROUP_GENERATION:
print(f" Skipping nodegroup content generation for asset '{asset_name}' (Category: '{asset_category}'). Tag added.")
assets_processed += 1 # Still count as processed for summary, even if skipped
continue # Skip the rest of the processing for this asset
@ -637,7 +628,7 @@ def process_library(context, asset_library_root_override=None): # Add override p
if parent_group is None:
print(f" Creating new parent group: '{target_parent_name}'")
print(f" DEBUG: Copying parent template '{PARENT_TEMPLATE_NAME}'") # DEBUG LOG (Indented)
print(f" DEBUG: Copying parent template '{PARENT_TEMPLATE_NAME}'")
parent_group = template_parent.copy()
if not parent_group:
print(f" !!! ERROR: Failed to copy parent template '{PARENT_TEMPLATE_NAME}'. Skipping asset '{asset_name}'.")
@ -648,7 +639,7 @@ def process_library(context, asset_library_root_override=None): # Add override p
is_new_parent = True
else:
print(f" Updating existing parent group: '{target_parent_name}'")
print(f" DEBUG: Found existing parent group.") # DEBUG LOG (Indented)
print(f" DEBUG: Found existing parent group.")
parent_groups_updated += 1
# Ensure marked as asset
@ -666,10 +657,9 @@ def process_library(context, asset_library_root_override=None): # Add override p
add_tag_if_new(parent_group.asset_data, supplier_name)
if archetype:
add_tag_if_new(parent_group.asset_data, archetype)
if asset_category: # Use asset_category for tagging
if asset_category:
add_tag_if_new(parent_group.asset_data, asset_category)
# Add other tags if needed
# else: print(f" Warn: Cannot apply tags, asset_data not available for '{parent_group.name}'.") # Optional warning
# Apply Aspect Ratio Correction
@ -689,7 +679,6 @@ def process_library(context, asset_library_root_override=None): # Add override p
aspect_node.outputs[0].default_value = correction_factor
print(f" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f} (was {current_val:.4f})")
aspect_ratio_set += 1
# else: print(f" Warn: Aspect ratio node '{ASPECT_RATIO_NODE_LABEL}' not found in parent group.") # Optional
# Apply Highest Resolution Value
hr_nodes = find_nodes_by_label(parent_group, HIGHEST_RESOLUTION_NODE_LABEL, 'ShaderNodeValue')
@ -700,7 +689,6 @@ def process_library(context, asset_library_root_override=None): # Add override p
hr_node.outputs[0].default_value = highest_resolution_value
print(f" Set '{HIGHEST_RESOLUTION_NODE_LABEL}' value to {highest_resolution_value} ({highest_resolution_str}) (was {current_hr_val:.1f})")
highest_res_set += 1 # Count successful sets
# else: print(f" Warn: Highest resolution node '{HIGHEST_RESOLUTION_NODE_LABEL}' not found in parent group.") # Optional
# Apply Stats (using image_stats_1k)
@ -712,7 +700,7 @@ def process_library(context, asset_library_root_override=None): # Add override p
stats_nodes = find_nodes_by_label(parent_group, stats_node_label, 'ShaderNodeCombineXYZ')
if stats_nodes:
stats_node = stats_nodes[0]
stats = image_stats_1k[map_type_to_stat] # Get stats dict for this map type
stats = image_stats_1k[map_type_to_stat]
if stats and isinstance(stats, dict):
# Handle potential list format for RGB stats (use first value) or direct float
@ -743,10 +731,6 @@ def process_library(context, asset_library_root_override=None): # Add override p
if updated_stat:
print(f" Set stats in '{stats_node_label}': Min={min_val:.4f}, Max={max_val:.4f}, Mean={mean_val:.4f}")
# else: print(f" Info: No valid 'stats' dictionary found for map type '{map_type_to_stat}' in image_stats_1k.") # Optional
# else: print(f" Warn: Stats node '{stats_node_label}' not found in parent group.") # Optional
# else: print(f" Info: Map type '{map_type_to_stat}' not present in image_stats_1k for stats application.") # Optional
# else: print(f" Warn: 'image_stats_1k' missing or invalid in metadata.") # Optional
# --- Set Asset Preview (only for new parent groups) ---
# Use the reference image path found earlier if available
@ -768,14 +752,13 @@ def process_library(context, asset_library_root_override=None): # Add override p
# --- Child Group Handling ---
# Iterate through the COMBINED map types
print(f" DEBUG: Starting child group loop for asset '{asset_name}'. Map types: {list(all_map_resolutions.keys())}") # DEBUG LOG (Indented)
print(f" DEBUG: Starting child group loop for asset '{asset_name}'. Map types: {list(all_map_resolutions.keys())}")
for map_type, resolutions in all_map_resolutions.items():
print(f" Processing Map Type: {map_type}")
# Determine if this is a merged map (not in map_details)
is_merged_map = map_type not in map_details
# Get details for this map type if available
current_map_details = map_details.get(map_type, {})
# For merged maps, primary_format will be None
output_format = current_map_details.get("output_format")
@ -792,7 +775,7 @@ def process_library(context, asset_library_root_override=None): # Add override p
print(f" !!! WARNING: No placeholder node labeled '{map_type}' found in parent group '{parent_group.name}'. Skipping this map type.")
continue
holder_node = holder_nodes[0] # Assume first is correct
print(f" DEBUG: Found placeholder node '{holder_node.label}' for map type '{map_type}'.") # DEBUG LOG (Indented)
print(f" DEBUG: Found placeholder node '{holder_node.label}' for map type '{map_type}'.")
# Determine child group name (LOGICAL and ENCODED)
logical_child_name = f"{asset_name}_{map_type}"
@ -802,8 +785,7 @@ def process_library(context, asset_library_root_override=None): # Add override p
is_new_child = False
if child_group is None:
print(f" DEBUG: Child group '{target_child_name_b64}' not found. Creating new one.") # DEBUG LOG (Indented)
# print(f" Creating new child group: '{target_child_name_b64}' (logical: '{logical_child_name}')") # Verbose
print(f" DEBUG: Child group '{target_child_name_b64}' not found. Creating new one.")
child_group = template_child.copy()
if not child_group:
print(f" !!! ERROR: Failed to copy child template '{CHILD_TEMPLATE_NAME}'. Skipping map type '{map_type}'.")
@ -813,8 +795,7 @@ def process_library(context, asset_library_root_override=None): # Add override p
child_groups_created += 1
is_new_child = True
else:
print(f" DEBUG: Found existing child group '{target_child_name_b64}'.") # DEBUG LOG (Indented)
# print(f" Updating existing child group: '{target_child_name_b64}'") # Verbose
print(f" DEBUG: Found existing child group '{target_child_name_b64}'.")
child_groups_updated += 1
# Assign child group to placeholder if needed
@ -842,10 +823,6 @@ def process_library(context, asset_library_root_override=None): # Add override p
if not link_exists:
parent_group.links.new(source_socket, target_socket)
print(f" Linked '{holder_node.label}' output to parent output socket '{map_type}'.")
# else: # Optional warnings
# if not source_socket: print(f" Warn: Could not find suitable output socket on placeholder '{holder_node.label}'.")
# if not target_socket: print(f" Warn: Could not find input socket '{map_type}' on parent output node.")
# else: print(f" Warn: Parent group '{parent_group.name}' has no Group Output node.")
except Exception as e_link:
print(f" !!! ERROR linking sockets for '{map_type}': {e_link}")
@ -859,7 +836,6 @@ def process_library(context, asset_library_root_override=None): # Add override p
# Defaulting to Color seems reasonable for most PBR outputs
if item.socket_type != 'NodeSocketColor':
item.socket_type = 'NodeSocketColor'
# print(f" Set parent output socket '{map_type}' type to Color.") # Optional info
except Exception as e_sock_type:
print(f" Warn: Could not verify/set socket type for '{map_type}': {e_sock_type}")
@ -872,10 +848,9 @@ def process_library(context, asset_library_root_override=None): # Add override p
for resolution in resolutions:
# --- Manifest Check (Map/Resolution Level) ---
if ENABLE_MANIFEST and is_map_processed(manifest_data, asset_name, map_type, resolution):
# print(f" Skipping {resolution} (Manifest)") # Verbose
maps_skipped_manifest += 1
continue
print(f" DEBUG: Processing map '{map_type}' resolution '{resolution}'. Manifest skip check passed.") # DEBUG LOG (Indented)
print(f" DEBUG: Processing map '{map_type}' resolution '{resolution}'. Manifest skip check passed.")
print(f" Processing Resolution: {resolution}")
@ -888,7 +863,7 @@ def process_library(context, asset_library_root_override=None): # Add override p
resolution=resolution,
primary_format=output_format
)
print(f" DEBUG: Reconstructed image path for {map_type}/{resolution}: {image_path_str}") # DEBUG LOG (Indented)
print(f" DEBUG: Reconstructed image path for {map_type}/{resolution}: {image_path_str}")
if not image_path_str:
# Error already printed by reconstruct function
@ -900,7 +875,7 @@ def process_library(context, asset_library_root_override=None): # Add override p
if not image_nodes:
print(f" !!! WARNING: No Image Texture node labeled '{resolution}' found in child group '{child_group.name}'. Cannot assign image.")
continue # Skip this resolution if node not found
print(f" DEBUG: Found {len(image_nodes)} image node(s) labeled '{resolution}' in child group '{child_group.name}'.") # DEBUG LOG (Indented)
print(f" DEBUG: Found {len(image_nodes)} image node(s) labeled '{resolution}' in child group '{child_group.name}'.")
# --- Load Image ---
img = None
@ -954,7 +929,6 @@ def process_library(context, asset_library_root_override=None): # Add override p
# --- Update Manifest (Map/Resolution Level) ---
if update_manifest(manifest_data, asset_name, map_type, resolution):
manifest_needs_saving = True
# print(f" Marked {map_type}/{resolution} processed in manifest.") # Verbose
maps_processed += 1
else:
@ -1008,13 +982,13 @@ def process_library(context, asset_library_root_override=None): # Add override p
print(f"Individual Maps Processed: {maps_processed}")
print(f"Asset Previews Set: {previews_set}")
print(f"Highest Resolution Nodes Set: {highest_res_set}")
print(f"Aspect Ratio Nodes Set: {aspect_ratio_set}") # Added counter
print(f"Aspect Ratio Nodes Set: {aspect_ratio_set}")
if errors_encountered > 0:
print(f"!!! Errors Encountered: {errors_encountered} !!!")
print("---------------------------")
# --- Explicit Save ---
print(f" DEBUG: Attempting explicit save for file: {bpy.data.filepath}") # DEBUG LOG (Indented)
print(f" DEBUG: Attempting explicit save for file: {bpy.data.filepath}")
try:
bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)
print("\n--- Explicitly saved the .blend file. ---")
@ -1047,7 +1021,6 @@ if __name__ == "__main__":
print(f"Found asset library root argument: {asset_root_arg}")
else:
print("Info: '--' found but no arguments after it.")
# else: print("Info: No '--' found in arguments.") # Optional debug
except Exception as e:
print(f"Error parsing command line arguments: {e}")
# --- End Argument Parsing ---

View File

@ -1,27 +1,20 @@
# configuration.py
import json
import os
from pathlib import Path
import logging
import re # Import the regex module
import json # Import the json module
import re
log = logging.getLogger(__name__) # Use logger defined in main.py
log = logging.getLogger(__name__)
# --- Constants ---
# Assumes config/ and presets/ are relative to this file's location
BASE_DIR = Path(__file__).parent
APP_SETTINGS_PATH = BASE_DIR / "config" / "app_settings.json"
LLM_SETTINGS_PATH = BASE_DIR / "config" / "llm_settings.json" # Added LLM settings path
LLM_SETTINGS_PATH = BASE_DIR / "config" / "llm_settings.json"
PRESETS_DIR = BASE_DIR / "Presets"
# --- Custom Exception ---
class ConfigurationError(Exception):
"""Custom exception for configuration loading errors."""
pass
# --- Helper Functions ---
def _get_base_map_type(target_map_string: str) -> str:
"""Extracts the base map type (e.g., 'COL') from a potentially numbered string ('COL-1')."""
# Use regex to find the leading alphabetical part
@ -72,7 +65,6 @@ def _fnmatch_to_regex(pattern: str) -> str:
return res
# --- Configuration Class ---
class Configuration:
"""
Loads and provides access to core settings combined with a specific preset.
@ -90,10 +82,10 @@ class Configuration:
log.debug(f"Initializing Configuration with preset: '{preset_name}'")
self.preset_name = preset_name
self._core_settings: dict = self._load_core_config()
self._llm_settings: dict = self._load_llm_config() # Load LLM settings
self._llm_settings: dict = self._load_llm_config()
self._preset_settings: dict = self._load_preset(preset_name)
self._validate_configs()
self._compile_regex_patterns() # Compile regex after validation
self._compile_regex_patterns()
log.info(f"Configuration loaded successfully using preset: '{self.preset_name}'")
@ -105,21 +97,14 @@ class Configuration:
self.compiled_bit_depth_regex_map: dict[str, re.Pattern] = {}
# Map: base_map_type -> list of tuples: (compiled_regex, original_keyword, rule_index)
self.compiled_map_keyword_regex: dict[str, list[tuple[re.Pattern, str, int]]] = {}
# Store the original rule order for priority checking later if needed (can be removed if index is stored in tuple)
# self._map_type_rule_order: list[dict] = [] # Keep for now, might be useful elsewhere
# Compile Extra Patterns (case-insensitive)
for pattern in self.move_to_extra_patterns:
try:
# Use the raw fnmatch pattern directly if it's simple enough for re.search
# Or convert using helper if needed. Let's try direct search first.
# We want to find the pattern *within* the filename.
regex_str = _fnmatch_to_regex(pattern) # Convert wildcards
regex_str = _fnmatch_to_regex(pattern)
self.compiled_extra_regex.append(re.compile(regex_str, re.IGNORECASE))
except re.error as e:
log.warning(f"Failed to compile 'extra' regex pattern '{pattern}': {e}. Skipping pattern.")
# Compile Model Patterns (case-insensitive)
model_patterns = self.asset_category_rules.get('model_patterns', [])
for pattern in model_patterns:
try:
@ -128,33 +113,23 @@ class Configuration:
except re.error as e:
log.warning(f"Failed to compile 'model' regex pattern '{pattern}': {e}. Skipping pattern.")
# Compile Bit Depth Variant Patterns (case-sensitive recommended)
for map_type, pattern in self.source_bit_depth_variants.items():
try:
# These often rely on specific suffixes, so anchoring might be better?
# Let's stick to the converted pattern for now, assuming it ends with suffix.
regex_str = _fnmatch_to_regex(pattern) # e.g., ".*_DISP16.*"
# If the original pattern ended with *, remove the trailing '.*' for suffix matching
regex_str = _fnmatch_to_regex(pattern)
if pattern.endswith('*'):
regex_str = regex_str.removesuffix('.*') # e.g., ".*_DISP16"
# Fallback for < 3.9: if regex_str.endswith('.*'): regex_str = regex_str[:-2]
regex_str = regex_str.removesuffix('.*')
# Use the fnmatch-converted regex directly, allowing matches anywhere in the filename
# This is less strict than anchoring to the end with \\.[^.]+$
final_regex_str = regex_str # Use the result from _fnmatch_to_regex
self.compiled_bit_depth_regex_map[map_type] = re.compile(final_regex_str, re.IGNORECASE) # Added IGNORECASE
final_regex_str = regex_str
self.compiled_bit_depth_regex_map[map_type] = re.compile(final_regex_str, re.IGNORECASE)
log.debug(f" Compiled bit depth variant for '{map_type}' as regex (IGNORECASE): {final_regex_str}")
except re.error as e:
log.warning(f"Failed to compile 'bit depth' regex pattern '{pattern}' for map type '{map_type}': {e}. Skipping pattern.")
# Compile Map Type Keywords (case-insensitive) based on the new structure
separator = re.escape(self.source_naming_separator) # Escape separator for regex
# Use defaultdict to easily append to lists for the same base type
separator = re.escape(self.source_naming_separator)
from collections import defaultdict
temp_compiled_map_regex = defaultdict(list)
for rule_index, mapping_rule in enumerate(self.map_type_mapping):
# Validate rule structure (dictionary with target_type and keywords)
if not isinstance(mapping_rule, dict) or \
'target_type' not in mapping_rule or \
'keywords' not in mapping_rule or \
@ -162,34 +137,23 @@ class Configuration:
log.warning(f"Skipping invalid map_type_mapping rule at index {rule_index}: {mapping_rule}. Expected dict with 'target_type' and 'keywords' list.")
continue
target_type = mapping_rule['target_type'].upper() # Use the base type directly
target_type = mapping_rule['target_type'].upper()
source_keywords = mapping_rule['keywords']
# Store the rule for potential priority access later (optional, index is now in tuple)
# self._map_type_rule_order.append(mapping_rule)
# Compile keywords for this rule and store with context
for keyword in source_keywords:
if not isinstance(keyword, str):
log.warning(f"Skipping non-string keyword '{keyword}' in rule {rule_index} for target '{target_type}'.")
continue
try:
# Match keyword potentially surrounded by separators or start/end
# Handle potential wildcards within the keyword using fnmatch conversion
kw_regex_part = _fnmatch_to_regex(keyword)
# Build regex to match the keyword part, anchored by separators or string boundaries
# Use non-capturing groups (?:...)
# Capture the keyword part itself for potential use later if needed (group 1)
regex_str = rf"(?:^|{separator})({kw_regex_part})(?:$|{separator})"
compiled_regex = re.compile(regex_str, re.IGNORECASE)
# Append tuple: (compiled_regex, original_keyword, rule_index)
temp_compiled_map_regex[target_type].append((compiled_regex, keyword, rule_index))
log.debug(f" Compiled keyword '{keyword}' (rule {rule_index}) for target '{target_type}' as regex: {regex_str}")
except re.error as e:
log.warning(f"Failed to compile map keyword regex '{keyword}' for target type '{target_type}': {e}. Skipping keyword.")
# Assign the compiled regex dictionary
self.compiled_map_keyword_regex = dict(temp_compiled_map_regex)
log.debug(f"Compiled map keyword regex keys: {list(self.compiled_map_keyword_regex.keys())}")
@ -217,7 +181,7 @@ class Configuration:
if not LLM_SETTINGS_PATH.is_file():
# Log a warning but don't raise an error, allow fallback if possible
log.warning(f"LLM configuration file not found: {LLM_SETTINGS_PATH}. LLM features might be disabled or use defaults.")
return {} # Return empty dict if file not found
return {}
try:
with open(LLM_SETTINGS_PATH, 'r', encoding='utf-8') as f:
settings = json.load(f)
@ -225,10 +189,10 @@ class Configuration:
return settings
except json.JSONDecodeError as e:
log.error(f"Failed to parse LLM configuration file {LLM_SETTINGS_PATH}: Invalid JSON - {e}")
return {} # Return empty dict on parse error
return {}
except Exception as e:
log.error(f"Failed to read LLM configuration file {LLM_SETTINGS_PATH}: {e}")
return {} # Return empty dict on other read errors
return {}
def _load_preset(self, preset_name: str) -> dict:
@ -272,7 +236,6 @@ class Configuration:
if 'target_type' not in rule or not isinstance(rule['target_type'], str):
raise ConfigurationError(f"Preset '{self.preset_name}': Rule at index {index} in 'map_type_mapping' is missing 'target_type' string.")
# Validate target_type against FILE_TYPE_DEFINITIONS keys
valid_file_type_keys = self._core_settings.get('FILE_TYPE_DEFINITIONS', {}).keys()
if rule['target_type'] not in valid_file_type_keys:
raise ConfigurationError(
@ -288,15 +251,12 @@ class Configuration:
raise ConfigurationError(f"Preset '{self.preset_name}': Keyword at index {kw_index} in rule {index} ('{rule['target_type']}') must be a string.")
# Core validation (check types or specific values if needed)
if not isinstance(self._core_settings.get('TARGET_FILENAME_PATTERN'), str):
raise ConfigurationError("Core config 'TARGET_FILENAME_PATTERN' must be a string.")
# --- Start: Added validation for new output patterns ---
if not isinstance(self._core_settings.get('OUTPUT_DIRECTORY_PATTERN'), str):
raise ConfigurationError("Core config 'OUTPUT_DIRECTORY_PATTERN' must be a string.")
if not isinstance(self._core_settings.get('OUTPUT_FILENAME_PATTERN'), str):
raise ConfigurationError("Core config 'OUTPUT_FILENAME_PATTERN' must be a string.")
# --- End: Added validation for new output patterns ---
if not isinstance(self._core_settings.get('IMAGE_RESOLUTIONS'), dict):
raise ConfigurationError("Core config 'IMAGE_RESOLUTIONS' must be a dictionary.")
@ -312,25 +272,18 @@ class Configuration:
f"Must be one of {list(valid_asset_type_keys)}."
)
# LLM settings validation (check if keys exist if the file was loaded)
if self._llm_settings: # Only validate if LLM settings were loaded
required_llm_keys = [ # Indent this block
if self._llm_settings:
required_llm_keys = [
"llm_predictor_examples", "llm_endpoint_url", "llm_api_key",
"llm_model_name", "llm_temperature", "llm_request_timeout",
"llm_predictor_prompt"
]
for key in required_llm_keys: # Indent this block
if key not in self._llm_settings: # Indent this block
for key in required_llm_keys:
if key not in self._llm_settings:
# Log warning instead of raising error to allow partial functionality
log.warning(f"LLM config is missing recommended key: '{key}'. LLM features might not work correctly.") # Indent this block
# raise ConfigurationError(f"LLM config is missing required key: '{key}'.") # Indent this block
log.warning(f"LLM config is missing recommended key: '{key}'. LLM features might not work correctly.")
log.debug("Configuration validation passed.")
# Add more checks as necessary
log.debug("Configuration validation passed.") # Keep this alignment
# --- Accessor Methods/Properties ---
# Use @property for direct access, methods for potentially complex lookups/defaults
@property
def supplier_name(self) -> str:
@ -344,7 +297,7 @@ class Configuration:
@property
def target_filename_pattern(self) -> str:
return self._core_settings['TARGET_FILENAME_PATTERN'] # Assumes validation passed
return self._core_settings['TARGET_FILENAME_PATTERN']
@property
def output_directory_pattern(self) -> str:
@ -362,7 +315,7 @@ class Configuration:
@property
def image_resolutions(self) -> dict[str, int]:
return self._core_settings['IMAGE_RESOLUTIONS'] # Assumes validation passed
return self._core_settings['IMAGE_RESOLUTIONS']
@property
@ -424,7 +377,7 @@ class Configuration:
@property
def jpg_quality(self) -> int:
"""Gets the configured JPG quality level."""
return self._core_settings.get('JPG_QUALITY', 95) # Use default if somehow missing
return self._core_settings.get('JPG_QUALITY', 95)
@property
def resolution_threshold_for_jpg(self) -> int:
@ -466,7 +419,7 @@ class Configuration:
# 2. Try to derive base FTD key by stripping common variant suffixes
# Regex to remove trailing suffixes like -<digits>, -<alphanum>, _<alphanum>
base_ftd_key_candidate = re.sub(r"(-[\w\d]+|_[\w\d]+)$", "", map_type_input)
if base_ftd_key_candidate != map_type_input: # Check if stripping occurred
if base_ftd_key_candidate != map_type_input:
definition = file_type_definitions.get(base_ftd_key_candidate)
if definition:
rule = definition.get('bit_depth_rule')
@ -501,13 +454,10 @@ class Configuration:
for _key, definition in file_type_definitions.items():
if isinstance(definition, dict):
standard_type = definition.get('standard_type')
# Ensure standard_type is a non-empty string
if standard_type and isinstance(standard_type, str) and standard_type.strip():
aliases.add(standard_type)
return sorted(list(aliases))
# --- LLM Prompt Data Accessors ---
def get_asset_type_definitions(self) -> dict:
"""Returns the ASSET_TYPE_DEFINITIONS dictionary from core settings."""
return self._core_settings.get('ASSET_TYPE_DEFINITIONS', {})
@ -532,7 +482,7 @@ class Configuration:
@property
def llm_predictor_prompt(self) -> str:
"""Returns the LLM predictor prompt string from LLM settings."""
return self._llm_settings.get('llm_predictor_prompt', '') # Fallback to empty string
return self._llm_settings.get('llm_predictor_prompt', '')
@property
def llm_endpoint_url(self) -> str:
@ -552,12 +502,12 @@ class Configuration:
@property
def llm_temperature(self) -> float:
"""Returns the LLM temperature from LLM settings."""
return self._llm_settings.get('llm_temperature', 0.5) # Default temperature
return self._llm_settings.get('llm_temperature', 0.5)
@property
def llm_request_timeout(self) -> int:
"""Returns the LLM request timeout in seconds from LLM settings."""
return self._llm_settings.get('llm_request_timeout', 120) # Default timeout
return self._llm_settings.get('llm_request_timeout', 120)
@property
def keybind_config(self) -> dict[str, list[str]]:
@ -582,14 +532,11 @@ class Configuration:
# For now, we rely on the order they appear in the config.
return keybinds
# --- Standalone Base Config Functions ---
def load_base_config() -> dict:
"""
Loads only the base configuration from app_settings.json.
Does not load presets or perform merging/validation.
"""
#log.debug(f"Loading base config from: {APP_SETTINGS_PATH}")
if not APP_SETTINGS_PATH.is_file():
log.error(f"Base configuration file not found: {APP_SETTINGS_PATH}")
# Return empty dict or raise a specific error if preferred
@ -598,14 +545,13 @@ def load_base_config() -> dict:
try:
with open(APP_SETTINGS_PATH, 'r', encoding='utf-8') as f:
settings = json.load(f)
#log.debug(f"Base config loaded successfully.")
return settings
except json.JSONDecodeError as e:
log.error(f"Failed to parse base configuration file {APP_SETTINGS_PATH}: Invalid JSON - {e}")
return {} # Return empty dict on error
return {}
except Exception as e:
log.error(f"Failed to read base configuration file {APP_SETTINGS_PATH}: {e}")
return {} # Return empty dict on error
return {}
def save_llm_config(settings_dict: dict):
"""
@ -615,7 +561,8 @@ def save_llm_config(settings_dict: dict):
try:
with open(LLM_SETTINGS_PATH, 'w', encoding='utf-8') as f:
json.dump(settings_dict, f, indent=4)
log.info(f"LLM config saved successfully to {LLM_SETTINGS_PATH}") # Use info level for successful save
# Use info level for successful save
log.info(f"LLM config saved successfully to {LLM_SETTINGS_PATH}")
except Exception as e:
log.error(f"Failed to save LLM configuration file {LLM_SETTINGS_PATH}: {e}")
# Re-raise as ConfigurationError to signal failure upstream

View File

@ -3,7 +3,7 @@ import logging
from PySide6.QtCore import QObject, Slot, QModelIndex
from PySide6.QtGui import QColor # Might be needed if copying logic directly, though unlikely now
from pathlib import Path
from .unified_view_model import UnifiedViewModel # Use relative import
from .unified_view_model import UnifiedViewModel
from rule_structure import SourceRule, AssetRule, FileRule
log = logging.getLogger(__name__)
@ -25,7 +25,7 @@ class AssetRestructureHandler(QObject):
log.debug("AssetRestructureHandler initialized.")
@Slot(FileRule, str, QModelIndex)
def handle_target_asset_override(self, file_rule_item: FileRule, new_target_name: str, index: QModelIndex): # Ensure FileRule is imported
def handle_target_asset_override(self, file_rule_item: FileRule, new_target_name: str, index: QModelIndex):
"""
Slot connected to UnifiedViewModel.targetAssetOverrideChanged.
Orchestrates model changes based on the new target asset path.
@ -35,7 +35,7 @@ class AssetRestructureHandler(QObject):
new_target_name: The new target asset path (string).
index: The QModelIndex of the changed item (passed by the signal).
"""
if not isinstance(file_rule_item, FileRule): # Check the correct parameter
if not isinstance(file_rule_item, FileRule):
log.warning(f"Handler received targetAssetOverrideChanged for non-FileRule item: {type(file_rule_item)}. Aborting.")
return
@ -47,14 +47,12 @@ class AssetRestructureHandler(QObject):
if effective_new_target_name == "": effective_new_target_name = None # Treat empty string as None
# --- Get necessary context ---
# Use file_rule_item directly
old_parent_asset = getattr(file_rule_item, 'parent_asset', None)
if not old_parent_asset:
log.error(f"Handler: File item '{Path(file_rule_item.file_path).name}' has no parent asset. Cannot restructure.")
# Note: Data change already happened in setData, cannot easily revert here.
return
# Use file_rule_item directly
source_rule = getattr(old_parent_asset, 'parent_source', None)
if not source_rule:
log.error(f"Handler: Could not find SourceRule for parent asset '{old_parent_asset.asset_name}'. Cannot restructure.")
@ -81,7 +79,7 @@ class AssetRestructureHandler(QObject):
except ValueError:
log.error(f"Handler: Could not find SourceRule index while looking for target parent '{effective_new_target_name}'.")
target_parent_asset = None # Reset if index is invalid
break # Found the asset
break
# 2. Handle Move or Creation
if target_parent_asset: # An existing AssetRule to move to was found
@ -91,7 +89,7 @@ class AssetRestructureHandler(QObject):
# The 'index' parameter IS the QModelIndex of the FileRule being changed.
# No need to re-fetch or re-validate it if the signal emits it correctly.
# The core issue was using a stale index to get the *object*, now we *have* the object.
source_file_qmodelindex = index # Use the index passed by the signal
source_file_qmodelindex = index
if not source_file_qmodelindex or not source_file_qmodelindex.isValid(): # Should always be valid if signal emits it
log.error(f"Handler: Received invalid QModelIndex for source file '{Path(file_rule_item.file_path).name}'. Cannot move.")
@ -114,7 +112,7 @@ class AssetRestructureHandler(QObject):
target_parent_asset = new_asset_qmodelindex.internalPointer() # Get the newly created AssetRule object
target_parent_index = new_asset_qmodelindex # The QModelIndex of the new AssetRule
source_file_qmodelindex = index # Use the index passed by the signal
source_file_qmodelindex = index
if not source_file_qmodelindex or not source_file_qmodelindex.isValid(): # Should always be valid
log.error(f"Handler: Received invalid QModelIndex for source file '{Path(file_rule_item.file_path).name}'. Cannot move to new asset.")
self.model.removeAssetRule(target_parent_asset) # Attempt to clean up newly created asset
@ -185,8 +183,8 @@ class AssetRestructureHandler(QObject):
return QModelIndex()
return QModelIndex()
@Slot(AssetRule, str, QModelIndex) # Updated signature
def handle_asset_name_changed(self, asset_rule_item: AssetRule, new_name: str, index: QModelIndex): # Ensure AssetRule is imported
@Slot(AssetRule, str, QModelIndex)
def handle_asset_name_changed(self, asset_rule_item: AssetRule, new_name: str, index: QModelIndex):
"""
Slot connected to UnifiedViewModel.assetNameChanged.
Handles logic when an AssetRule's name is changed.

View File

@ -1,4 +1,3 @@
# gui/base_prediction_handler.py
import logging
import time
from abc import ABC, abstractmethod
@ -16,7 +15,7 @@ except ImportError:
class SourceRule: pass
from abc import ABCMeta
from PySide6.QtCore import QObject # Ensure QObject is imported if not already
from PySide6.QtCore import QObject
# Combine metaclasses to avoid conflict between QObject and ABC
class QtABCMeta(type(QObject), ABCMeta):
@ -52,7 +51,7 @@ class BasePredictionHandler(QObject, ABC, metaclass=QtABCMeta):
super().__init__(parent)
self.input_source_identifier = input_source_identifier
self._is_running = False
self._is_cancelled = False # Added cancellation flag
self._is_cancelled = False
@property
def is_running(self) -> bool:
@ -65,7 +64,7 @@ class BasePredictionHandler(QObject, ABC, metaclass=QtABCMeta):
Main execution slot intended to be connected to QThread.started.
Handles the overall process: setup, execution, error handling, signaling.
"""
log.debug(f"--> Entered BasePredictionHandler.run() for {self.input_source_identifier}") # ADDED DEBUG LOG
log.debug(f"--> Entered BasePredictionHandler.run() for {self.input_source_identifier}")
if self._is_running:
log.warning(f"Handler for '{self.input_source_identifier}' is already running. Aborting.")
return
@ -75,7 +74,7 @@ class BasePredictionHandler(QObject, ABC, metaclass=QtABCMeta):
return
self._is_running = True
self._is_cancelled = False # Ensure cancel flag is reset at start
self._is_cancelled = False
thread_id = QThread.currentThread() # Use currentThread() for PySide6
log.info(f"[{time.time():.4f}][T:{thread_id}] Starting prediction run for: {self.input_source_identifier}")
self.status_update.emit(f"Starting analysis for '{Path(self.input_source_identifier).name}'...")
@ -99,7 +98,7 @@ class BasePredictionHandler(QObject, ABC, metaclass=QtABCMeta):
error_msg = f"Error analyzing '{Path(self.input_source_identifier).name}': {e}"
self.prediction_error.emit(self.input_source_identifier, error_msg)
# Status update might be redundant if error is shown elsewhere, but can be useful
# self.status_update.emit(f"Error: {e}")
# Status update might be redundant if error is shown elsewhere, but can be useful
finally:
# --- Cleanup ---

View File

@ -1,4 +1,3 @@
# gui/config_editor_dialog.py
import json
from PySide6.QtWidgets import (
@ -7,14 +6,13 @@ from PySide6.QtWidgets import (
QPushButton, QFileDialog, QLabel, QTableWidget,
QTableWidgetItem, QDialogButtonBox, QMessageBox, QListWidget,
QListWidgetItem, QFormLayout, QGroupBox, QStackedWidget,
QHeaderView, QSizePolicy # Added QHeaderView and QSizePolicy
QHeaderView, QSizePolicy
)
from PySide6.QtGui import QColor, QPainter
from PySide6.QtCore import Qt, QEvent
from PySide6.QtWidgets import QColorDialog, QStyledItemDelegate, QApplication
# Assuming configuration.py is in the parent directory or accessible
# Adjust import path if necessary
try:
from configuration import load_base_config, save_base_config
except ImportError:
@ -30,7 +28,6 @@ class ColorDelegate(QStyledItemDelegate):
if isinstance(color_str, str) and color_str.startswith('#'):
color = QColor(color_str)
if color.isValid():
# Fill the background with the color
painter.fillRect(option.rect, color)
# Optionally draw text (e.g., the hex code) centered
# painter.drawText(option.rect, Qt.AlignCenter, color_str)
@ -198,19 +195,19 @@ class ConfigEditorDialog(QDialog):
output_dir_layout.addWidget(output_dir_edit)
output_dir_layout.addWidget(output_dir_button)
form_layout.addRow(output_dir_label, output_dir_layout)
self.widgets["OUTPUT_BASE_DIR"] = output_dir_edit # Store reference
self.widgets["OUTPUT_BASE_DIR"] = output_dir_edit
# 2. EXTRA_FILES_SUBDIR: QLineEdit
extra_subdir_label = QLabel("Subdirectory for Extra Files:")
extra_subdir_edit = QLineEdit()
form_layout.addRow(extra_subdir_label, extra_subdir_edit)
self.widgets["EXTRA_FILES_SUBDIR"] = extra_subdir_edit # Store reference
self.widgets["EXTRA_FILES_SUBDIR"] = extra_subdir_edit
# 3. METADATA_FILENAME: QLineEdit
metadata_label = QLabel("Metadata Filename:")
metadata_edit = QLineEdit()
form_layout.addRow(metadata_label, metadata_edit)
self.widgets["METADATA_FILENAME"] = metadata_edit # Store reference
self.widgets["METADATA_FILENAME"] = metadata_edit
layout.addLayout(form_layout)
layout.addStretch() # Keep stretch at the end
@ -245,10 +242,8 @@ class ConfigEditorDialog(QDialog):
self.widgets.pop("RESPECT_VARIANT_MAP_TYPES", None)
self.widgets.pop("ASPECT_RATIO_DECIMALS", None)
# Main layout for this tab
main_tab_layout = QVBoxLayout()
# Form layout for simple input fields
form_layout = QFormLayout()
# 1. TARGET_FILENAME_PATTERN: QLineEdit
@ -276,7 +271,6 @@ class ConfigEditorDialog(QDialog):
main_tab_layout.addLayout(form_layout)
# Add the main layout to the tab's provided layout
layout.addLayout(main_tab_layout)
layout.addStretch() # Keep stretch at the end of the tab's main layout
@ -315,7 +309,6 @@ class ConfigEditorDialog(QDialog):
for key in keys_to_clear:
self.widgets.pop(key, None)
# Main layout for this tab
main_tab_layout = QVBoxLayout()
# --- IMAGE_RESOLUTIONS Section ---
@ -332,7 +325,7 @@ class ConfigEditorDialog(QDialog):
# TODO: Implement custom delegate for "Resolution (px)" column
# TODO: Connect add/remove buttons signals
resolutions_layout.addWidget(resolutions_table)
self.widgets["IMAGE_RESOLUTIONS_TABLE"] = resolutions_table # Store table reference
self.widgets["IMAGE_RESOLUTIONS_TABLE"] = resolutions_table
resolutions_button_layout = QHBoxLayout()
add_res_button = QPushButton("Add Row")
@ -398,7 +391,6 @@ class ConfigEditorDialog(QDialog):
main_tab_layout.addLayout(form_layout)
# Add the main layout to the tab's provided layout
layout.addLayout(main_tab_layout)
layout.addStretch() # Keep stretch at the end of the tab's main layout
@ -436,7 +428,6 @@ class ConfigEditorDialog(QDialog):
self.widgets.pop("MAP_BIT_DEPTH_RULES_TABLE", None)
# Overall QVBoxLayout for the "Definitions" tab
overall_layout = QVBoxLayout()
# --- Top Widget: DEFAULT_ASSET_CATEGORY ---
@ -527,7 +518,6 @@ class ConfigEditorDialog(QDialog):
file_types_button_layout.addStretch()
file_types_layout.addLayout(file_types_button_layout)
# Add the overall layout to the main tab layout provided
layout.addLayout(overall_layout)
layout.addStretch() # Keep stretch at the end of the tab's main layout
@ -642,7 +632,7 @@ class ConfigEditorDialog(QDialog):
nodegroup_layout.addWidget(nodegroup_widget)
nodegroup_layout.addWidget(nodegroup_button)
form_layout.addRow(nodegroup_label, nodegroup_layout)
self.widgets["DEFAULT_NODEGROUP_BLEND_PATH"] = nodegroup_widget # Store reference
self.widgets["DEFAULT_NODEGROUP_BLEND_PATH"] = nodegroup_widget
# 2. DEFAULT_MATERIALS_BLEND_PATH: QLineEdit + QPushButton
materials_label = QLabel("Default Materials Library (.blend):")
@ -655,7 +645,7 @@ class ConfigEditorDialog(QDialog):
materials_layout.addWidget(materials_widget)
materials_layout.addWidget(materials_button)
form_layout.addRow(materials_label, materials_layout)
self.widgets["DEFAULT_MATERIALS_BLEND_PATH"] = materials_widget # Store reference
self.widgets["DEFAULT_MATERIALS_BLEND_PATH"] = materials_widget
# 3. BLENDER_EXECUTABLE_PATH: QLineEdit + QPushButton
blender_label = QLabel("Blender Executable Path:")
@ -668,7 +658,7 @@ class ConfigEditorDialog(QDialog):
blender_layout.addWidget(blender_widget)
blender_layout.addWidget(blender_button)
form_layout.addRow(blender_label, blender_layout)
self.widgets["BLENDER_EXECUTABLE_PATH"] = blender_widget # Store reference
self.widgets["BLENDER_EXECUTABLE_PATH"] = blender_widget
layout.addLayout(form_layout)
layout.addStretch()
@ -686,7 +676,7 @@ class ConfigEditorDialog(QDialog):
# TODO: Implement custom delegate for "Examples" column (QLineEdit)
layout.addWidget(table)
self.widgets["ASSET_TYPE_DEFINITIONS_TABLE"] = table # Store table reference
self.widgets["ASSET_TYPE_DEFINITIONS_TABLE"] = table
def create_file_type_definitions_table_widget(self, layout, definitions_data):
"""Creates a QTableWidget for editing file type definitions."""
@ -703,7 +693,7 @@ class ConfigEditorDialog(QDialog):
# TODO: Implement custom delegate for "Bit Depth Rule" column (QComboBox)
layout.addWidget(table)
self.widgets["FILE_TYPE_DEFINITIONS_TABLE"] = table # Store table reference
self.widgets["FILE_TYPE_DEFINITIONS_TABLE"] = table
def create_image_resolutions_table_widget(self, layout, resolutions_data):
"""Creates a QTableWidget for editing image resolutions."""
@ -717,7 +707,7 @@ class ConfigEditorDialog(QDialog):
# TODO: Implement custom delegate for "Resolution (px)" column (e.g., QLineEdit with validation or two SpinBoxes)
layout.addWidget(table)
self.widgets["IMAGE_RESOLUTIONS_TABLE"] = table # Store table reference
self.widgets["IMAGE_RESOLUTIONS_TABLE"] = table
def create_map_bit_depth_rules_table_widget(self, layout, rules_data: dict):
"""Creates a QTableWidget for editing map bit depth rules (Map Type -> Rule)."""
@ -731,7 +721,7 @@ class ConfigEditorDialog(QDialog):
# TODO: Implement custom delegate for "Rule" column (QComboBox)
layout.addWidget(table)
self.widgets["MAP_BIT_DEPTH_RULES_TABLE"] = table # Store table reference
self.widgets["MAP_BIT_DEPTH_RULES_TABLE"] = table
def create_map_merge_rules_widget(self, layout, rules_data):
@ -803,7 +793,7 @@ class ConfigEditorDialog(QDialog):
group_layout.addWidget(input_table)
self.merge_rule_details_layout.addRow(group)
self.merge_rule_widgets["inputs_table"] = input_table # Store table reference
self.merge_rule_widgets["inputs_table"] = input_table
# defaults: QTableWidget (Fixed Rows: R, G, B, A. Columns: "Channel", "Default Value"). Label: "Channel Defaults (if input missing)".
@ -824,7 +814,7 @@ class ConfigEditorDialog(QDialog):
group_layout.addWidget(defaults_table)
self.merge_rule_details_layout.addRow(group)
self.merge_rule_widgets["defaults_table"] = defaults_table # Store table reference
self.merge_rule_widgets["defaults_table"] = defaults_table
# output_bit_depth: QComboBox (Options: "respect_inputs", "force_8bit", "force_16bit"). Label: "Output Bit Depth".
@ -1175,18 +1165,6 @@ class ConfigEditorDialog(QDialog):
row += 1
# Removed duplicated methods:
# - create_map_merge_rules_widget (duplicate of lines 684-689)
# - populate_merge_rules_list (duplicate of lines 691-698)
# - display_merge_rule_details (duplicate of lines 699-788)
# - browse_path (duplicate of lines 790-801)
# - pick_color (duplicate of lines 802-807)
# - save_settings (duplicate of lines 808-874)
# - populate_widgets_from_settings (duplicate of lines 875-933)
# - populate_asset_definitions_table (duplicate of lines 935-969)
# - populate_file_type_definitions_table (duplicate of lines 971-1005)
# - populate_image_resolutions_table (duplicate of lines 1006-1018)
# - populate_map_bit_depth_rules_table (duplicate of lines 1020-1027)
# Example usage (for testing the dialog independently)

View File

@ -1,41 +1,34 @@
from pathlib import Path
# gui/delegates.py
from PySide6.QtWidgets import QStyledItemDelegate, QLineEdit, QComboBox
from PySide6.QtCore import Qt, QModelIndex
# Import Configuration and ConfigurationError
from configuration import Configuration, ConfigurationError, load_base_config # Keep load_base_config for SupplierSearchDelegate
from PySide6.QtWidgets import QListWidgetItem # Import QListWidgetItem
from PySide6.QtWidgets import QListWidgetItem
import json
import logging
import os # Added for path manipulation if needed, though json.dump handles creation
from PySide6.QtWidgets import QCompleter # Added QCompleter
import os
from PySide6.QtWidgets import QCompleter
# Configure logging
log = logging.getLogger(__name__)
SUPPLIERS_CONFIG_PATH = "config/suppliers.json"
class LineEditDelegate(QStyledItemDelegate):
"""Delegate for editing string values using a QLineEdit."""
def createEditor(self, parent, option, index):
# Creates the QLineEdit editor widget used for editing.
editor = QLineEdit(parent)
return editor
def setEditorData(self, editor: QLineEdit, index: QModelIndex):
# Sets the editor's initial data based on the model's data.
# Use EditRole to get the raw data suitable for editing.
value = index.model().data(index, Qt.EditRole)
editor.setText(str(value) if value is not None else "")
def setModelData(self, editor: QLineEdit, model, index: QModelIndex):
# Commits the editor's data back to the model.
value = editor.text()
# Pass the potentially modified text back to the model's setData.
model.setData(index, value, Qt.EditRole)
def updateEditorGeometry(self, editor, option, index):
# Ensures the editor widget is placed correctly within the cell.
editor.setGeometry(option.rect)
@ -51,10 +44,9 @@ class ComboBoxDelegate(QStyledItemDelegate):
# REMOVED self.main_window store
def createEditor(self, parent, option, index: QModelIndex):
# Creates the QComboBox editor widget.
editor = QComboBox(parent)
column = index.column()
model = index.model() # GET model from index
model = index.model()
# Add a "clear" option first, associating None with it.
editor.addItem("---", None) # UserData = None
@ -74,8 +66,6 @@ class ComboBoxDelegate(QStyledItemDelegate):
items_keys = model._asset_type_keys # Use cached keys
elif column == COL_ITEM_TYPE:
items_keys = model._file_type_keys # Use cached keys
# else: # Handle other columns if necessary (optional)
# log.debug(f"ComboBoxDelegate applied to unexpected column: {column}")
except Exception as e:
log.error(f"Error getting keys from UnifiedViewModel in ComboBoxDelegate: {e}")
@ -98,7 +88,6 @@ class ComboBoxDelegate(QStyledItemDelegate):
return editor
def setEditorData(self, editor: QComboBox, index: QModelIndex):
# Sets the combo box's current item based on the model's string data.
# Get the current string value (or None) from the model via EditRole.
value = index.model().data(index, Qt.EditRole) # This should be a string or None
@ -115,7 +104,6 @@ class ComboBoxDelegate(QStyledItemDelegate):
def setModelData(self, editor: QComboBox, model, index: QModelIndex):
# Commits the selected combo box data (string or None) back to the model.
# Get the UserData associated with the currently selected item.
# This will be the string value or None (for the "---" option).
value = editor.currentData() # This is either the string or None
@ -123,7 +111,6 @@ class ComboBoxDelegate(QStyledItemDelegate):
model.setData(index, value, Qt.EditRole)
def updateEditorGeometry(self, editor, option, index):
# Ensures the editor widget is placed correctly within the cell.
editor.setGeometry(option.rect)
class SupplierSearchDelegate(QStyledItemDelegate):

View File

@ -9,7 +9,7 @@ from PySide6.QtWidgets import (
from PySide6.QtCore import Slot as pyqtSlot, Signal as pyqtSignal # Use PySide6 equivalents
# Assuming configuration module exists and has relevant functions later
from configuration import save_llm_config, ConfigurationError # Import necessary items
from configuration import save_llm_config, ConfigurationError
# For now, define path directly for initial structure
LLM_CONFIG_PATH = "config/llm_settings.json"
@ -102,10 +102,8 @@ class LLMEditorWidget(QWidget):
def _connect_signals(self):
"""Connect signals to slots."""
# Save button
self.save_button.clicked.connect(self._save_settings)
# Fields triggering unsaved changes
self.prompt_editor.textChanged.connect(self._mark_unsaved)
self.endpoint_url_edit.textChanged.connect(self._mark_unsaved)
self.api_key_edit.textChanged.connect(self._mark_unsaved)
@ -113,7 +111,6 @@ class LLMEditorWidget(QWidget):
self.temperature_spinbox.valueChanged.connect(self._mark_unsaved)
self.timeout_spinbox.valueChanged.connect(self._mark_unsaved)
# Example management buttons and tab close signal
self.add_example_button.clicked.connect(self._add_example_tab)
self.delete_example_button.clicked.connect(self._delete_current_example_tab)
self.examples_tab_widget.tabCloseRequested.connect(self._remove_example_tab)
@ -145,7 +142,7 @@ class LLMEditorWidget(QWidget):
example_text = json.dumps(example, indent=4)
example_editor = QTextEdit()
example_editor.setPlainText(example_text)
example_editor.textChanged.connect(self._mark_unsaved) # Connect here
example_editor.textChanged.connect(self._mark_unsaved)
self.examples_tab_widget.addTab(example_editor, f"Example {i+1}")
except TypeError as e:
logger.error(f"Error formatting example {i+1}: {e}. Skipping.")
@ -277,7 +274,7 @@ class LLMEditorWidget(QWidget):
logger.debug("Adding new example tab.")
new_example_editor = QTextEdit()
new_example_editor.setPlaceholderText("Enter example JSON here...")
new_example_editor.textChanged.connect(self._mark_unsaved) # Connect signal
new_example_editor.textChanged.connect(self._mark_unsaved)
# Determine the next example number
next_example_num = self.examples_tab_widget.count() + 1

View File

@ -1,5 +1,5 @@
import os
import json # Added for direct config loading
import json
import logging
from pathlib import Path
@ -8,18 +8,14 @@ from PySide6.QtCore import QObject, Signal, QThread, Slot, QTimer
# --- Backend Imports ---
# Assuming these might be needed based on MainWindow's usage
try:
# Removed load_base_config import
# Removed Configuration import as we load manually now
from configuration import ConfigurationError # Keep error class
from .llm_prediction_handler import LLMPredictionHandler # Backend handler
from rule_structure import SourceRule # For signal emission type hint
except ImportError as e:
logging.getLogger(__name__).critical(f"Failed to import backend modules for LLMInteractionHandler: {e}")
LLMPredictionHandler = None
# load_base_config = None # Removed
ConfigurationError = Exception
SourceRule = None # Define as None if import fails
# Configuration = None # Removed
log = logging.getLogger(__name__)
# Define config file paths relative to this handler's location
@ -97,7 +93,7 @@ class LLMInteractionHandler(QObject):
def queue_llm_requests_batch(self, requests: list[tuple[str, list | None]]):
"""Adds multiple requests to the LLM processing queue."""
added_count = 0
log.debug(f"Queueing batch. Current queue content: {self.llm_processing_queue}") # ADDED DEBUG LOG
log.debug(f"Queueing batch. Current queue content: {self.llm_processing_queue}")
for input_path, file_list in requests:
is_in_queue = any(item[0] == input_path for item in self.llm_processing_queue)
if not is_in_queue:
@ -108,7 +104,6 @@ class LLMInteractionHandler(QObject):
if added_count > 0:
log.info(f"Added {added_count} requests to LLM queue. New size: {len(self.llm_processing_queue)}")
# If not currently processing, start the queue
if not self._is_processing:
QTimer.singleShot(0, self._process_next_llm_item)
@ -269,9 +264,9 @@ class LLMInteractionHandler(QObject):
# log.debug(f"--> Entered LLMPredictionHandler.run() for {self.input_path}")
self.llm_prediction_thread.start()
log.debug(f"LLM prediction thread start() called for {input_path_str}. Is running: {self.llm_prediction_thread.isRunning()}") # ADDED DEBUG LOG
log.debug(f"LLM prediction thread start() called for {input_path_str}. Is running: {self.llm_prediction_thread.isRunning()}")
# Log success *after* start() is called successfully
log.debug(f"Successfully initiated LLM prediction thread for {input_path_str}.") # MOVED/REWORDED LOG
log.debug(f"Successfully initiated LLM prediction thread for {input_path_str}.")
except Exception as e:
# --- Handle errors during setup/start ---
@ -357,8 +352,6 @@ class LLMInteractionHandler(QObject):
# Pass the potentially None file_list. _start_llm_prediction handles extraction if needed.
self._start_llm_prediction(next_dir, file_list=file_list)
# --- DO NOT pop item here. Item is popped in _handle_llm_result or _handle_llm_error ---
# Log message moved into the try block of _start_llm_prediction
# log.debug(f"Successfully started LLM prediction thread for {next_dir}. Item remains in queue until finished.")
except Exception as e:
# This block now catches errors from _start_llm_prediction itself
log.exception(f"Error occurred *during* _start_llm_prediction call for {next_dir}: {e}")

View File

@ -1,25 +1,23 @@
import os
import json
import requests
import re # Added import for regex
import logging # Add logging
from pathlib import Path # Add Path for basename
from PySide6.QtCore import QObject, Slot # Keep QObject for parent type hint, Slot for cancel if kept separate
import re
import logging
from pathlib import Path
from PySide6.QtCore import QObject, Slot
# Removed Signal, QThread as they are handled by BasePredictionHandler or caller
from typing import List, Dict, Any
# Assuming rule_structure defines SourceRule, AssetRule, FileRule etc.
# Adjust the import path if necessary based on project structure
from rule_structure import SourceRule, AssetRule, FileRule # Ensure AssetRule and FileRule are imported
from rule_structure import SourceRule, AssetRule, FileRule
# Assuming configuration loads app_settings.json
# Adjust the import path if necessary
# Removed Configuration import
# from configuration import Configuration
# from configuration import load_base_config # No longer needed here
from .base_prediction_handler import BasePredictionHandler # Import base class
from .base_prediction_handler import BasePredictionHandler
log = logging.getLogger(__name__) # Setup logger
log = logging.getLogger(__name__)
class LLMPredictionHandler(BasePredictionHandler):
"""
@ -42,8 +40,8 @@ class LLMPredictionHandler(BasePredictionHandler):
"""
super().__init__(input_source_identifier, parent)
# input_source_identifier is stored by the base class as self.input_source_identifier
self.file_list = file_list # Store the provided relative file list
self.settings = settings # Store the settings dictionary
self.file_list = file_list
self.settings = settings
# Access LLM settings via self.settings['key']
# _is_running and _is_cancelled are handled by the base class
@ -68,10 +66,9 @@ class LLMPredictionHandler(BasePredictionHandler):
log.info(f"Performing LLM prediction for: {self.input_source_identifier}")
base_name = Path(self.input_source_identifier).name
# Use the file list passed during initialization
if not self.file_list:
log.warning(f"No files provided for LLM prediction for {self.input_source_identifier}. Returning empty list.")
self.status_update.emit(f"No files found for {base_name}.") # Use base signal
self.status_update.emit(f"No files found for {base_name}.")
return [] # Return empty list, not an error
# Check for cancellation before preparing prompt
@ -82,7 +79,6 @@ class LLMPredictionHandler(BasePredictionHandler):
# --- Prepare Prompt ---
self.status_update.emit(f"Preparing LLM input for {base_name}...")
try:
# Pass relative file list
prompt = self._prepare_prompt(self.file_list)
except Exception as e:
log.exception("Error preparing LLM prompt.")
@ -128,13 +124,11 @@ class LLMPredictionHandler(BasePredictionHandler):
"""
Prepares the full prompt string to send to the LLM using stored settings.
"""
# Access settings via the settings dictionary
prompt_template = self.settings.get('predictor_prompt')
if not prompt_template:
raise ValueError("LLM predictor prompt template content is empty or missing in settings.")
# Access definitions and examples directly from the settings dictionary
asset_defs = json.dumps(self.settings.get('asset_type_definitions', {}), indent=4)
# Combine file type defs and examples (assuming structure from Configuration class)
file_type_defs_combined = {}
@ -151,7 +145,6 @@ class LLMPredictionHandler(BasePredictionHandler):
# Format *relative* file list as a single string with newlines
file_list_str = "\n".join(relative_file_list)
# Replace placeholders
prompt = prompt_template.replace('{ASSET_TYPE_DEFINITIONS}', asset_defs)
prompt = prompt.replace('{FILE_TYPE_DEFINITIONS}', file_defs)
prompt = prompt.replace('{EXAMPLE_INPUT_OUTPUT_PAIRS}', examples)
@ -174,51 +167,39 @@ class LLMPredictionHandler(BasePredictionHandler):
ValueError: If the endpoint URL is not configured or the response is invalid.
requests.exceptions.RequestException: For other request-related errors.
"""
endpoint_url = self.settings.get('endpoint_url') # Get from settings dict
endpoint_url = self.settings.get('endpoint_url')
if not endpoint_url:
raise ValueError("LLM endpoint URL is not configured in settings.")
headers = {
"Content-Type": "application/json",
}
api_key = self.settings.get('api_key') # Get from settings dict
api_key = self.settings.get('api_key')
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
# Construct payload based on OpenAI Chat Completions format
payload = {
# Use configured model name from settings dict
"model": self.settings.get('model_name', 'local-model'),
"messages": [{"role": "user", "content": prompt}],
# Use configured temperature from settings dict
"temperature": self.settings.get('temperature', 0.5),
# Add max_tokens if needed/configurable:
# "max_tokens": self.settings.get('max_tokens'), # Example if added to settings
# Ensure the LLM is instructed to return JSON in the prompt itself
# Some models/endpoints support a specific json mode:
# "response_format": { "type": "json_object" } # If supported by endpoint
}
# Status update emitted by _perform_prediction before calling this
# self.status_update.emit(f"Sending request to LLM at {endpoint_url}...")
print(f"--- Calling LLM API: {endpoint_url} ---")
# print(f"--- Payload Preview ---\n{json.dumps(payload, indent=2)[:500]}...\n--- END Payload Preview ---")
# Note: Exceptions raised here (Timeout, RequestException, ValueError)
# will be caught by the _perform_prediction method's handler.
# Make the POST request with a timeout
response = requests.post(
endpoint_url,
headers=headers,
json=payload,
timeout=self.settings.get('request_timeout', 120) # Use settings dict (with default)
timeout=self.settings.get('request_timeout', 120)
)
response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)
# Parse the JSON response
response_data = response.json()
# print(f"--- LLM Raw Response ---\n{json.dumps(response_data, indent=2)}\n--- END Raw Response ---") # Debugging
# Extract content - structure depends on the API (OpenAI format assumed)
if "choices" in response_data and len(response_data["choices"]) > 0:
@ -243,10 +224,7 @@ class LLMPredictionHandler(BasePredictionHandler):
# will be caught by the _perform_prediction method's handler.
# --- Sanitize Input String ---
clean_json_str = llm_response_json_str.strip()
# 1. Remove multi-line /* */ comments
clean_json_str = re.sub(r'/\*.*?\*/', '', clean_json_str, flags=re.DOTALL)
clean_json_str = re.sub(r'/\*.*?\*/', '', llm_response_json_str.strip(), flags=re.DOTALL)
# 2. Remove single-line // comments (handle potential URLs carefully)
# Only remove // if it's likely a comment (e.g., whitespace before it,
@ -298,14 +276,12 @@ class LLMPredictionHandler(BasePredictionHandler):
# 3. Remove markdown code fences
clean_json_str = clean_json_str.strip()
if clean_json_str.startswith("```json"):
clean_json_str = clean_json_str[7:] # Remove ```json\n
clean_json_str = clean_json_str[7:].strip()
if clean_json_str.endswith("```"):
clean_json_str = clean_json_str[:-3] # Remove ```
clean_json_str = clean_json_str.strip() # Remove any extra whitespace
clean_json_str = clean_json_str[:-3].strip()
# 4. Remove <think> tags (just in case)
clean_json_str = re.sub(r'<think>.*?</think>', '', clean_json_str, flags=re.DOTALL | re.IGNORECASE)
clean_json_str = clean_json_str.strip()
clean_json_str = re.sub(r'<think>.*?</think>', '', clean_json_str, flags=re.DOTALL | re.IGNORECASE).strip()
# --- Parse Sanitized JSON ---
try:
@ -327,7 +303,6 @@ class LLMPredictionHandler(BasePredictionHandler):
# --- Prepare for Rule Creation ---
source_rule = SourceRule(input_path=self.input_source_identifier)
# Get valid types directly from the settings dictionary
valid_asset_types = list(self.settings.get('asset_type_definitions', {}).keys())
valid_file_types = list(self.settings.get('file_type_definitions', {}).keys())
asset_rules_map: Dict[str, AssetRule] = {} # Maps group_name to AssetRule
@ -369,17 +344,17 @@ class LLMPredictionHandler(BasePredictionHandler):
# --- Handle Grouping and Asset Type ---
if not group_name or not isinstance(group_name, str):
log.warning(f"File '{file_path_rel}' has missing, null, or invalid 'proposed_asset_group_name' ({group_name}). Cannot assign to an asset. Skipping file.")
continue # Skip files that cannot be grouped
continue
asset_type = response_data["asset_group_classifications"].get(group_name)
if not asset_type:
log.warning(f"No classification found in 'asset_group_classifications' for group '{group_name}' (proposed for file '{file_path_rel}'). Skipping file.")
continue # Skip files belonging to unclassified groups
continue
if asset_type not in valid_asset_types:
log.warning(f"Invalid asset_type '{asset_type}' found in 'asset_group_classifications' for group '{group_name}'. Skipping file '{file_path_rel}'.")
continue # Skip files belonging to groups with invalid types
continue
# --- Construct Absolute Path ---
try:
@ -400,14 +375,13 @@ class LLMPredictionHandler(BasePredictionHandler):
asset_rule = AssetRule(asset_name=group_name, asset_type=asset_type)
source_rule.assets.append(asset_rule)
asset_rules_map[group_name] = asset_rule
# else: use existing asset_rule
# --- Create and Add File Rule ---
file_rule = FileRule(
file_path=file_path_abs,
item_type=file_type,
item_type_override=file_type, # Initial override based on LLM
target_asset_name_override=group_name, # Use the group name
target_asset_name_override=group_name,
output_format_override=None,
is_gloss_source=False,
resolution_override=None,
@ -422,5 +396,3 @@ class LLMPredictionHandler(BasePredictionHandler):
log.warning(f"LLM prediction for '{self.input_source_identifier}' resulted in zero valid assets after parsing.")
return [source_rule] # Return list containing the single SourceRule
# Removed conceptual example usage comments

View File

@ -1,4 +1,3 @@
# gui/log_console_widget.py
import logging
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QTextEdit, QLabel, QSizePolicy
@ -18,26 +17,19 @@ class LogConsoleWidget(QWidget):
def _init_ui(self):
"""Initializes the UI elements for the log console."""
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 5, 0, 0) # Add some top margin
layout.setContentsMargins(0, 5, 0, 0)
log_console_label = QLabel("Log Console:")
self.log_console_output = QTextEdit()
self.log_console_output.setReadOnly(True)
# self.log_console_output.setMaximumHeight(150) # Let the parent layout control height
self.log_console_output.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) # Allow vertical expansion
layout.addWidget(log_console_label)
layout.addWidget(self.log_console_output)
# Initially hidden, visibility controlled by MainWindow
self.setVisible(False)
@Slot(str)
def _append_log_message(self, message):
"""Appends a log message to the QTextEdit console."""
self.log_console_output.append(message)
# Auto-scroll to the bottom
self.log_console_output.verticalScrollBar().setValue(self.log_console_output.verticalScrollBar().maximum())
# Note: Visibility is controlled externally via setVisible(),
# so the _toggle_log_console_visibility slot is not needed here.

View File

@ -4,10 +4,9 @@ import json
import logging
import time
from pathlib import Path
import functools # Ensure functools is imported directly for partial
from functools import partial
from PySide6.QtWidgets import QApplication # Added for processEvents
from PySide6.QtWidgets import QApplication
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QTableView,
QPushButton, QComboBox, QTableWidget, QTableWidgetItem, QHeaderView,
@ -16,27 +15,20 @@ from PySide6.QtWidgets import (
QFormLayout, QGroupBox, QAbstractItemView, QSizePolicy, QTreeView, QMenu
)
from PySide6.QtCore import Qt, Signal, Slot, QPoint, QModelIndex, QTimer
from PySide6.QtGui import QColor, QAction, QPalette, QClipboard, QGuiApplication # Added QGuiApplication for clipboard
from PySide6.QtGui import QColor, QAction, QPalette, QClipboard, QGuiApplication
# --- Local GUI Imports ---
# Import delegates and models needed by the panel
from .delegates import LineEditDelegate, ComboBoxDelegate, SupplierSearchDelegate, ItemTypeSearchDelegate # Added ItemTypeSearchDelegate
from .unified_view_model import UnifiedViewModel # Assuming UnifiedViewModel is passed in
from .delegates import LineEditDelegate, ComboBoxDelegate, SupplierSearchDelegate, ItemTypeSearchDelegate
from .unified_view_model import UnifiedViewModel
# --- Backend Imports ---
# Import Rule Structures if needed for context menus etc.
from rule_structure import SourceRule, AssetRule, FileRule
# Import config loading if defaults are needed directly here (though better passed from MainWindow)
# Import configuration directly for PRESETS_DIR access
import configuration
try:
from configuration import ConfigurationError, load_base_config
except ImportError:
ConfigurationError = Exception
load_base_config = None
# Define PRESETS_DIR fallback if configuration module fails to load entirely
class configuration:
PRESETS_DIR = "Presets" # Fallback path
PRESETS_DIR = "Presets"
log = logging.getLogger(__name__)
@ -49,27 +41,20 @@ class MainPanelWidget(QWidget):
- Processing controls (Start, Cancel, Clear, LLM Re-interpret)
"""
# --- Signals Emitted by the Panel ---
# Request to add new input paths (e.g., from drag/drop handled by MainWindow)
# add_paths_requested = Signal(list) # Maybe not needed if MainWindow handles drop directly
# Request to start the main processing job
process_requested = Signal(dict) # Emits dict with settings: output_dir, overwrite, workers, blender_enabled, ng_path, mat_path
process_requested = Signal(dict)
# Request to cancel the ongoing processing job
cancel_requested = Signal()
# Request to clear the current queue/view
clear_queue_requested = Signal()
# Request to re-interpret selected items using LLM
llm_reinterpret_requested = Signal(list) # Emits list of source paths
preset_reinterpret_requested = Signal(list, str) # Emits list[source_paths], preset_name
llm_reinterpret_requested = Signal(list)
preset_reinterpret_requested = Signal(list, str)
# Notify when the output directory changes
output_dir_changed = Signal(str)
# Notify when Blender settings change
blender_settings_changed = Signal(bool, str, str) # enabled, ng_path, mat_path
blender_settings_changed = Signal(bool, str, str)
def __init__(self, unified_model: UnifiedViewModel, parent=None, file_type_keys: list[str] | None = None):
"""
@ -83,9 +68,8 @@ class MainPanelWidget(QWidget):
super().__init__(parent)
self.unified_model = unified_model
self.file_type_keys = file_type_keys if file_type_keys else []
self.llm_processing_active = False # Track if LLM is running (set by MainWindow)
self.llm_processing_active = False
# Get project root for resolving default paths if needed here
script_dir = Path(__file__).parent
self.project_root = script_dir.parent
@ -95,9 +79,8 @@ class MainPanelWidget(QWidget):
def _setup_ui(self):
"""Sets up the UI elements for the panel."""
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(5, 5, 5, 5) # Reduce margins
main_layout.setContentsMargins(5, 5, 5, 5)
# --- Output Directory Selection ---
output_layout = QHBoxLayout()
self.output_dir_label = QLabel("Output Directory:")
self.output_path_edit = QLineEdit()
@ -107,8 +90,6 @@ class MainPanelWidget(QWidget):
output_layout.addWidget(self.browse_output_button)
main_layout.addLayout(output_layout)
# --- Set Initial Output Path (Copied from MainWindow) ---
# Consider passing this default path from MainWindow instead of reloading config here
if load_base_config:
try:
base_config = load_base_config()
@ -127,36 +108,27 @@ class MainPanelWidget(QWidget):
self.output_path_edit.setText("")
# --- Unified View Setup ---
self.unified_view = QTreeView()
self.unified_view.setModel(self.unified_model) # Set the passed-in model
self.unified_view.setModel(self.unified_model)
# Instantiate Delegates
lineEditDelegate = LineEditDelegate(self.unified_view)
# ComboBoxDelegate needs access to MainWindow's get_llm_source_preset_name,
# which might require passing MainWindow or a callback here.
# For now, let's assume it can work without it or we adapt it later.
# TODO: Revisit ComboBoxDelegate dependency
comboBoxDelegate = ComboBoxDelegate(self) # Pass only parent (self)
supplierSearchDelegate = SupplierSearchDelegate(self) # Pass parent
# Pass file_type_keys to ItemTypeSearchDelegate
comboBoxDelegate = ComboBoxDelegate(self)
supplierSearchDelegate = SupplierSearchDelegate(self)
itemTypeSearchDelegate = ItemTypeSearchDelegate(self.file_type_keys, self)
# Set Delegates for Columns
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_SUPPLIER, supplierSearchDelegate)
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_ASSET_TYPE, comboBoxDelegate)
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_TARGET_ASSET, lineEditDelegate)
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_ITEM_TYPE, itemTypeSearchDelegate) # Use ItemTypeSearchDelegate
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_NAME, lineEditDelegate) # Assign LineEditDelegate for AssetRule names
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_ITEM_TYPE, itemTypeSearchDelegate)
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_NAME, lineEditDelegate)
# Configure View Appearance
self.unified_view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.unified_view.setAlternatingRowColors(True)
self.unified_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.unified_view.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked | QAbstractItemView.EditTrigger.SelectedClicked | QAbstractItemView.EditTrigger.EditKeyPressed)
self.unified_view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) # Allow multi-select for re-interpret
self.unified_view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
# Configure Header Resize Modes
header = self.unified_view.header()
header.setStretchLastSection(False)
header.setSectionResizeMode(UnifiedViewModel.COL_NAME, QHeaderView.ResizeMode.ResizeToContents)
@ -165,31 +137,23 @@ class MainPanelWidget(QWidget):
header.setSectionResizeMode(UnifiedViewModel.COL_ASSET_TYPE, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(UnifiedViewModel.COL_ITEM_TYPE, QHeaderView.ResizeMode.ResizeToContents)
# Enable custom context menu
self.unified_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
# --- Enable Drag and Drop ---
self.unified_view.setDragEnabled(True)
self.unified_view.setAcceptDrops(True)
self.unified_view.setDropIndicatorShown(True)
self.unified_view.setDefaultDropAction(Qt.MoveAction)
# Use InternalMove for handling drops within the model itself
self.unified_view.setDragDropMode(QAbstractItemView.InternalMove)
# Ensure ExtendedSelection is set (already done above, but good practice)
self.unified_view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
# --- End Drag and Drop ---
# Add the Unified View to the main layout
main_layout.addWidget(self.unified_view, 1) # Give it stretch factor 1
main_layout.addWidget(self.unified_view, 1)
# --- Progress Bar ---
self.progress_bar = QProgressBar()
self.progress_bar.setValue(0)
self.progress_bar.setTextVisible(True)
self.progress_bar.setFormat("Idle") # Initial format
self.progress_bar.setFormat("Idle")
main_layout.addWidget(self.progress_bar)
# --- Blender Integration Controls ---
blender_group = QGroupBox("Blender Post-Processing")
blender_layout = QVBoxLayout(blender_group)
@ -197,7 +161,6 @@ class MainPanelWidget(QWidget):
self.blender_integration_checkbox.setToolTip("If checked, attempts to run create_nodegroups.py and create_materials.py in Blender.")
blender_layout.addWidget(self.blender_integration_checkbox)
# Nodegroup Blend Path
nodegroup_layout = QHBoxLayout()
nodegroup_layout.addWidget(QLabel("Nodegroup .blend:"))
self.nodegroup_blend_path_input = QLineEdit()
@ -207,7 +170,6 @@ class MainPanelWidget(QWidget):
nodegroup_layout.addWidget(self.browse_nodegroup_blend_button)
blender_layout.addLayout(nodegroup_layout)
# Materials Blend Path
materials_layout = QHBoxLayout()
materials_layout.addWidget(QLabel("Materials .blend:"))
self.materials_blend_path_input = QLineEdit()
@ -217,8 +179,6 @@ class MainPanelWidget(QWidget):
materials_layout.addWidget(self.browse_materials_blend_button)
blender_layout.addLayout(materials_layout)
# Initialize paths from config (Copied from MainWindow)
# Consider passing these defaults from MainWindow
if load_base_config:
try:
base_config = load_base_config()
@ -234,15 +194,13 @@ class MainPanelWidget(QWidget):
log.warning("MainPanelWidget: load_base_config not available to set default Blender paths.")
# Disable Blender controls initially if checkbox is unchecked
self.nodegroup_blend_path_input.setEnabled(False)
self.browse_nodegroup_blend_button.setEnabled(False)
self.materials_blend_path_input.setEnabled(False)
self.browse_materials_blend_button.setEnabled(False)
main_layout.addWidget(blender_group) # Add the group box to the main layout
main_layout.addWidget(blender_group)
# --- Bottom Controls ---
bottom_controls_layout = QHBoxLayout()
self.overwrite_checkbox = QCheckBox("Overwrite Existing")
self.overwrite_checkbox.setToolTip("If checked, existing output folders for processed assets will be deleted and replaced.")
@ -263,11 +221,6 @@ class MainPanelWidget(QWidget):
bottom_controls_layout.addWidget(self.workers_spinbox)
bottom_controls_layout.addStretch(1)
# --- LLM Re-interpret Button (Removed, functionality moved to context menu) ---
# self.llm_reinterpret_button = QPushButton("Re-interpret Selected with LLM")
# self.llm_reinterpret_button.setToolTip("Re-run LLM interpretation on the selected source items.")
# self.llm_reinterpret_button.setEnabled(False) # Initially disabled
# bottom_controls_layout.addWidget(self.llm_reinterpret_button)
self.clear_queue_button = QPushButton("Clear Queue")
self.start_button = QPushButton("Start Processing")
@ -281,37 +234,30 @@ class MainPanelWidget(QWidget):
def _connect_signals(self):
"""Connect internal UI signals to slots or emit panel signals."""
# Output Directory
self.browse_output_button.clicked.connect(self._browse_for_output_directory)
self.output_path_edit.editingFinished.connect(self._on_output_path_changed) # Emit signal when user finishes editing
self.output_path_edit.editingFinished.connect(self._on_output_path_changed)
# Unified View
self.unified_view.customContextMenuRequested.connect(self._show_unified_view_context_menu)
# Blender Controls
self.blender_integration_checkbox.toggled.connect(self._toggle_blender_controls)
self.browse_nodegroup_blend_button.clicked.connect(self._browse_for_nodegroup_blend)
self.browse_materials_blend_button.clicked.connect(self._browse_for_materials_blend)
# Emit signal when paths change
self.nodegroup_blend_path_input.editingFinished.connect(self._emit_blender_settings_changed)
self.materials_blend_path_input.editingFinished.connect(self._emit_blender_settings_changed)
self.blender_integration_checkbox.toggled.connect(self._emit_blender_settings_changed)
# Bottom Buttons
self.clear_queue_button.clicked.connect(self.clear_queue_requested) # Emit signal directly
self.start_button.clicked.connect(self._on_start_processing_clicked) # Use slot to gather data
self.cancel_button.clicked.connect(self.cancel_requested) # Emit signal directly
# self.llm_reinterpret_button.clicked.connect(self._on_llm_reinterpret_clicked) # Removed button connection
self.clear_queue_button.clicked.connect(self.clear_queue_requested)
self.start_button.clicked.connect(self._on_start_processing_clicked)
self.cancel_button.clicked.connect(self.cancel_requested)
# --- Slots for Internal UI Logic ---
@Slot()
def _browse_for_output_directory(self):
"""Opens a dialog to select the output directory."""
current_path = self.output_path_edit.text()
if not current_path or not Path(current_path).is_dir():
current_path = str(self.project_root) # Use project root as fallback
current_path = str(self.project_root)
directory = QFileDialog.getExistingDirectory(
self,
@ -321,7 +267,7 @@ class MainPanelWidget(QWidget):
)
if directory:
self.output_path_edit.setText(directory)
self._on_output_path_changed() # Explicitly call the change handler
self._on_output_path_changed()
@Slot()
def _on_output_path_changed(self):
@ -335,7 +281,6 @@ class MainPanelWidget(QWidget):
self.browse_nodegroup_blend_button.setEnabled(checked)
self.materials_blend_path_input.setEnabled(checked)
self.browse_materials_blend_button.setEnabled(checked)
# No need to emit here, the checkbox toggle signal is connected separately
def _browse_for_blend_file(self, line_edit_widget: QLineEdit):
"""Opens a dialog to select a .blend file and updates the line edit."""
@ -350,7 +295,7 @@ class MainPanelWidget(QWidget):
)
if file_path:
line_edit_widget.setText(file_path)
line_edit_widget.editingFinished.emit() # Trigger editingFinished to emit change signal
line_edit_widget.editingFinished.emit()
@Slot()
def _browse_for_nodegroup_blend(self):
@ -376,7 +321,6 @@ class MainPanelWidget(QWidget):
QMessageBox.warning(self, "Missing Output Directory", "Please select an output directory.")
return
# Basic validation (MainWindow should do more thorough validation)
try:
Path(output_dir).mkdir(parents=True, exist_ok=True)
except Exception as e:
@ -393,8 +337,6 @@ class MainPanelWidget(QWidget):
}
self.process_requested.emit(settings)
# Removed _update_llm_reinterpret_button_state as the button is removed.
# Context menu actions will handle their own enabled state or rely on _on_llm_reinterpret_clicked checks.
def _get_unique_source_dirs_from_selection(self, selected_indexes: list[QModelIndex]) -> set[str]:
"""
@ -407,19 +349,17 @@ class MainPanelWidget(QWidget):
log.error("Unified view model not found.")
return unique_source_dirs
processed_source_paths = set() # To avoid processing duplicates if multiple cells of the same source are selected
processed_source_paths = set()
for index in selected_indexes:
if not index.isValid():
continue
# Use the model's getItem method for robust node retrieval
item_node = model.getItem(index)
source_rule_node = None
# Find the parent SourceRule node by traversing upwards using the index
source_rule_node = None
current_index = index # Start with the index of the selected item
current_index = index
while current_index.isValid():
current_item = model.getItem(current_index)
if isinstance(current_item, SourceRule):
@ -429,11 +369,9 @@ class MainPanelWidget(QWidget):
# If loop finishes without break, source_rule_node remains None
if source_rule_node:
# Use input_path attribute as defined in SourceRule
source_path = getattr(source_rule_node, 'input_path', None)
if source_path and source_path not in processed_source_paths:
source_path_obj = Path(source_path)
# Check if it's a directory or a zip file (common input types)
if source_path_obj.is_dir() or (source_path_obj.is_file() and source_path_obj.suffix.lower() == '.zip'):
log.debug(f"Identified source path for re-interpretation: {source_path}")
unique_source_dirs.add(source_path)
@ -471,9 +409,7 @@ class MainPanelWidget(QWidget):
def _on_reinterpret_preset_selected(self, preset_name: str, index: QModelIndex):
"""Handles the selection of a preset from the re-interpret context sub-menu."""
log.info(f"Preset re-interpretation requested: Preset='{preset_name}', Index='{index.row()},{index.column()}'")
# Reuse logic from _on_llm_reinterpret_clicked to get selected source paths
selected_indexes = self.unified_view.selectionModel().selectedIndexes()
# Use the helper method to get all selected source paths, not just the one clicked
unique_source_dirs = self._get_unique_source_dirs_from_selection(selected_indexes)
if not unique_source_dirs:
@ -494,12 +430,11 @@ class MainPanelWidget(QWidget):
model = self.unified_view.model()
if not model: return
item_node = model.getItem(index) # Use model's method
item_node = model.getItem(index)
# Find the SourceRule node associated with the clicked index
# Find the SourceRule node associated with the clicked index
source_rule_node = None
current_index = index # Start with the clicked index
current_index = index
while current_index.isValid():
current_item = model.getItem(current_index)
if isinstance(current_item, SourceRule):
@ -510,11 +445,9 @@ class MainPanelWidget(QWidget):
menu = QMenu(self)
# --- Re-interpret Menu ---
if source_rule_node: # Only show if we clicked on or within a SourceRule item
reinterpet_menu = menu.addMenu("Re-interpret selected source")
# Get Preset Names (Option B: Direct File Listing)
preset_names = []
try:
presets_dir = configuration.PRESETS_DIR
@ -523,17 +456,15 @@ class MainPanelWidget(QWidget):
if filename.endswith(".json") and filename != "_template.json":
preset_name = os.path.splitext(filename)[0]
preset_names.append(preset_name)
preset_names.sort() # Sort alphabetically
preset_names.sort()
else:
log.warning(f"Presets directory not found or not a directory: {presets_dir}")
except Exception as e:
log.exception(f"Error listing presets in {configuration.PRESETS_DIR}: {e}")
# Populate Sub-Menu with Presets
if preset_names:
for preset_name in preset_names:
preset_action = QAction(preset_name, self)
# Pass the preset name and the *clicked* index (though the slot will get all selected)
preset_action.triggered.connect(functools.partial(self._on_reinterpret_preset_selected, preset_name, index))
reinterpet_menu.addAction(preset_action)
else:
@ -542,39 +473,31 @@ class MainPanelWidget(QWidget):
reinterpet_menu.addAction(no_presets_action)
# Add LLM Option (Static)
reinterpet_menu.addSeparator()
llm_action = QAction("LLM", self)
# Connect to the existing slot that handles LLM re-interpretation requests
llm_action.triggered.connect(self._on_llm_reinterpret_clicked)
# Disable if LLM is currently processing
llm_action.setEnabled(not self.llm_processing_active)
reinterpet_menu.addAction(llm_action)
menu.addSeparator() # Separator before other actions
menu.addSeparator()
# --- Other Actions (like Copy LLM Example) ---
if source_rule_node: # Check again if it's a source item for this action
copy_llm_example_action = QAction("Copy LLM Example to Clipboard", self)
copy_llm_example_action.setToolTip("Copies a JSON structure representing the input files and predicted output, suitable for LLM examples.")
# Pass the found source_rule_node
copy_llm_example_action.triggered.connect(lambda: self._copy_llm_example_to_clipboard(source_rule_node))
menu.addAction(copy_llm_example_action)
# menu.addSeparator() # Removed redundant separator
# Add other general actions here if needed...
if not menu.isEmpty():
menu.exec(self.unified_view.viewport().mapToGlobal(point))
@Slot(SourceRule) # Accept SourceRule directly
@Slot(SourceRule)
def _copy_llm_example_to_clipboard(self, source_rule_node: SourceRule | None):
"""Copies a JSON structure for the given SourceRule node to the clipboard."""
if not source_rule_node:
log.warning(f"No SourceRule node provided to copy LLM example.")
return
# We already have the source_rule_node passed in
source_rule: SourceRule = source_rule_node
log.info(f"Attempting to generate LLM example JSON for source: {source_rule.input_path}")
@ -612,11 +535,10 @@ class MainPanelWidget(QWidget):
try:
json_string = json.dumps(llm_example, indent=2)
clipboard = QGuiApplication.clipboard() # Use QGuiApplication
clipboard = QGuiApplication.clipboard()
if clipboard:
clipboard.setText(json_string)
log.info(f"Copied LLM example JSON to clipboard for source: {source_rule.input_path}")
# Cannot show status bar message here
else:
log.error("Failed to get system clipboard.")
except Exception as e:
@ -630,10 +552,10 @@ class MainPanelWidget(QWidget):
"""Updates the progress bar display."""
if total_count > 0:
percentage = int((current_count / total_count) * 100)
log.debug(f"Updating progress bar: current={current_count}, total={total_count}, calculated_percentage={percentage}") # DEBUG LOG
log.debug(f"Updating progress bar: current={current_count}, total={total_count}, calculated_percentage={percentage}")
self.progress_bar.setValue(percentage)
self.progress_bar.setFormat(f"%p% ({current_count}/{total_count})")
QApplication.processEvents() # Force GUI update
QApplication.processEvents()
else:
self.progress_bar.setValue(0)
self.progress_bar.setFormat("0/0")
@ -642,7 +564,6 @@ class MainPanelWidget(QWidget):
def set_progress_bar_text(self, text: str):
"""Sets the text format of the progress bar."""
self.progress_bar.setFormat(text)
# Reset value if setting text like "Idle" or "Waiting..."
if not "%" in text:
self.progress_bar.setValue(0)
@ -650,7 +571,6 @@ class MainPanelWidget(QWidget):
@Slot(bool)
def set_controls_enabled(self, enabled: bool):
"""Enables or disables controls within the panel."""
# Enable/disable most controls based on the 'enabled' flag
self.output_path_edit.setEnabled(enabled)
self.browse_output_button.setEnabled(enabled)
self.unified_view.setEnabled(enabled)
@ -670,8 +590,6 @@ class MainPanelWidget(QWidget):
self.materials_blend_path_input.setEnabled(blender_paths_enabled)
self.browse_materials_blend_button.setEnabled(blender_paths_enabled)
# LLM button removed, no need to update its state here.
# Context menu actions enable/disable themselves based on context (e.g., llm_processing_active).
@Slot(bool)
@ -696,11 +614,11 @@ class MainPanelWidget(QWidget):
# No button state to update directly, but context menu will check this flag when built.
# TODO: Add method to get current output path if needed by MainWindow before processing
def get_output_directory(self) -> str:
def get_output_directory() -> str:
return self.output_path_edit.text().strip()
# TODO: Add method to get current Blender settings if needed by MainWindow before processing
def get_blender_settings(self) -> dict:
def get_blender_settings() -> dict:
return {
"enabled": self.blender_integration_checkbox.isChecked(),
"nodegroup_blend_path": self.nodegroup_blend_path_input.text(),
@ -708,16 +626,14 @@ class MainPanelWidget(QWidget):
}
# TODO: Add method to get current worker count if needed by MainWindow before processing
def get_worker_count(self) -> int:
def get_worker_count() -> int:
return self.workers_spinbox.value()
# TODO: Add method to get current overwrite setting if needed by MainWindow before processing
def get_overwrite_setting(self) -> bool:
def get_overwrite_setting() -> bool:
return self.overwrite_checkbox.isChecked()
# --- Delegate Dependency ---
# This method might be needed by ComboBoxDelegate if it relies on MainWindow's logic
def get_llm_source_preset_name(self) -> str | None:
def get_llm_source_preset_name() -> str | None:
"""
Placeholder for providing context to delegates.
Ideally, the required info (like last preset name) should be passed

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,12 @@
# gui/rule_based_prediction_handler.py
import logging
from pathlib import Path
import time
import os
import re # Import regex
import tempfile # Added for temporary extraction directory
import zipfile # Added for zip file handling
# import patoolib # Potential import for rar/7z - Add later if zip works
from collections import defaultdict, Counter # Added Counter
from typing import List, Dict, Any # For type hinting
import re
import tempfile
import zipfile
from collections import defaultdict, Counter
from typing import List, Dict, Any
# --- PySide6 Imports ---
from PySide6.QtCore import QObject, Slot # Keep QObject for parent type hint, Slot for classify_files if kept as method
@ -22,30 +20,23 @@ if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
try:
from configuration import Configuration, ConfigurationError # load_base_config might not be needed here
from configuration import Configuration, ConfigurationError
from rule_structure import SourceRule, AssetRule, FileRule
from .base_prediction_handler import BasePredictionHandler # Import the base class
from .base_prediction_handler import BasePredictionHandler
BACKEND_AVAILABLE = True
except ImportError as e:
# Update error message source
print(f"ERROR (RuleBasedPredictionHandler): Failed to import backend/config/base modules: {e}")
# Define placeholders if imports fail
Configuration = None
load_base_config = None # Placeholder
load_base_config = None
ConfigurationError = Exception
# AssetProcessingError = Exception
SourceRule, AssetRule, FileRule = (None,)*3 # Placeholder for rule structures
# Removed: AssetType, ItemType = (None,)*2 # Placeholder for types
# Removed: app_config = None # Placeholder for config
SourceRule, AssetRule, FileRule = (None,)*3
BACKEND_AVAILABLE = False
log = logging.getLogger(__name__)
# Basic config if logger hasn't been set up elsewhere
if not log.hasHandlers():
logging.basicConfig(level=logging.INFO, format='%(levelname)s (RuleBasedPredictHandler): %(message)s')
# Helper function for classification (can be moved outside class if preferred)
def classify_files(file_list: List[str], config: Configuration) -> Dict[str, List[Dict[str, Any]]]:
"""
Analyzes a list of files based on configuration rules using a two-pass approach
@ -71,16 +62,15 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
Returns an empty dict if classification fails or no files are provided.
"""
temp_grouped_files = defaultdict(list)
extra_files_to_associate = [] # Store tuples: (file_path_str, filename) for Pass 2 association
primary_asset_names = set() # Store asset names derived *only* from primary map files (populated in Pass 1)
primary_assignments = set() # Stores tuples: (asset_name, target_type) (populated *only* in Pass 1)
processed_in_pass1 = set() # Keep track of files handled in Pass 1
extra_files_to_associate = []
primary_asset_names = set()
primary_assignments = set()
processed_in_pass1 = set()
# --- Validation ---
if not file_list or not config:
log.warning("Classification skipped: Missing file list or config.")
return {}
# Access compiled regex directly from the config object
if not hasattr(config, 'compiled_map_keyword_regex') or not config.compiled_map_keyword_regex:
log.warning("Classification skipped: Missing compiled map keyword regex in config.")
if not hasattr(config, 'compiled_extra_regex'):
@ -143,12 +133,10 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
if match:
log.debug(f"PASS 1: File '{filename}' matched PRIORITIZED bit depth variant for type '{target_type}'.")
matched_item_type = target_type
is_gloss_flag = False # Bit depth variants are typically not gloss
is_gloss_flag = False
# Check if primary already assigned (safety for overlapping patterns)
if (asset_name, matched_item_type) in primary_assignments:
log.warning(f"PASS 1: Primary assignment ({asset_name}, {matched_item_type}) already exists. File '{filename}' will be handled in Pass 2.")
# Don't process here, let Pass 2 handle it as a general map or extra
else:
primary_assignments.add((asset_name, matched_item_type))
log.debug(f" PASS 1: Added primary assignment: ({asset_name}, {matched_item_type})")
@ -163,9 +151,6 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
processed_in_pass1.add(file_path_str)
processed = True
break # Stop checking other variant patterns for this file
# Log if not processed in this pass
# if not processed:
# log.debug(f"PASS 1: File '{filename}' did not match any prioritized variant.")
log.debug(f"--- Finished Pass 1. Primary assignments made: {primary_assignments} ---")
@ -174,7 +159,7 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
for file_path_str in file_list:
if file_path_str in processed_in_pass1:
log.debug(f"PASS 2: Skipping '{Path(file_path_str).name}' (processed in Pass 1).")
continue # Skip files already classified as prioritized variants
continue
file_path = Path(file_path_str)
filename = file_path.name
@ -186,20 +171,18 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
for extra_pattern in compiled_extra_regex:
if extra_pattern.search(filename):
log.debug(f"PASS 2: File '{filename}' matched EXTRA pattern: {extra_pattern.pattern}")
# Don't group yet, just collect for later association
extra_files_to_associate.append((file_path_str, filename))
is_extra = True
break
if is_extra:
continue # Move to the next file if it's an extra
continue
# 2. Check for General Map Files in Pass 2
for target_type, patterns_list in compiled_map_regex.items():
for compiled_regex, original_keyword, rule_index in patterns_list:
match = compiled_regex.search(filename)
if match:
# Access rule details
is_gloss_flag = False
try:
map_type_mapping_list = config.map_type_mapping
@ -213,24 +196,21 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
if (asset_name, target_type) in primary_assignments:
log.debug(f"PASS 2: File '{filename}' matched '{original_keyword}' for type '{target_type}', but primary already assigned via Pass 1. Classifying as EXTRA.")
matched_item_type = "EXTRA"
is_gloss_flag = False # Extras are not gloss sources
is_gloss_flag = False
else:
# No prioritized variant exists, assign the general map type
log.debug(f"PASS 2: File '{filename}' matched '{original_keyword}' for item_type '{target_type}'.")
matched_item_type = target_type
# Do NOT add to primary_assignments here - only Pass 1 does that.
# Do NOT add to primary_asset_names here either.
temp_grouped_files[asset_name].append({
'file_path': file_path_str,
'item_type': matched_item_type, # Could be target_type or EXTRA
'item_type': matched_item_type,
'asset_name': asset_name,
'is_gloss_source': is_gloss_flag
})
is_map = True
break # Stop checking patterns for this file
break
if is_map:
break # Stop checking target types for this file
break
# 3. Handle Unmatched Files in Pass 2 (Not Extra, Not Map)
if not is_extra and not is_map:
@ -246,13 +226,12 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
# --- Determine Primary Asset Name for Extra Association (using Pass 1 results) ---
final_primary_asset_name = None
if primary_asset_names: # Use names derived only from Pass 1 (prioritized variants)
# Find the most common name among those derived from primary maps identified in Pass 1
if primary_asset_names:
primary_map_asset_names_pass1 = [
f_info['asset_name']
for asset_files in temp_grouped_files.values()
for f_info in asset_files
if f_info['asset_name'] in primary_asset_names and (f_info['asset_name'], f_info['item_type']) in primary_assignments # Ensure it was a Pass 1 assignment
if f_info['asset_name'] in primary_asset_names and (f_info['asset_name'], f_info['item_type']) in primary_assignments
]
if primary_map_asset_names_pass1:
name_counts = Counter(primary_map_asset_names_pass1)
@ -267,7 +246,6 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
log.warning("Primary asset names set (from Pass 1) was populated, but no corresponding groups found. Falling back.")
if not final_primary_asset_name:
# Fallback: No primary maps found in Pass 1. Use the first asset group found overall.
if temp_grouped_files and extra_files_to_associate:
fallback_name = sorted(temp_grouped_files.keys())[0]
final_primary_asset_name = fallback_name
@ -282,7 +260,6 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
if final_primary_asset_name and extra_files_to_associate:
log.debug(f"Associating {len(extra_files_to_associate)} extra file(s) with primary asset '{final_primary_asset_name}'")
for file_path_str, filename in extra_files_to_associate:
# Check if file already exists in the group (e.g., if somehow classified twice)
if not any(f['file_path'] == file_path_str for f in temp_grouped_files[final_primary_asset_name]):
temp_grouped_files[final_primary_asset_name].append({
'file_path': file_path_str,
@ -293,7 +270,6 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
else:
log.debug(f"Skipping duplicate association of extra file: {filename}")
elif extra_files_to_associate:
# Logged warning above if final_primary_asset_name couldn't be determined
pass
@ -321,8 +297,6 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
super().__init__(input_source_identifier, parent)
self.original_input_paths = original_input_paths
self.preset_name = preset_name
# _is_running is handled by the base class
# Keep track of the current request being processed by this persistent handler
self._current_input_path = None
self._current_file_list = None
self._current_preset_name = None
@ -341,18 +315,16 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
# Allow re-triggering for the *same* source if needed (e.g., preset changed)
if self._is_running and self._current_input_path != input_source_identifier:
log.warning(f"RuleBasedPredictionHandler is busy with '{self._current_input_path}'. Ignoring request for '{input_source_identifier}'.")
# Optionally emit an error signal specific to this condition
# self.prediction_error.emit(input_source_identifier, "Handler busy with another prediction.")
return
self._is_running = True
self._is_cancelled = False # Reset cancellation flag for new request
self._is_cancelled = False
self._current_input_path = input_source_identifier
self._current_file_list = original_input_paths
self._current_preset_name = preset_name
log.info(f"Starting rule-based prediction for: {input_source_identifier} using preset: {preset_name}")
self.status_update.emit(f"Starting analysis for '{Path(input_source_identifier).name}'...") # Use base signal
self.status_update.emit(f"Starting analysis for '{Path(input_source_identifier).name}'...")
source_rules_list = []
try:
@ -362,9 +334,8 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
if not preset_name:
log.warning("No preset selected for prediction.")
self.status_update.emit("No preset selected.")
# Emit empty list for non-critical issues, signal completion
self.prediction_ready.emit(input_source_identifier, [])
self._is_running = False # Mark as finished
self._is_running = False
return
source_path = Path(input_source_identifier)
@ -391,15 +362,13 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
if not classified_assets:
log.warning(f"Classification yielded no assets for source '{input_source_identifier}'.")
self.status_update.emit("No assets identified from files.")
# Emit empty list, signal completion
self.prediction_ready.emit(input_source_identifier, [])
self._is_running = False # Mark as finished
self._is_running = False
return
# --- Build the Hierarchy ---
self.status_update.emit(f"Building rule hierarchy for '{source_path.name}'...")
try:
# (Hierarchy building logic remains the same as before)
supplier_identifier = config.supplier_name
source_rule = SourceRule(
input_path=input_source_identifier,
@ -407,7 +376,6 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
preset_name=preset_name
)
asset_rules = []
# asset_type_definitions = config._core_settings.get('ASSET_TYPE_DEFINITIONS', {}) # Use accessor
file_type_definitions = config._core_settings.get('FILE_TYPE_DEFINITIONS', {})
for asset_name, files_info in classified_assets.items():
@ -415,7 +383,7 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
if not files_info: continue
asset_category_rules = config.asset_category_rules
asset_type_definitions = config.get_asset_type_definitions() # Use new accessor
asset_type_definitions = config.get_asset_type_definitions()
asset_type_keys = list(asset_type_definitions.keys())
# Initialize predicted_asset_type using the validated default
@ -427,9 +395,8 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
# Check for Model type based on file patterns
if "Model" in asset_type_keys:
model_patterns_regex = config.compiled_model_regex # Already compiled
model_patterns_regex = config.compiled_model_regex
for f_info in files_info:
# Only consider files not marked as EXTRA or FILE_IGNORE for model classification
if f_info['item_type'] in ["EXTRA", "FILE_IGNORE"]:
continue
file_path_obj = Path(f_info['file_path'])
@ -447,9 +414,9 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
decal_keywords = asset_category_rules.get('decal_keywords', [])
for keyword in decal_keywords:
# Ensure keyword is a string before trying to escape it
if isinstance(keyword, str) and keyword: # Added check for non-empty string
if isinstance(keyword, str) and keyword:
try:
if re.search(r'\b' + re.escape(keyword) + r'\b', asset_name, re.IGNORECASE): # Match whole word
if re.search(r'\b' + re.escape(keyword) + r'\b', asset_name, re.IGNORECASE):
predicted_asset_type = "Decal"
determined_by_rule = True
log.debug(f"Asset '{asset_name}' classified as 'Decal' due to keyword '{keyword}'.")
@ -457,7 +424,7 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
except re.error as e_re:
log.warning(f"Regex error with decal_keyword '{keyword}': {e_re}")
if determined_by_rule:
pass # Already logged if Decal
pass
# 2. If not determined by specific rules, check for Surface (if not Model/Decal by rule)
if not determined_by_rule and predicted_asset_type == config.default_asset_category and "Surface" in asset_type_keys:
@ -489,13 +456,10 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
log.debug(f"Asset '{asset_name}' classified as 'Surface' due to material indicators.")
# 3. Final validation: Ensure predicted_asset_type is a valid key.
# config.default_asset_category is already validated to be a key.
if predicted_asset_type not in asset_type_keys:
log.warning(f"Derived AssetType '{predicted_asset_type}' for asset '{asset_name}' is not in ASSET_TYPE_DEFINITIONS. "
f"Falling back to default: '{config.default_asset_category}'.")
predicted_asset_type = config.default_asset_category
# This case should ideally not be hit if logic above correctly uses asset_type_keys
# and default_asset_category is valid.
asset_rule = AssetRule(asset_name=asset_name, asset_type=predicted_asset_type)
file_rules = []
@ -512,9 +476,6 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
log.warning(f"Predicted ItemType '{base_item_type}' (checked as '{final_item_type}') for file '{file_info['file_path']}' is not in FILE_TYPE_DEFINITIONS. Setting to FILE_IGNORE.")
final_item_type = "FILE_IGNORE"
# standard_map_type is no longer stored on FileRule
# It will be looked up from config when needed for naming/output
# Remove the logic that determined and assigned it here.
is_gloss_source_value = file_info.get('is_gloss_source', False)
@ -540,22 +501,20 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
# --- Emit Success Signal ---
log.info(f"Rule-based prediction finished successfully for '{input_source_identifier}'.")
self.prediction_ready.emit(input_source_identifier, source_rules_list) # Use base signal
self.prediction_ready.emit(input_source_identifier, source_rules_list)
except Exception as e:
# --- Emit Error Signal ---
log.exception(f"Error during rule-based prediction for '{input_source_identifier}': {e}")
error_msg = f"Error analyzing '{Path(input_source_identifier).name}': {e}"
self.prediction_error.emit(input_source_identifier, error_msg) # Use base signal
self.prediction_error.emit(input_source_identifier, error_msg)
finally:
# --- Cleanup ---
self._is_running = False
self._current_input_path = None # Clear current task info
self._current_input_path = None
self._current_file_list = None
self._current_preset_name = None
log.info(f"Finished rule-based prediction run for: {input_source_identifier}")
def is_running(self) -> bool:
"""Returns True if the handler is currently processing a prediction request."""
# The _is_running flag is managed by the base class or the run_prediction method
return self._is_running

View File

@ -18,7 +18,7 @@ from PySide6.QtGui import QAction # Keep QAction if needed for context menus wit
# Assuming project root is parent of the directory containing this file
script_dir = Path(__file__).parent
project_root = script_dir.parent
PRESETS_DIR = project_root / "Presets" # Corrected path
PRESETS_DIR = project_root / "Presets"
TEMPLATE_PATH = PRESETS_DIR / "_template.json"
APP_SETTINGS_PATH_LOCAL = project_root / "config" / "app_settings.json"
@ -51,10 +51,10 @@ class PresetEditorWidget(QWidget):
self._init_ui()
# --- Initial State ---
self._ftd_keys = self._get_file_type_definition_keys() # Load FTD keys
self._clear_editor() # Clear/disable editor fields initially
self._set_editor_enabled(False) # Disable editor initially
self.populate_presets() # Populate preset list
self._ftd_keys = self._get_file_type_definition_keys()
self._clear_editor()
self._set_editor_enabled(False)
self.populate_presets()
# --- Connect Editor Signals ---
self._connect_editor_change_signals()
@ -91,7 +91,7 @@ class PresetEditorWidget(QWidget):
selector_layout.addWidget(QLabel("Presets:"))
self.editor_preset_list = QListWidget()
self.editor_preset_list.currentItemChanged.connect(self._load_selected_preset_for_editing)
selector_layout.addWidget(self.editor_preset_list) # Corrected: Add to selector_layout
selector_layout.addWidget(self.editor_preset_list)
list_button_layout = QHBoxLayout()
self.editor_new_button = QPushButton("New")
@ -101,7 +101,7 @@ class PresetEditorWidget(QWidget):
list_button_layout.addWidget(self.editor_new_button)
list_button_layout.addWidget(self.editor_delete_button)
selector_layout.addLayout(list_button_layout)
main_layout.addWidget(self.selector_container) # Add selector container to main layout
main_layout.addWidget(self.selector_container)
# Editor Tabs
self.json_editor_container = QWidget()
@ -121,7 +121,7 @@ class PresetEditorWidget(QWidget):
save_button_layout = QHBoxLayout()
self.editor_save_button = QPushButton("Save")
self.editor_save_as_button = QPushButton("Save As...")
self.editor_save_button.setEnabled(False) # Disabled initially
self.editor_save_button.setEnabled(False)
self.editor_save_button.clicked.connect(self._save_current_preset)
self.editor_save_as_button.clicked.connect(self._save_preset_as)
save_button_layout.addStretch()
@ -129,7 +129,7 @@ class PresetEditorWidget(QWidget):
save_button_layout.addWidget(self.editor_save_as_button)
editor_layout.addLayout(save_button_layout)
main_layout.addWidget(self.json_editor_container) # Add editor container to main layout
main_layout.addWidget(self.json_editor_container)
def _create_editor_general_tab(self):
"""Creates the widgets and layout for the 'General & Naming' editor tab."""
@ -206,7 +206,7 @@ class PresetEditorWidget(QWidget):
list_widget = QListWidget()
list_widget.setAlternatingRowColors(True)
list_widget.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked | QAbstractItemView.EditTrigger.SelectedClicked | QAbstractItemView.EditTrigger.EditKeyPressed)
setattr(self, attribute_name, list_widget) # Store list widget on the instance
setattr(self, attribute_name, list_widget)
add_button = QPushButton("+")
remove_button = QPushButton("-")
@ -231,7 +231,7 @@ class PresetEditorWidget(QWidget):
# Connections
add_button.clicked.connect(partial(self._editor_add_list_item, list_widget))
remove_button.clicked.connect(partial(self._editor_remove_list_item, list_widget))
list_widget.itemChanged.connect(self._mark_editor_unsaved) # Mark unsaved on item edit
list_widget.itemChanged.connect(self._mark_editor_unsaved)
def _setup_table_widget_with_controls(self, parent_layout, label_text, attribute_name, columns):
"""Adds a QTableWidget with Add/Remove buttons to a layout."""
@ -239,7 +239,7 @@ class PresetEditorWidget(QWidget):
table_widget.setColumnCount(len(columns))
table_widget.setHorizontalHeaderLabels(columns)
table_widget.setAlternatingRowColors(True)
setattr(self, attribute_name, table_widget) # Store table widget
setattr(self, attribute_name, table_widget)
add_button = QPushButton("+ Row")
remove_button = QPushButton("- Row")
@ -259,7 +259,7 @@ class PresetEditorWidget(QWidget):
# Connections
add_button.clicked.connect(partial(self._editor_add_table_row, table_widget))
remove_button.clicked.connect(partial(self._editor_remove_table_row, table_widget))
table_widget.itemChanged.connect(self._mark_editor_unsaved) # Mark unsaved on item edit
table_widget.itemChanged.connect(self._mark_editor_unsaved)
# --- Preset Population and Handling ---
def populate_presets(self):
@ -271,14 +271,12 @@ class PresetEditorWidget(QWidget):
self.editor_preset_list.clear()
log.debug("Preset list cleared.")
# Add the "Select a Preset" placeholder item
placeholder_item = QListWidgetItem("--- Select a Preset ---")
placeholder_item.setFlags(placeholder_item.flags() & ~Qt.ItemFlag.ItemIsSelectable & ~Qt.ItemFlag.ItemIsEditable)
placeholder_item.setData(Qt.ItemDataRole.UserRole, "__PLACEHOLDER__")
self.editor_preset_list.addItem(placeholder_item)
log.debug("Added '--- Select a Preset ---' placeholder item.")
# Add LLM Option
llm_item = QListWidgetItem("- LLM Interpretation -")
llm_item.setData(Qt.ItemDataRole.UserRole, "__LLM__") # Special identifier
self.editor_preset_list.addItem(llm_item)
@ -287,7 +285,6 @@ class PresetEditorWidget(QWidget):
if not PRESETS_DIR.is_dir():
msg = f"Error: Presets directory not found at {PRESETS_DIR}"
log.error(msg)
# Consider emitting a status signal to MainWindow?
return
presets = sorted([f for f in PRESETS_DIR.glob("*.json") if f.is_file() and not f.name.startswith('_')])
@ -298,13 +295,13 @@ class PresetEditorWidget(QWidget):
else:
for preset_path in presets:
item = QListWidgetItem(preset_path.stem)
item.setData(Qt.ItemDataRole.UserRole, preset_path) # Store full path
item.setData(Qt.ItemDataRole.UserRole, preset_path)
self.editor_preset_list.addItem(item)
log.info(f"Loaded {len(presets)} presets into editor list.")
# Select the "Select a Preset" item by default
log.debug("Preset list populated. Selecting '--- Select a Preset ---' item.")
self.editor_preset_list.setCurrentItem(placeholder_item) # Select the placeholder item
self.editor_preset_list.setCurrentItem(placeholder_item)
# --- Preset Editor Methods ---
@ -335,7 +332,7 @@ class PresetEditorWidget(QWidget):
combo_box.addItems(self._ftd_keys)
else:
log.warning("FILE_TYPE_DEFINITIONS keys not available for ComboBox in map_type_mapping.")
combo_box.currentIndexChanged.connect(self._mark_editor_unsaved) # Mark unsaved on change
combo_box.currentIndexChanged.connect(self._mark_editor_unsaved)
table_widget.setCellWidget(row_count, 0, combo_box)
# Column 1: Input Keywords (QTableWidgetItem)
table_widget.setItem(row_count, 1, QTableWidgetItem(""))
@ -358,9 +355,6 @@ class PresetEditorWidget(QWidget):
if self._is_loading_editor: return
self.editor_unsaved_changes = True
self.editor_save_button.setEnabled(True)
# Update window title (handled by MainWindow) - maybe emit signal?
# preset_name = Path(self.current_editing_preset_path).name if self.current_editing_preset_path else 'New Preset'
# self.window().setWindowTitle(f"Asset Processor Tool - {preset_name}*") # Access parent window
def _connect_editor_change_signals(self):
"""Connect signals from all editor widgets to mark_editor_unsaved."""
@ -417,7 +411,6 @@ class PresetEditorWidget(QWidget):
self.current_editing_preset_path = None
self.editor_unsaved_changes = False
self.editor_save_button.setEnabled(False)
# self.window().setWindowTitle("Asset Processor Tool") # Reset window title (handled by MainWindow)
self._set_editor_enabled(False)
finally:
self._is_loading_editor = False
@ -465,7 +458,7 @@ class PresetEditorWidget(QWidget):
else:
log.warning("FILE_TYPE_DEFINITIONS keys not available for ComboBox in map_type_mapping during population.")
combo_box.currentIndexChanged.connect(self._mark_editor_unsaved) # Connect signal
combo_box.currentIndexChanged.connect(self._mark_editor_unsaved)
self.editor_table_map_type_mapping.setCellWidget(i, 0, combo_box)
# Column 1: Input Keywords (QTableWidgetItem)
@ -514,7 +507,6 @@ class PresetEditorWidget(QWidget):
self.current_editing_preset_path = file_path
self.editor_unsaved_changes = False
self.editor_save_button.setEnabled(False)
# self.window().setWindowTitle(f"Asset Processor Tool - {file_path.name}") # Handled by MainWindow
log.info(f"Preset '{file_path.name}' loaded into editor.")
except json.JSONDecodeError as json_err:
log.error(f"Invalid JSON in {file_path.name}: {json_err}")
@ -562,7 +554,7 @@ class PresetEditorWidget(QWidget):
log.debug(f"Loading preset for editing: {current_item.text()}")
preset_path = item_data
self._load_preset_for_editing(preset_path)
self._last_valid_preset_name = preset_path.stem # Store the name
self._last_valid_preset_name = preset_path.stem
mode = "preset"
preset_name = self._last_valid_preset_name
else:
@ -656,10 +648,8 @@ class PresetEditorWidget(QWidget):
with open(self.current_editing_preset_path, 'w', encoding='utf-8') as f: f.write(content_to_save)
self.editor_unsaved_changes = False
self.editor_save_button.setEnabled(False)
# self.window().setWindowTitle(f"Asset Processor Tool - {self.current_editing_preset_path.name}") # Handled by MainWindow
self.presets_changed_signal.emit() # Signal that presets changed
self.presets_changed_signal.emit()
log.info("Preset saved successfully.")
# Refresh list within the editor
self.populate_presets()
# Reselect the saved item
items = self.editor_preset_list.findItems(self.current_editing_preset_path.stem, Qt.MatchFlag.MatchExactly)
@ -690,11 +680,10 @@ class PresetEditorWidget(QWidget):
if reply == QMessageBox.StandardButton.No: log.debug("Save As overwrite cancelled."); return False
log.info(f"Saving preset as: {save_path.name}")
with open(save_path, 'w', encoding='utf-8') as f: f.write(content_to_save)
self.current_editing_preset_path = save_path # Update current path
self.current_editing_preset_path = save_path
self.editor_unsaved_changes = False
self.editor_save_button.setEnabled(False)
# self.window().setWindowTitle(f"Asset Processor Tool - {save_path.name}") # Handled by MainWindow
self.presets_changed_signal.emit() # Signal change
self.presets_changed_signal.emit()
log.info("Preset saved successfully (Save As).")
# Refresh list and select the new item
self.populate_presets()
@ -718,18 +707,15 @@ class PresetEditorWidget(QWidget):
self._populate_editor_from_data(template_data)
# Override specific fields for a new preset
self.editor_preset_name.setText("NewPreset")
# self.window().setWindowTitle("Asset Processor Tool - New Preset*") # Handled by MainWindow
except Exception as e:
log.exception(f"Error loading template preset file {TEMPLATE_PATH}: {e}")
QMessageBox.critical(self, "Error", f"Could not load template preset file:\n{TEMPLATE_PATH}\n\nError: {e}")
self._clear_editor()
# self.window().setWindowTitle("Asset Processor Tool - New Preset*") # Handled by MainWindow
self.editor_supplier_name.setText("MySupplier") # Set a default supplier name
self.editor_supplier_name.setText("MySupplier")
else:
log.warning("Presets/_template.json not found. Creating empty preset.")
# self.window().setWindowTitle("Asset Processor Tool - New Preset*") # Handled by MainWindow
self.editor_preset_name.setText("NewPreset")
self.editor_supplier_name.setText("MySupplier") # Set a default supplier name
self.editor_supplier_name.setText("MySupplier")
self._set_editor_enabled(True)
self.editor_unsaved_changes = True
self.editor_save_button.setEnabled(True)
@ -761,8 +747,7 @@ class PresetEditorWidget(QWidget):
preset_path.unlink()
log.info("Preset deleted successfully.")
if self.current_editing_preset_path == preset_path: self._clear_editor()
self.presets_changed_signal.emit() # Signal change
# Refresh list
self.presets_changed_signal.emit()
self.populate_presets()
except Exception as e:
log.exception(f"Error deleting preset file {preset_path}: {e}")

View File

@ -1,15 +1,14 @@
import logging # Import logging
import logging
import time # For logging timestamps
from PySide6.QtCore import QAbstractTableModel, Qt, QModelIndex, QSortFilterProxyModel, QThread # Import QThread
from PySide6.QtCore import QAbstractTableModel, Qt, QModelIndex, QSortFilterProxyModel, QThread
from PySide6.QtGui import QColor
log = logging.getLogger(__name__) # Get logger
log = logging.getLogger(__name__)
# Define colors for alternating asset groups
COLOR_ASSET_GROUP_1 = QColor("#292929") # Dark grey 1
COLOR_ASSET_GROUP_2 = QColor("#343434") # Dark grey 2
# Define text colors for statuses
class PreviewTableModel(QAbstractTableModel):
"""
Custom table model for the GUI preview table.
@ -54,11 +53,11 @@ class PreviewTableModel(QAbstractTableModel):
self._headers_detailed = ["Status", "Predicted Asset", "Original Path", "Predicted Output", "Details", "Additional Files"] # Added new column header
self._sorted_unique_assets = [] # Store sorted unique asset names for coloring
self._headers_simple = ["Input Path"]
self.set_data(data or []) # Initialize data and simple_data
self.set_data(data or [])
def set_simple_mode(self, enabled: bool):
"""Toggles the model between detailed and simple view modes."""
thread_id = QThread.currentThread() # Get current thread object
thread_id = QThread.currentThread()
log.info(f"[{time.time():.4f}][T:{thread_id}] --> Entered PreviewTableModel.set_simple_mode(enabled={enabled}). Current mode: {self._simple_mode}")
if self._simple_mode != enabled:
log.info(f"[{time.time():.4f}][T:{thread_id}] Calling beginResetModel()...")
@ -78,7 +77,6 @@ class PreviewTableModel(QAbstractTableModel):
if parent.isValid():
return 0
row_count = len(self._simple_data) if self._simple_mode else len(self._table_rows) # Use _table_rows for detailed mode
# log.debug(f"PreviewTableModel.rowCount called. Mode: {self._simple_mode}, Row Count: {row_count}")
return row_count
def columnCount(self, parent=QModelIndex()):
@ -86,7 +84,6 @@ class PreviewTableModel(QAbstractTableModel):
if parent.isValid():
return 0
col_count = len(self._headers_simple) if self._simple_mode else len(self._headers_detailed) # Use updated headers_detailed
# log.debug(f"PreviewTableModel.columnCount called. Mode: {self._simple_mode}, Column Count: {col_count}")
return col_count
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
@ -100,8 +97,7 @@ class PreviewTableModel(QAbstractTableModel):
# --- Simple Mode ---
if self._simple_mode:
if row >= len(self._simple_data):
# log.warning(f"data called with out of bounds row in simple mode: {row}/{len(self._simple_data)}")
return None # Bounds check
return None
source_asset_path = self._simple_data[row]
if role == Qt.ItemDataRole.DisplayRole:
if col == self.COL_SIMPLE_PATH:
@ -113,12 +109,10 @@ class PreviewTableModel(QAbstractTableModel):
# --- Detailed Mode ---
if row >= len(self._table_rows): # Use _table_rows
# log.warning(f"data called with out of bounds row in detailed mode: {row}/{len(self._table_rows)}")
return None # Bounds check
return None
row_data = self._table_rows[row] # Get data from the structured row
# --- Handle Custom Internal Roles ---
# These roles are now handled by the proxy model based on the structured data
if role == self.ROLE_RAW_STATUS:
# Return status of the main file if it exists, otherwise a placeholder for additional rows
main_file = row_data.get('main_file')
@ -132,7 +126,7 @@ class PreviewTableModel(QAbstractTableModel):
main_file = row_data.get('main_file')
if main_file:
raw_status = main_file.get('status', '[No Status]')
details = main_file.get('details', '') # Get details for parsing
details = main_file.get('details', '')
# Implement status text simplification
if raw_status == "Unmatched Extra":
@ -268,14 +262,6 @@ class PreviewTableModel(QAbstractTableModel):
return None
# --- Handle Text Alignment Role ---
if role == Qt.ItemDataRole.TextAlignmentRole:
if col == self.COL_ORIGINAL_PATH:
return int(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
elif col == self.COL_ADDITIONAL_FILES:
return int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
# For other columns, return default alignment (or None)
return None
return None
@ -291,7 +277,7 @@ class PreviewTableModel(QAbstractTableModel):
def set_data(self, data: list):
"""Sets the model's data, extracts simple data, and emits signals."""
# Removed diagnostic import here
thread_id = QThread.currentThread() # Get current thread object
thread_id = QThread.currentThread()
log.info(f"[{time.time():.4f}][T:{thread_id}] --> Entered PreviewTableModel.set_data. Received {len(data)} items.")
log.info(f"[{time.time():.4f}][T:{thread_id}] Calling beginResetModel()...")
self.beginResetModel()
@ -357,7 +343,7 @@ class PreviewTableModel(QAbstractTableModel):
def clear_data(self):
"""Clears the model's data."""
thread_id = QThread.currentThread() # Get current thread object
thread_id = QThread.currentThread()
log.info(f"[{time.time():.4f}][T:{thread_id}] PreviewTableModel.clear_data called.")
self.set_data([])
@ -398,21 +384,18 @@ class PreviewSortFilterProxyModel(QSortFilterProxyModel):
"""
model = self.sourceModel()
if not model:
# log.debug("ProxyModel.lessThan: No source model.")
return super().lessThan(left, right) # Fallback if no source model
# If in simple mode, sort by the simple path column
if isinstance(model, PreviewTableModel) and model._simple_mode:
left_path = model.data(left.siblingAtColumn(model.COL_SIMPLE_PATH), Qt.ItemDataRole.DisplayRole)
right_path = model.data(right.siblingAtColumn(model.COL_SIMPLE_PATH), Qt.ItemDataRole.DisplayRole)
# log.debug(f"ProxyModel.lessThan (Simple Mode): Comparing '{left_path}' < '{right_path}'")
if not left_path: return True
if not right_path: return False
return left_path < right_path
# --- Detailed Mode Sorting ---
# log.debug("ProxyModel.lessThan (Detailed Mode).")
# Get the full row data from the source model's _table_rows
left_row_data = model._table_rows[left.row()]
right_row_data = model._table_rows[right.row()]

View File

@ -3,16 +3,12 @@ from PySide6.QtWidgets import (QApplication, QWidget, QVBoxLayout, QLabel, QLine
QFormLayout, QComboBox, QCheckBox, QSpinBox, QDoubleSpinBox)
from PySide6.QtCore import Signal, Slot, QObject
# Assuming rule_structure.py is in the parent directory or accessible via PYTHONPATH
# from ..rule_structure import SourceRule, AssetRule, FileRule # Adjust import based on actual structure
# For now, we'll use placeholder classes or assume rule_structure is directly importable
# from rule_structure import SourceRule, AssetRule, FileRule # Assuming direct import is possible
class RuleEditorWidget(QWidget):
"""
A widget to display and edit hierarchical processing rules (Source, Asset, File).
"""
rule_updated = Signal(object) # Signal emitted when a rule is updated
rule_updated = Signal(object)
def __init__(self, asset_types: list[str] | None = None, file_types: list[str] | None = None, parent=None):
"""
@ -24,8 +20,8 @@ class RuleEditorWidget(QWidget):
parent: The parent widget.
"""
super().__init__(parent)
self.asset_types = asset_types if asset_types else [] # Store asset types
self.file_types = file_types if file_types else [] # Store file types
self.asset_types = asset_types if asset_types else []
self.file_types = file_types if file_types else []
self.current_rule_type = None
self.current_rule_object = None
@ -65,7 +61,6 @@ class RuleEditorWidget(QWidget):
editor_widget = self._create_editor_widget(attr_name, attr_value)
if editor_widget:
self.form_layout.addRow(label, editor_widget)
# Connect signal to update rule object
self._connect_editor_signal(editor_widget, attr_name)
def _create_editor_widget(self, attr_name, attr_value):
@ -79,7 +74,6 @@ class RuleEditorWidget(QWidget):
# Handle None case for override: if None, don't select anything or select a placeholder
if attr_value is None and attr_name == 'asset_type_override':
# Optionally add a placeholder like "<None>" or "<Default>"
# widget.insertItem(0, "<Default>") # Example placeholder
widget.setCurrentIndex(-1) # No selection or placeholder
elif attr_value in self.asset_types:
widget.setCurrentText(attr_value)
@ -114,12 +108,6 @@ class RuleEditorWidget(QWidget):
widget = QLineEdit()
widget.setText(str(attr_value) if attr_value is not None else "")
return widget
# Add more types as needed
# elif isinstance(attr_value, list):
# # Example for a simple list of strings
# widget = QLineEdit()
# widget.setText(", ".join(map(str, attr_value)))
# return widget
else:
# For unsupported types, just display the value
label = QLabel(str(attr_value))
@ -140,7 +128,6 @@ class RuleEditorWidget(QWidget):
elif isinstance(editor_widget, QComboBox):
# Use currentTextChanged to get the string value directly
editor_widget.currentTextChanged.connect(lambda text: self._update_rule_attribute(attr_name, text))
# Add connections for other widget types
def _update_rule_attribute(self, attr_name, value):
"""
@ -162,7 +149,6 @@ class RuleEditorWidget(QWidget):
converted_value = value # Fallback for other types
setattr(self.current_rule_object, attr_name, converted_value)
self.rule_updated.emit(self.current_rule_object)
# print(f"Updated {attr_name} to {converted_value} in {self.current_rule_type}") # Debugging
except ValueError:
# Handle potential conversion errors (e.g., non-numeric input for int/float)
print(f"Error converting value '{value}' for attribute '{attr_name}'")
@ -202,8 +188,8 @@ if __name__ == '__main__':
file_setting_y: str = "default_file_string"
# Example usage: Provide asset types during instantiation
asset_types_from_config = ["Surface", "Model", "Decal", "Atlas", "UtilityMap"] # Example list
file_types_from_config = ["MAP_COL", "MAP_NRM", "MAP_METAL", "MAP_ROUGH", "MAP_AO", "MAP_DISP", "MAP_REFL", "MAP_SSS", "MAP_FUZZ", "MAP_IDMAP", "MAP_MASK", "MAP_IMPERFECTION", "MODEL", "EXTRA", "FILE_IGNORE"] # Example list
asset_types_from_config = ["Surface", "Model", "Decal", "Atlas", "UtilityMap"]
file_types_from_config = ["MAP_COL", "MAP_NRM", "MAP_METAL", "MAP_ROUGH", "MAP_AO", "MAP_DISP", "MAP_REFL", "MAP_SSS", "MAP_FUZZ", "MAP_IDMAP", "MAP_MASK", "MAP_IMPERFECTION", "MODEL", "EXTRA", "FILE_IGNORE"]
editor = RuleEditorWidget(asset_types=asset_types_from_config, file_types=file_types_from_config)
# Test loading different rule types
@ -212,8 +198,6 @@ if __name__ == '__main__':
file_rule = FileRule()
editor.load_rule(source_rule, "SourceRule")
# editor.load_rule(asset_rule, "AssetRule")
# editor.load_rule(file_rule, "FileRule")
editor.show()

View File

@ -1,6 +1,6 @@
from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal, Slot
from PySide6.QtGui import QIcon # Assuming we might want icons later
from rule_structure import SourceRule, AssetRule, FileRule # Import rule structures
from rule_structure import SourceRule, AssetRule, FileRule
class RuleHierarchyModel(QAbstractItemModel):
"""
@ -57,15 +57,6 @@ class RuleHierarchyModel(QAbstractItemModel):
else:
return None
# Add other roles as needed (e.g., Qt.ItemDataRole.DecorationRole for icons)
# elif role == Qt.ItemDataRole.DecorationRole:
# if isinstance(item, SourceRule):
# return QIcon("icons/source.png") # Placeholder icon
# elif isinstance(item, AssetRule):
# return QIcon("icons/asset.png") # Placeholder icon
# elif isinstance(item, FileRule):
# return QIcon("icons/file.png") # Placeholder icon
# else:
# return None
return None

Some files were not shown because too many files have changed in this diff Show More