Major Comment and codebase cleanup
This commit is contained in:
parent
ddb5a43a21
commit
932b39fd01
@ -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)
|
|
||||||
@ -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): ...
|
|
||||||
@ -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
|
|
||||||
@ -1 +0,0 @@
|
|||||||
# REMOVED Placeholder SourceRule creation
|
|
||||||
@ -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 ---
|
|
||||||
@ -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
|
|
||||||
@ -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.
|
|
||||||
@ -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 ---
|
|
||||||
@ -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 ---
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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}")
|
|
||||||
@ -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)
|
|
||||||
@ -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
@ -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_"
|
|
||||||
@ -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`.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
@ -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.
|
|
||||||
@ -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.
|
* **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.
|
* **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.
|
* **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.
|
* **Imports:** Organize imports at the top of the file, grouped by standard library, third-party libraries, and local modules.
|
||||||
* **Naming:**
|
* **Naming:**
|
||||||
* Use `snake_case` for function and variable names.
|
* Use `snake_case` for function and variable names.
|
||||||
|
|||||||
@ -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.
|
|
||||||
@ -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}")
|
|
||||||
@ -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
|
|
||||||
@ -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;
|
|
||||||
@ -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;
|
|
||||||
@ -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
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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];
|
|
||||||
@ -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];
|
|
||||||
@ -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
|
|
||||||
@ -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.
|
|
||||||
@ -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;
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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];
|
|
||||||
@ -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.
|
|
||||||
@ -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];
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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")`.
|
|
||||||
@ -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.
|
|
||||||
@ -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.};
|
|
||||||
@ -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.
|
|
||||||
@ -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
|
|
||||||
@ -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];
|
|
||||||
@ -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];
|
|
||||||
@ -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".
|
|
||||||
@ -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
|
|
||||||
@ -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;
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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`
|
|
||||||
@ -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`.
|
|
||||||
@ -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
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
```
|
|
||||||
@ -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.
|
|
||||||
@ -12,18 +12,15 @@ bl_info = {
|
|||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
|
|
||||||
# Import other modules (will be created later)
|
|
||||||
from . import operator
|
from . import operator
|
||||||
from . import panel
|
from . import panel
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
# Register classes from imported modules
|
|
||||||
operator.register()
|
operator.register()
|
||||||
panel.register()
|
panel.register()
|
||||||
print("Material Merger Addon Registered")
|
print("Material Merger Addon Registered")
|
||||||
|
|
||||||
def unregister():
|
def unregister():
|
||||||
# Unregister classes from imported modules
|
|
||||||
panel.unregister()
|
panel.unregister()
|
||||||
operator.unregister()
|
operator.unregister()
|
||||||
print("Material Merger Addon Unregistered")
|
print("Material Merger Addon Unregistered")
|
||||||
|
|||||||
@ -10,7 +10,6 @@ MATERIAL_MERGE_NODEGROUP_NAME = "MaterialMerge"
|
|||||||
HANDLER_NODEGROUP_NAME = "PBR_Handler" # Assumption from plan
|
HANDLER_NODEGROUP_NAME = "PBR_Handler" # Assumption from plan
|
||||||
BSDF_NODEGROUP_NAME = "PBR_BSDF" # 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)):
|
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.
|
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
|
return None, None, None
|
||||||
|
|
||||||
source_tree = source_mat.node_tree
|
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_bsdf_node = None
|
||||||
copied_final_disp_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.")
|
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_bsdf_node = top_merge_node
|
||||||
source_final_disp_node = top_merge_node # Both outputs come from the 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:
|
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.")
|
print(f" Error: Identified merge node in '{source_mat.name}' lacks required BSDF/Displacement outputs.")
|
||||||
return None, None, None
|
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:
|
if not source_final_disp_node:
|
||||||
print(f" Error: Could not find base Handler node '{HANDLER_NODEGROUP_NAME}' in '{source_mat.name}'.")
|
print(f" Error: Could not find base Handler node '{HANDLER_NODEGROUP_NAME}' in '{source_mat.name}'.")
|
||||||
return None, None, None
|
return None, None, None
|
||||||
# Ensure sockets exist
|
|
||||||
if 'BSDF' not in source_final_bsdf_node.outputs:
|
if 'BSDF' not in source_final_bsdf_node.outputs:
|
||||||
print(f" Error: Identified BSDF node '{BSDF_NODEGROUP_NAME}' lacks BSDF output.")
|
print(f" Error: Identified BSDF node '{BSDF_NODEGROUP_NAME}' lacks BSDF output.")
|
||||||
return None, None, None
|
return None, None, None
|
||||||
@ -152,7 +149,6 @@ class MATERIAL_OT_merge_materials(Operator):
|
|||||||
bl_label = "Merge Selected Materials"
|
bl_label = "Merge Selected Materials"
|
||||||
bl_options = {'REGISTER', 'UNDO'}
|
bl_options = {'REGISTER', 'UNDO'}
|
||||||
|
|
||||||
# Properties to hold the names of the selected materials
|
|
||||||
# These will be set by the UI panel
|
# These will be set by the UI panel
|
||||||
material_a_name: StringProperty(
|
material_a_name: StringProperty(
|
||||||
name="Material A",
|
name="Material A",
|
||||||
@ -195,14 +191,14 @@ class MATERIAL_OT_merge_materials(Operator):
|
|||||||
|
|
||||||
# Add Material Output node
|
# Add Material Output node
|
||||||
output_node = new_node_tree.nodes.new(type='ShaderNodeOutputMaterial')
|
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
|
# 2. Copy nodes from source materials
|
||||||
print("Copying nodes for Material A...")
|
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))
|
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:
|
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.")
|
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'}
|
return {'CANCELLED'}
|
||||||
|
|
||||||
print("Copying nodes for Material B...")
|
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))
|
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:
|
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.")
|
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'}
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
|
||||||
@ -255,17 +251,14 @@ class MATERIAL_OT_merge_materials(Operator):
|
|||||||
# Add the linked/appended group to the new material's node tree
|
# Add the linked/appended group to the new material's node tree
|
||||||
merge_node = new_node_tree.nodes.new(type='ShaderNodeGroup')
|
merge_node = new_node_tree.nodes.new(type='ShaderNodeGroup')
|
||||||
merge_node.node_tree = merge_group
|
merge_node.node_tree = merge_group
|
||||||
merge_node.label = MATERIAL_MERGE_NODEGROUP_NAME # Set label for clarity
|
merge_node.label = MATERIAL_MERGE_NODEGROUP_NAME
|
||||||
merge_node.location = (200, 0) # Basic positioning
|
merge_node.location = (200, 0)
|
||||||
|
|
||||||
|
|
||||||
# 4. Make Connections
|
# 4. Make Connections
|
||||||
links = new_node_tree.links
|
links = new_node_tree.links
|
||||||
|
|
||||||
# Connect BSDFs to Merge node
|
# 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')
|
bsdf_output_socket_a = copied_bsdf_a.outputs.get('BSDF')
|
||||||
shader_input_socket_a = merge_node.inputs.get('Shader A')
|
shader_input_socket_a = merge_node.inputs.get('Shader A')
|
||||||
bsdf_output_socket_b = copied_bsdf_b.outputs.get('BSDF')
|
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]):
|
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.")
|
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'}
|
return {'CANCELLED'}
|
||||||
|
|
||||||
link_bsdf_a = links.new(bsdf_output_socket_a, shader_input_socket_a)
|
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)
|
link_bsdf_b = links.new(bsdf_output_socket_b, shader_input_socket_b)
|
||||||
|
|
||||||
# Connect Displacements to Merge node
|
# 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_output_socket_a = copied_disp_a.outputs.get('Displacement')
|
||||||
disp_input_socket_a = merge_node.inputs.get('Displacement A')
|
disp_input_socket_a = merge_node.inputs.get('Displacement A')
|
||||||
disp_output_socket_b = copied_disp_b.outputs.get('Displacement')
|
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]):
|
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.")
|
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'}
|
return {'CANCELLED'}
|
||||||
|
|
||||||
link_disp_a = links.new(disp_output_socket_a, disp_input_socket_a)
|
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)
|
link_disp_b = links.new(disp_output_socket_b, disp_input_socket_b)
|
||||||
|
|
||||||
# Connect Merge node outputs to Material Output
|
# Connect Merge node outputs to Material Output
|
||||||
# Ensure the sockets exist before linking
|
|
||||||
merge_bsdf_output = merge_node.outputs.get('BSDF')
|
merge_bsdf_output = merge_node.outputs.get('BSDF')
|
||||||
output_surface_input = output_node.inputs.get('Surface')
|
output_surface_input = output_node.inputs.get('Surface')
|
||||||
merge_disp_output = merge_node.outputs.get('Displacement')
|
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]):
|
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.")
|
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'}
|
return {'CANCELLED'}
|
||||||
|
|
||||||
link_merge_bsdf = links.new(merge_bsdf_output, output_surface_input)
|
link_merge_bsdf = links.new(merge_bsdf_output, output_surface_input)
|
||||||
@ -315,7 +304,6 @@ class MATERIAL_OT_merge_materials(Operator):
|
|||||||
# 5. Layout (Optional)
|
# 5. Layout (Optional)
|
||||||
# TODO: Implement better node layout
|
# TODO: Implement better node layout
|
||||||
|
|
||||||
# Update node tree to apply changes
|
|
||||||
new_node_tree.nodes.update()
|
new_node_tree.nodes.update()
|
||||||
|
|
||||||
self.report({'INFO'}, f"Successfully merged '{mat_a.name}' and '{mat_b.name}' into '{new_mat.name}'")
|
self.report({'INFO'}, f"Successfully merged '{mat_a.name}' and '{mat_b.name}' into '{new_mat.name}'")
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import bpy
|
import bpy
|
||||||
from bpy.types import Panel
|
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):
|
class MATERIAL_PT_material_merger_panel(Panel):
|
||||||
"""Creates a Panel in the Shader Editor sidebar"""
|
"""Creates a Panel in the Shader Editor sidebar"""
|
||||||
@ -13,9 +13,6 @@ class MATERIAL_PT_material_merger_panel(Panel):
|
|||||||
def draw(self, context):
|
def draw(self, context):
|
||||||
layout = self.layout
|
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 = layout.row()
|
||||||
row.label(text="Select Materials to Merge:")
|
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.
|
# We'll use StringProperty for simplicity in the UI for now.
|
||||||
# A more advanced UI might use PointerProperty to bpy.data.materials
|
# A more advanced UI might use PointerProperty to bpy.data.materials
|
||||||
|
|
||||||
# Material A selection
|
|
||||||
row = layout.row()
|
row = layout.row()
|
||||||
row.prop(context.scene, "material_merger_mat_a", text="Material A")
|
row.prop(context.scene, "material_merger_mat_a", text="Material A")
|
||||||
|
|
||||||
# Material B selection
|
|
||||||
row = layout.row()
|
row = layout.row()
|
||||||
row.prop(context.scene, "material_merger_mat_b", text="Material B")
|
row.prop(context.scene, "material_merger_mat_b", text="Material B")
|
||||||
|
|
||||||
|
|
||||||
# Merge button
|
|
||||||
row = layout.row()
|
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_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
|
row.operator(MATERIAL_OT_merge_materials.bl_idname, text=MATERIAL_OT_merge_materials.bl_label).material_b_name = context.scene.material_merger_mat_b
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import time
|
import time
|
||||||
import base64 # Although not directly used here, keep for consistency if reusing more code later
|
import base64 # Although not directly used here, keep for consistency if reusing more code later
|
||||||
import sys # <<< ADDED IMPORT
|
import sys
|
||||||
|
|
||||||
# --- USER CONFIGURATION ---
|
# --- 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
|
primary_path = asset_dir_path / filename
|
||||||
if primary_path.is_file():
|
if primary_path.is_file():
|
||||||
# print(f" Found primary path: {str(primary_path)}") # Verbose
|
|
||||||
return str(primary_path)
|
return str(primary_path)
|
||||||
# else: print(f" Primary path not found: {str(primary_path)}") # Verbose
|
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
print(f" !!! ERROR: Missing key '{e}' in IMAGE_FILENAME_PATTERN. Cannot reconstruct path.")
|
print(f" !!! ERROR: Missing key '{e}' in IMAGE_FILENAME_PATTERN. Cannot reconstruct path.")
|
||||||
return None # Cannot proceed without valid pattern
|
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
|
# Continue to fallback
|
||||||
|
|
||||||
# 2. Try fallback extensions
|
# 2. Try fallback extensions
|
||||||
# print(f" Trying fallback extensions for {map_type}/{resolution}...") # Verbose
|
|
||||||
for ext in FALLBACK_IMAGE_EXTENSIONS:
|
for ext in FALLBACK_IMAGE_EXTENSIONS:
|
||||||
# Skip if we already tried this extension as primary (and it failed)
|
# Skip if we already tried this extension as primary (and it failed)
|
||||||
if primary_format and ext.lower() == primary_format.lower():
|
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:
|
if isinstance(map_stats, dict) and stat_key in map_stats:
|
||||||
return map_stats[stat_key] # Return the value for the first match
|
return map_stats[stat_key] # Return the value for the first match
|
||||||
else:
|
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
|
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
|
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
|
Scans the library, reads metadata, finds PBRSET node groups in the specified
|
||||||
.blend file, and creates/updates materials linking to them.
|
.blend file, and creates/updates materials linking to them.
|
||||||
"""
|
"""
|
||||||
print("DEBUG: Script started.") # DEBUG LOG
|
print("DEBUG: Script started.")
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
print(f"\n--- Starting Material Creation from Node Groups ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---")
|
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 asset_library_root_override: {asset_library_root_override}")
|
||||||
print(f" DEBUG: Received nodegroup_blend_file_path_override: {nodegroup_blend_file_path_override}") # DEBUG LOG (Indented)
|
print(f" DEBUG: Received nodegroup_blend_file_path_override: {nodegroup_blend_file_path_override}")
|
||||||
|
|
||||||
|
|
||||||
# --- Determine Asset Library Root ---
|
# --- 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("!!! ERROR: Processed asset library root not set in script and not provided via argument.")
|
||||||
print("--- Script aborted. ---")
|
print("--- Script aborted. ---")
|
||||||
return False
|
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 ---
|
# --- Determine Nodegroup Blend File Path ---
|
||||||
if nodegroup_blend_file_path_override:
|
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("!!! ERROR: Nodegroup blend file path not set in script and not provided via argument.")
|
||||||
print("--- Script aborted. ---")
|
print("--- Script aborted. ---")
|
||||||
return False
|
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 ---
|
# --- Pre-run Checks ---
|
||||||
@ -281,8 +276,8 @@ def process_library_for_materials(context, asset_library_root_override=None, nod
|
|||||||
else:
|
else:
|
||||||
placeholder_node_found_in_template = True
|
placeholder_node_found_in_template = True
|
||||||
print(f" Found Template Material: '{TEMPLATE_MATERIAL_NAME}' with placeholder '{PLACEHOLDER_NODE_LABEL}'")
|
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: Template Material Found: {template_mat is not None}")
|
||||||
print(f" DEBUG: Placeholder Node Found in Template: {placeholder_node_found_in_template}") # DEBUG LOG (Indented)
|
print(f" DEBUG: Placeholder Node Found in Template: {placeholder_node_found_in_template}")
|
||||||
|
|
||||||
|
|
||||||
if not valid_setup:
|
if not valid_setup:
|
||||||
@ -296,7 +291,6 @@ def process_library_for_materials(context, asset_library_root_override=None, nod
|
|||||||
assets_processed = 0
|
assets_processed = 0
|
||||||
assets_skipped = 0
|
assets_skipped = 0
|
||||||
materials_created = 0
|
materials_created = 0
|
||||||
# materials_updated = 0 # Not updating existing materials anymore
|
|
||||||
node_groups_linked = 0
|
node_groups_linked = 0
|
||||||
previews_set = 0
|
previews_set = 0
|
||||||
viewport_colors_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)
|
metadata_files_found = len(metadata_paths)
|
||||||
print(f"Found {metadata_files_found} metadata.json files.")
|
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:
|
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
|
return True # No work needed is considered success
|
||||||
|
|
||||||
# --- Process Each Metadata File ---
|
# --- 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:
|
for metadata_path in metadata_paths:
|
||||||
asset_dir_path = metadata_path.parent
|
asset_dir_path = metadata_path.parent
|
||||||
print(f"\n--- Processing Metadata: {metadata_path.relative_to(root_path)} ---")
|
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:
|
try:
|
||||||
with open(metadata_path, 'r', encoding='utf-8') as f:
|
with open(metadata_path, 'r', encoding='utf-8') as f:
|
||||||
metadata = json.load(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.")
|
print(f" !!! ERROR: Metadata file is missing 'asset_name'. Skipping.")
|
||||||
errors_encountered += 1
|
errors_encountered += 1
|
||||||
continue
|
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}")
|
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 ---
|
# --- Determine Target Names ---
|
||||||
target_material_name = f"{MATERIAL_NAME_PREFIX}{asset_name}"
|
target_material_name = f"{MATERIAL_NAME_PREFIX}{asset_name}"
|
||||||
target_pbrset_group_name = f"{PBRSET_GROUP_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 Material Name: {target_material_name}")
|
||||||
print(f" DEBUG: Target PBRSET Group Name: {target_pbrset_group_name}") # DEBUG LOG (Indented)
|
print(f" DEBUG: Target PBRSET Group Name: {target_pbrset_group_name}")
|
||||||
|
|
||||||
|
|
||||||
# --- Check if Material Already Exists (Skip Logic) ---
|
# --- 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.")
|
print(f" Skipping asset '{asset_name}': Material '{target_material_name}' already exists.")
|
||||||
assets_skipped += 1
|
assets_skipped += 1
|
||||||
continue # Move to the next metadata file
|
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 ---
|
# --- Create New Material ---
|
||||||
print(f" Creating new material: '{target_material_name}'")
|
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()
|
material = template_mat.copy()
|
||||||
if not material:
|
if not material:
|
||||||
print(f" !!! ERROR: Failed to copy template material '{TEMPLATE_MATERIAL_NAME}'. Skipping asset '{asset_name}'.")
|
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
|
continue
|
||||||
material.name = target_material_name
|
material.name = target_material_name
|
||||||
materials_created += 1
|
materials_created += 1
|
||||||
print(f" DEBUG: Material '{material.name}' created.") # DEBUG LOG (Indented)
|
print(f" DEBUG: Material '{material.name}' created.")
|
||||||
|
|
||||||
|
|
||||||
# --- Find Placeholder Node ---
|
# --- 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
|
placeholder_node = None # Ensure it's None
|
||||||
else:
|
else:
|
||||||
placeholder_node = placeholder_nodes[0] # Assume first is correct
|
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 ---
|
# --- Find and Link PBRSET Node Group from Library ---
|
||||||
linked_pbrset_group = None
|
linked_pbrset_group = None
|
||||||
if placeholder_node and pbrset_blend_file_path: # Only proceed if placeholder exists and library file is known
|
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
|
# Check if the group is already linked in the current file
|
||||||
existing_linked_group = bpy.data.node_groups.get(target_pbrset_group_name)
|
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
|
# 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 ---
|
# --- Link Linked Node Group to Placeholder ---
|
||||||
if placeholder_node and linked_pbrset_group:
|
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:
|
if placeholder_node.node_tree != linked_pbrset_group:
|
||||||
try:
|
try:
|
||||||
placeholder_node.node_tree = linked_pbrset_group
|
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 ---
|
# --- Mark Material as Asset ---
|
||||||
if not material.asset_data:
|
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:
|
try:
|
||||||
material.asset_mark()
|
material.asset_mark()
|
||||||
print(f" Marked material '{material.name}' as asset.")
|
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 ---
|
# --- Copy Asset Tags ---
|
||||||
if material.asset_data and linked_pbrset_group and linked_pbrset_group.asset_data:
|
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
|
tags_copied_count = 0
|
||||||
if supplier_name:
|
if supplier_name:
|
||||||
if add_tag_if_new(material.asset_data, supplier_name): tags_copied_count += 1
|
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
|
# Copy other tags from PBRSET group
|
||||||
for ng_tag in linked_pbrset_group.asset_data.tags:
|
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 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 ---
|
# --- Set Custom Preview ---
|
||||||
@ -525,12 +517,12 @@ def process_library_for_materials(context, asset_library_root_override=None, nod
|
|||||||
|
|
||||||
# --- Set Viewport Properties from Stats ---
|
# --- Set Viewport Properties from Stats ---
|
||||||
if image_stats_1k and isinstance(image_stats_1k, dict):
|
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
|
# Viewport Color
|
||||||
color_mean = get_stat_value(image_stats_1k, VIEWPORT_COLOR_MAP_TYPES, 'mean')
|
color_mean = get_stat_value(image_stats_1k, VIEWPORT_COLOR_MAP_TYPES, 'mean')
|
||||||
if isinstance(color_mean, list) and len(color_mean) >= 3:
|
if isinstance(color_mean, list) and len(color_mean) >= 3:
|
||||||
color_rgba = (*color_mean[:3], 1.0)
|
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]):
|
if tuple(material.diffuse_color[:3]) != tuple(color_rgba[:3]):
|
||||||
material.diffuse_color = color_rgba
|
material.diffuse_color = color_rgba
|
||||||
print(f" Set viewport color: {color_rgba[:3]}")
|
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 Processed/Attempted: {assets_processed}")
|
||||||
print(f"Assets Skipped (Already Exist): {assets_skipped}")
|
print(f"Assets Skipped (Already Exist): {assets_skipped}")
|
||||||
print(f"Materials Created: {materials_created}")
|
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"PBRSET Node Groups Linked: {node_groups_linked}")
|
||||||
print(f"Material Previews Set: {previews_set}")
|
print(f"Material Previews Set: {previews_set}")
|
||||||
print(f"Viewport Colors Set: {viewport_colors_set}")
|
print(f"Viewport Colors Set: {viewport_colors_set}")
|
||||||
print(f"Viewport Roughness Set: {viewport_roughness_set}")
|
print(f"Viewport Roughness Set: {viewport_roughness_set}")
|
||||||
print(f"Viewport Metallic Set: {viewport_metallic_set}")
|
print(f"Viewport Metallic Set: {viewport_metallic_set}")
|
||||||
if pbrset_groups_missing_in_library > 0:
|
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:
|
if library_link_errors > 0:
|
||||||
print(f"!!! Library Link Errors: {library_link_errors} !!!")
|
print(f"!!! Library Link Errors: {library_link_errors} !!!")
|
||||||
if placeholder_nodes_missing > 0:
|
if placeholder_nodes_missing > 0:
|
||||||
@ -611,7 +602,7 @@ def process_library_for_materials(context, asset_library_root_override=None, nod
|
|||||||
print("---------------------------------------")
|
print("---------------------------------------")
|
||||||
|
|
||||||
# --- Explicit Save ---
|
# --- 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:
|
try:
|
||||||
bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)
|
bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)
|
||||||
print("\n--- Explicitly saved the .blend file. ---")
|
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}")
|
print(f"Found nodegroup blend file path argument: {nodegroup_blend_file_arg}")
|
||||||
else:
|
else:
|
||||||
print("Info: '--' found but not enough arguments after it for nodegroup blend file.")
|
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:
|
except Exception as e:
|
||||||
print(f"Error parsing command line arguments: {e}")
|
print(f"Error parsing command line arguments: {e}")
|
||||||
# --- End Argument Parsing ---
|
# --- End Argument Parsing ---
|
||||||
|
|||||||
@ -28,7 +28,7 @@ from pathlib import Path
|
|||||||
import time
|
import time
|
||||||
import re # For parsing aspect ratio string
|
import re # For parsing aspect ratio string
|
||||||
import base64 # For encoding node group names
|
import base64 # For encoding node group names
|
||||||
import sys # <<< ADDED IMPORT
|
import sys
|
||||||
|
|
||||||
# --- USER CONFIGURATION ---
|
# --- USER CONFIGURATION ---
|
||||||
|
|
||||||
@ -36,7 +36,7 @@ import sys # <<< ADDED IMPORT
|
|||||||
# Example: r"G:\Assets\Processed"
|
# Example: r"G:\Assets\Processed"
|
||||||
# IMPORTANT: This should point to the base directory containing supplier folders (e.g., Poliigon)
|
# IMPORTANT: This should point to the base directory containing supplier folders (e.g., Poliigon)
|
||||||
# This will be overridden by command-line arguments if provided.
|
# 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
|
# Names of the required node group templates in the Blender file
|
||||||
PARENT_TEMPLATE_NAME = "Template_PBRSET"
|
PARENT_TEMPLATE_NAME = "Template_PBRSET"
|
||||||
@ -109,7 +109,6 @@ CATEGORIES_FOR_NODEGROUP_GENERATION = ["Surface", "Decal"]
|
|||||||
def encode_name_b64(name_str):
|
def encode_name_b64(name_str):
|
||||||
"""Encodes a string using URL-safe Base64 for node group names."""
|
"""Encodes a string using URL-safe Base64 for node group names."""
|
||||||
try:
|
try:
|
||||||
# Ensure the input is a string
|
|
||||||
name_str = str(name_str)
|
name_str = str(name_str)
|
||||||
return base64.urlsafe_b64encode(name_str.encode('utf-8')).decode('ascii')
|
return base64.urlsafe_b64encode(name_str.encode('utf-8')).decode('ascii')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -174,7 +173,6 @@ def get_color_space(map_type):
|
|||||||
return PBR_COLOR_SPACE_MAP[short_type]
|
return PBR_COLOR_SPACE_MAP[short_type]
|
||||||
|
|
||||||
# Fallback if no specific rule found
|
# 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
|
return DEFAULT_COLOR_SPACE
|
||||||
|
|
||||||
def calculate_aspect_correction_factor(image_width, image_height, aspect_string):
|
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.")
|
print(" Warn: Invalid image dimensions for aspect ratio calculation. Returning 1.0.")
|
||||||
return 1.0
|
return 1.0
|
||||||
|
|
||||||
# Calculate the actual aspect ratio of the image file
|
|
||||||
current_aspect_ratio = image_width / image_height
|
current_aspect_ratio = image_width / image_height
|
||||||
|
|
||||||
if not aspect_string or aspect_string.upper() == "EVEN":
|
if not aspect_string or aspect_string.upper() == "EVEN":
|
||||||
# If scaling was even, the correction factor is just the image's aspect ratio
|
# If scaling was even, the correction factor is just the image's aspect ratio
|
||||||
# to make UVs match the image proportions.
|
# 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
|
return current_aspect_ratio
|
||||||
|
|
||||||
# Handle non-uniform scaling cases ("Xnnn", "Ynnn")
|
# 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
|
# Apply the non-uniform correction formula based on original script logic
|
||||||
scaling_factor_percent = amount / 100.0
|
scaling_factor_percent = amount / 100.0
|
||||||
correction_factor = current_aspect_ratio # Default
|
correction_factor = current_aspect_ratio
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if axis == 'X':
|
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}.")
|
print(f" Error calculating aspect correction factor: {e}. Returning current ratio {current_aspect_ratio:.4f}.")
|
||||||
return current_aspect_ratio
|
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
|
return correction_factor
|
||||||
|
|
||||||
|
|
||||||
@ -256,16 +251,14 @@ def reconstruct_image_path_with_fallback(asset_dir_path, asset_name, map_type, r
|
|||||||
if primary_format:
|
if primary_format:
|
||||||
try:
|
try:
|
||||||
filename = IMAGE_FILENAME_PATTERN.format(
|
filename = IMAGE_FILENAME_PATTERN.format(
|
||||||
assetname=asset_name, # Token is 'assetname'
|
assetname=asset_name,
|
||||||
maptype=map_type, # Token is 'maptype'
|
maptype=map_type,
|
||||||
resolution=resolution, # Token is 'resolution'
|
resolution=resolution,
|
||||||
ext=primary_format.lower() # Token is 'ext'
|
ext=primary_format.lower()
|
||||||
)
|
)
|
||||||
primary_path = asset_dir_path / filename
|
primary_path = asset_dir_path / filename
|
||||||
if primary_path.is_file():
|
if primary_path.is_file():
|
||||||
# print(f" Found primary path: {str(primary_path)}") # Verbose
|
|
||||||
return str(primary_path)
|
return str(primary_path)
|
||||||
# else: print(f" Primary path not found: {str(primary_path)}") # Verbose
|
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
print(f" !!! ERROR: Missing key '{e}' in IMAGE_FILENAME_PATTERN. Cannot reconstruct path.")
|
print(f" !!! ERROR: Missing key '{e}' in IMAGE_FILENAME_PATTERN. Cannot reconstruct path.")
|
||||||
return None # Cannot proceed without valid pattern
|
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
|
# Continue to fallback
|
||||||
|
|
||||||
# 2. Try fallback extensions
|
# 2. Try fallback extensions
|
||||||
# print(f" Trying fallback extensions for {map_type}/{resolution}...") # Verbose
|
|
||||||
for ext in FALLBACK_IMAGE_EXTENSIONS:
|
for ext in FALLBACK_IMAGE_EXTENSIONS:
|
||||||
# Skip if we already tried this extension as primary (and it failed)
|
# Skip if we already tried this extension as primary (and it failed)
|
||||||
if primary_format and ext.lower() == primary_format.lower():
|
if primary_format and ext.lower() == primary_format.lower():
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
fallback_filename = IMAGE_FILENAME_PATTERN.format(
|
fallback_filename = IMAGE_FILENAME_PATTERN.format(
|
||||||
assetname=asset_name, # Token is 'assetname'
|
assetname=asset_name,
|
||||||
maptype=map_type, # Token is 'maptype'
|
maptype=map_type,
|
||||||
resolution=resolution, # Token is 'resolution'
|
resolution=resolution,
|
||||||
ext=ext.lower() # Token is 'ext'
|
ext=ext.lower()
|
||||||
)
|
)
|
||||||
fallback_path = asset_dir_path / fallback_filename
|
fallback_path = asset_dir_path / fallback_filename
|
||||||
if fallback_path.is_file():
|
if fallback_path.is_file():
|
||||||
@ -400,13 +392,13 @@ def update_manifest(manifest_data, asset_name, map_type=None, resolution=None):
|
|||||||
|
|
||||||
# --- Core Logic ---
|
# --- 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 ENABLE_MANIFEST # Declare intent to modify global if needed
|
||||||
global PROCESSED_ASSET_LIBRARY_ROOT # Allow modification of global
|
global PROCESSED_ASSET_LIBRARY_ROOT # Allow modification of global
|
||||||
"""Scans the library, reads metadata, creates/updates node groups."""
|
"""Scans the library, reads metadata, creates/updates node groups."""
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
print(f"\n--- Starting Node Group Processing ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---")
|
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 ---
|
# --- Determine Asset Library Root ---
|
||||||
if asset_library_root_override:
|
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("!!! ERROR: Processed asset library root not set in script and not provided via argument.")
|
||||||
print("--- Script aborted. ---")
|
print("--- Script aborted. ---")
|
||||||
return False
|
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 ---
|
# --- Pre-run Checks ---
|
||||||
print("Performing 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
|
valid_setup = False
|
||||||
else:
|
else:
|
||||||
print(f" Asset Library Root: '{root_path}'")
|
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
|
# 2. Check Templates
|
||||||
template_parent = bpy.data.node_groups.get(PARENT_TEMPLATE_NAME)
|
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
|
valid_setup = False
|
||||||
if template_parent and template_child:
|
if template_parent and template_child:
|
||||||
print(f" Found Templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'")
|
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 Parent Found: {template_parent is not None}")
|
||||||
print(f" DEBUG: Template Child Found: {template_child is not None}") # DEBUG LOG (Indented)
|
print(f" DEBUG: Template Child Found: {template_child is not None}")
|
||||||
|
|
||||||
# 3. Check Blend File Saved (if manifest enabled)
|
# 3. Check Blend File Saved (if manifest enabled)
|
||||||
if ENABLE_MANIFEST and not context.blend_data.filepath:
|
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)
|
metadata_files_found = len(metadata_paths)
|
||||||
print(f"Found {metadata_files_found} metadata.json files.")
|
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:
|
if metadata_files_found == 0:
|
||||||
print("No metadata files found. Nothing to process.")
|
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 ---
|
# --- Process Each Metadata File ---
|
||||||
for metadata_path in metadata_paths:
|
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"\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:
|
try:
|
||||||
with open(metadata_path, 'r', encoding='utf-8') as f:
|
with open(metadata_path, 'r', encoding='utf-8') as f:
|
||||||
metadata = json.load(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")
|
asset_name = metadata.get("asset_name")
|
||||||
supplier_name = metadata.get("supplier_name")
|
supplier_name = metadata.get("supplier_name")
|
||||||
archetype = metadata.get("archetype")
|
archetype = metadata.get("archetype")
|
||||||
asset_category = metadata.get("category", "Unknown") # Read "category" key from metadata
|
asset_category = metadata.get("category", "Unknown")
|
||||||
# Get map info from the correct keys
|
|
||||||
processed_resolutions = metadata.get("processed_map_resolutions", {}) # Default to empty dict
|
processed_resolutions = metadata.get("processed_map_resolutions", {}) # Default to empty dict
|
||||||
merged_resolutions = metadata.get("merged_map_resolutions", {}) # Get merged maps too
|
merged_resolutions = metadata.get("merged_map_resolutions", {}) # Get merged maps too
|
||||||
map_details = metadata.get("map_details", {}) # Default to empty dict
|
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
|
errors_encountered += 1
|
||||||
continue
|
continue
|
||||||
# map_details check remains a warning as merged maps won't be in it
|
# 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}")
|
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
|
# Conditional skip based on asset_category
|
||||||
if asset_category not in CATEGORIES_FOR_NODEGROUP_GENERATION: # Check asset_category
|
if asset_category not in CATEGORIES_FOR_NODEGROUP_GENERATION:
|
||||||
print(f" Skipping nodegroup content generation for asset '{asset_name}' (Category: '{asset_category}'). Tag added.") # Use asset_category in log
|
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
|
assets_processed += 1 # Still count as processed for summary, even if skipped
|
||||||
continue # Skip the rest of the processing for this asset
|
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:
|
if parent_group is None:
|
||||||
print(f" Creating new parent group: '{target_parent_name}'")
|
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()
|
parent_group = template_parent.copy()
|
||||||
if not parent_group:
|
if not parent_group:
|
||||||
print(f" !!! ERROR: Failed to copy parent template '{PARENT_TEMPLATE_NAME}'. Skipping asset '{asset_name}'.")
|
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
|
is_new_parent = True
|
||||||
else:
|
else:
|
||||||
print(f" Updating existing parent group: '{target_parent_name}'")
|
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
|
parent_groups_updated += 1
|
||||||
|
|
||||||
# Ensure marked as asset
|
# 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)
|
add_tag_if_new(parent_group.asset_data, supplier_name)
|
||||||
if archetype:
|
if archetype:
|
||||||
add_tag_if_new(parent_group.asset_data, 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_tag_if_new(parent_group.asset_data, asset_category)
|
||||||
# Add other tags if needed
|
# 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
|
# 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
|
aspect_node.outputs[0].default_value = correction_factor
|
||||||
print(f" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f} (was {current_val:.4f})")
|
print(f" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f} (was {current_val:.4f})")
|
||||||
aspect_ratio_set += 1
|
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
|
# Apply Highest Resolution Value
|
||||||
hr_nodes = find_nodes_by_label(parent_group, HIGHEST_RESOLUTION_NODE_LABEL, 'ShaderNodeValue')
|
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
|
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})")
|
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
|
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)
|
# 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')
|
stats_nodes = find_nodes_by_label(parent_group, stats_node_label, 'ShaderNodeCombineXYZ')
|
||||||
if stats_nodes:
|
if stats_nodes:
|
||||||
stats_node = stats_nodes[0]
|
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):
|
if stats and isinstance(stats, dict):
|
||||||
# Handle potential list format for RGB stats (use first value) or direct float
|
# 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:
|
if updated_stat:
|
||||||
print(f" Set stats in '{stats_node_label}': Min={min_val:.4f}, Max={max_val:.4f}, Mean={mean_val:.4f}")
|
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) ---
|
# --- Set Asset Preview (only for new parent groups) ---
|
||||||
# Use the reference image path found earlier if available
|
# 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 ---
|
# --- Child Group Handling ---
|
||||||
# Iterate through the COMBINED map types
|
# 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():
|
for map_type, resolutions in all_map_resolutions.items():
|
||||||
print(f" Processing Map Type: {map_type}")
|
print(f" Processing Map Type: {map_type}")
|
||||||
|
|
||||||
# Determine if this is a merged map (not in map_details)
|
# Determine if this is a merged map (not in map_details)
|
||||||
is_merged_map = map_type 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, {})
|
current_map_details = map_details.get(map_type, {})
|
||||||
# For merged maps, primary_format will be None
|
# For merged maps, primary_format will be None
|
||||||
output_format = current_map_details.get("output_format")
|
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.")
|
print(f" !!! WARNING: No placeholder node labeled '{map_type}' found in parent group '{parent_group.name}'. Skipping this map type.")
|
||||||
continue
|
continue
|
||||||
holder_node = holder_nodes[0] # Assume first is correct
|
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)
|
# Determine child group name (LOGICAL and ENCODED)
|
||||||
logical_child_name = f"{asset_name}_{map_type}"
|
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
|
is_new_child = False
|
||||||
|
|
||||||
if child_group is None:
|
if child_group is None:
|
||||||
print(f" DEBUG: Child group '{target_child_name_b64}' not found. Creating new one.") # DEBUG LOG (Indented)
|
print(f" DEBUG: Child group '{target_child_name_b64}' not found. Creating new one.")
|
||||||
# print(f" Creating new child group: '{target_child_name_b64}' (logical: '{logical_child_name}')") # Verbose
|
|
||||||
child_group = template_child.copy()
|
child_group = template_child.copy()
|
||||||
if not child_group:
|
if not child_group:
|
||||||
print(f" !!! ERROR: Failed to copy child template '{CHILD_TEMPLATE_NAME}'. Skipping map type '{map_type}'.")
|
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
|
child_groups_created += 1
|
||||||
is_new_child = True
|
is_new_child = True
|
||||||
else:
|
else:
|
||||||
print(f" DEBUG: Found existing child group '{target_child_name_b64}'.") # DEBUG LOG (Indented)
|
print(f" DEBUG: Found existing child group '{target_child_name_b64}'.")
|
||||||
# print(f" Updating existing child group: '{target_child_name_b64}'") # Verbose
|
|
||||||
child_groups_updated += 1
|
child_groups_updated += 1
|
||||||
|
|
||||||
# Assign child group to placeholder if needed
|
# 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:
|
if not link_exists:
|
||||||
parent_group.links.new(source_socket, target_socket)
|
parent_group.links.new(source_socket, target_socket)
|
||||||
print(f" Linked '{holder_node.label}' output to parent output socket '{map_type}'.")
|
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:
|
except Exception as e_link:
|
||||||
print(f" !!! ERROR linking sockets for '{map_type}': {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
|
# Defaulting to Color seems reasonable for most PBR outputs
|
||||||
if item.socket_type != 'NodeSocketColor':
|
if item.socket_type != 'NodeSocketColor':
|
||||||
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:
|
except Exception as e_sock_type:
|
||||||
print(f" Warn: Could not verify/set socket type for '{map_type}': {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:
|
for resolution in resolutions:
|
||||||
# --- Manifest Check (Map/Resolution Level) ---
|
# --- Manifest Check (Map/Resolution Level) ---
|
||||||
if ENABLE_MANIFEST and is_map_processed(manifest_data, asset_name, map_type, resolution):
|
if ENABLE_MANIFEST and is_map_processed(manifest_data, asset_name, map_type, resolution):
|
||||||
# print(f" Skipping {resolution} (Manifest)") # Verbose
|
|
||||||
maps_skipped_manifest += 1
|
maps_skipped_manifest += 1
|
||||||
continue
|
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}")
|
print(f" Processing Resolution: {resolution}")
|
||||||
|
|
||||||
@ -888,7 +863,7 @@ def process_library(context, asset_library_root_override=None): # Add override p
|
|||||||
resolution=resolution,
|
resolution=resolution,
|
||||||
primary_format=output_format
|
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:
|
if not image_path_str:
|
||||||
# Error already printed by reconstruct function
|
# 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:
|
if not image_nodes:
|
||||||
print(f" !!! WARNING: No Image Texture node labeled '{resolution}' found in child group '{child_group.name}'. Cannot assign image.")
|
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
|
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 ---
|
# --- Load Image ---
|
||||||
img = None
|
img = None
|
||||||
@ -954,7 +929,6 @@ def process_library(context, asset_library_root_override=None): # Add override p
|
|||||||
# --- Update Manifest (Map/Resolution Level) ---
|
# --- Update Manifest (Map/Resolution Level) ---
|
||||||
if update_manifest(manifest_data, asset_name, map_type, resolution):
|
if update_manifest(manifest_data, asset_name, map_type, resolution):
|
||||||
manifest_needs_saving = True
|
manifest_needs_saving = True
|
||||||
# print(f" Marked {map_type}/{resolution} processed in manifest.") # Verbose
|
|
||||||
maps_processed += 1
|
maps_processed += 1
|
||||||
|
|
||||||
else:
|
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"Individual Maps Processed: {maps_processed}")
|
||||||
print(f"Asset Previews Set: {previews_set}")
|
print(f"Asset Previews Set: {previews_set}")
|
||||||
print(f"Highest Resolution Nodes Set: {highest_res_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:
|
if errors_encountered > 0:
|
||||||
print(f"!!! Errors Encountered: {errors_encountered} !!!")
|
print(f"!!! Errors Encountered: {errors_encountered} !!!")
|
||||||
print("---------------------------")
|
print("---------------------------")
|
||||||
|
|
||||||
# --- Explicit Save ---
|
# --- 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:
|
try:
|
||||||
bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)
|
bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)
|
||||||
print("\n--- Explicitly saved the .blend file. ---")
|
print("\n--- Explicitly saved the .blend file. ---")
|
||||||
@ -1047,7 +1021,6 @@ if __name__ == "__main__":
|
|||||||
print(f"Found asset library root argument: {asset_root_arg}")
|
print(f"Found asset library root argument: {asset_root_arg}")
|
||||||
else:
|
else:
|
||||||
print("Info: '--' found but no arguments after it.")
|
print("Info: '--' found but no arguments after it.")
|
||||||
# else: print("Info: No '--' found in arguments.") # Optional debug
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error parsing command line arguments: {e}")
|
print(f"Error parsing command line arguments: {e}")
|
||||||
# --- End Argument Parsing ---
|
# --- End Argument Parsing ---
|
||||||
|
|||||||
117
configuration.py
117
configuration.py
@ -1,27 +1,20 @@
|
|||||||
# configuration.py
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import logging
|
import logging
|
||||||
import re # Import the regex module
|
import re
|
||||||
import json # Import the json module
|
|
||||||
|
|
||||||
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
|
BASE_DIR = Path(__file__).parent
|
||||||
APP_SETTINGS_PATH = BASE_DIR / "config" / "app_settings.json"
|
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"
|
PRESETS_DIR = BASE_DIR / "Presets"
|
||||||
|
|
||||||
# --- Custom Exception ---
|
|
||||||
class ConfigurationError(Exception):
|
class ConfigurationError(Exception):
|
||||||
"""Custom exception for configuration loading errors."""
|
"""Custom exception for configuration loading errors."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# --- Helper Functions ---
|
|
||||||
def _get_base_map_type(target_map_string: str) -> str:
|
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')."""
|
"""Extracts the base map type (e.g., 'COL') from a potentially numbered string ('COL-1')."""
|
||||||
# Use regex to find the leading alphabetical part
|
# Use regex to find the leading alphabetical part
|
||||||
@ -72,7 +65,6 @@ def _fnmatch_to_regex(pattern: str) -> str:
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
# --- Configuration Class ---
|
|
||||||
class Configuration:
|
class Configuration:
|
||||||
"""
|
"""
|
||||||
Loads and provides access to core settings combined with a specific preset.
|
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}'")
|
log.debug(f"Initializing Configuration with preset: '{preset_name}'")
|
||||||
self.preset_name = preset_name
|
self.preset_name = preset_name
|
||||||
self._core_settings: dict = self._load_core_config()
|
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._preset_settings: dict = self._load_preset(preset_name)
|
||||||
self._validate_configs()
|
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}'")
|
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] = {}
|
self.compiled_bit_depth_regex_map: dict[str, re.Pattern] = {}
|
||||||
# Map: base_map_type -> list of tuples: (compiled_regex, original_keyword, rule_index)
|
# 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]]] = {}
|
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:
|
for pattern in self.move_to_extra_patterns:
|
||||||
try:
|
try:
|
||||||
# Use the raw fnmatch pattern directly if it's simple enough for re.search
|
regex_str = _fnmatch_to_regex(pattern)
|
||||||
# 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
|
|
||||||
self.compiled_extra_regex.append(re.compile(regex_str, re.IGNORECASE))
|
self.compiled_extra_regex.append(re.compile(regex_str, re.IGNORECASE))
|
||||||
except re.error as e:
|
except re.error as e:
|
||||||
log.warning(f"Failed to compile 'extra' regex pattern '{pattern}': {e}. Skipping pattern.")
|
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', [])
|
model_patterns = self.asset_category_rules.get('model_patterns', [])
|
||||||
for pattern in model_patterns:
|
for pattern in model_patterns:
|
||||||
try:
|
try:
|
||||||
@ -128,33 +113,23 @@ class Configuration:
|
|||||||
except re.error as e:
|
except re.error as e:
|
||||||
log.warning(f"Failed to compile 'model' regex pattern '{pattern}': {e}. Skipping pattern.")
|
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():
|
for map_type, pattern in self.source_bit_depth_variants.items():
|
||||||
try:
|
try:
|
||||||
# These often rely on specific suffixes, so anchoring might be better?
|
regex_str = _fnmatch_to_regex(pattern)
|
||||||
# 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
|
|
||||||
if pattern.endswith('*'):
|
if pattern.endswith('*'):
|
||||||
regex_str = regex_str.removesuffix('.*') # e.g., ".*_DISP16"
|
regex_str = regex_str.removesuffix('.*')
|
||||||
# Fallback for < 3.9: if regex_str.endswith('.*'): regex_str = regex_str[:-2]
|
|
||||||
|
|
||||||
# Use the fnmatch-converted regex directly, allowing matches anywhere in the filename
|
final_regex_str = regex_str
|
||||||
# This is less strict than anchoring to the end with \\.[^.]+$
|
self.compiled_bit_depth_regex_map[map_type] = re.compile(final_regex_str, re.IGNORECASE)
|
||||||
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
|
|
||||||
log.debug(f" Compiled bit depth variant for '{map_type}' as regex (IGNORECASE): {final_regex_str}")
|
log.debug(f" Compiled bit depth variant for '{map_type}' as regex (IGNORECASE): {final_regex_str}")
|
||||||
except re.error as e:
|
except re.error as e:
|
||||||
log.warning(f"Failed to compile 'bit depth' regex pattern '{pattern}' for map type '{map_type}': {e}. Skipping pattern.")
|
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)
|
||||||
separator = re.escape(self.source_naming_separator) # Escape separator for regex
|
|
||||||
# Use defaultdict to easily append to lists for the same base type
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
temp_compiled_map_regex = defaultdict(list)
|
temp_compiled_map_regex = defaultdict(list)
|
||||||
|
|
||||||
for rule_index, mapping_rule in enumerate(self.map_type_mapping):
|
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 \
|
if not isinstance(mapping_rule, dict) or \
|
||||||
'target_type' not in mapping_rule or \
|
'target_type' not in mapping_rule or \
|
||||||
'keywords' 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.")
|
log.warning(f"Skipping invalid map_type_mapping rule at index {rule_index}: {mapping_rule}. Expected dict with 'target_type' and 'keywords' list.")
|
||||||
continue
|
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']
|
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:
|
for keyword in source_keywords:
|
||||||
if not isinstance(keyword, str):
|
if not isinstance(keyword, str):
|
||||||
log.warning(f"Skipping non-string keyword '{keyword}' in rule {rule_index} for target '{target_type}'.")
|
log.warning(f"Skipping non-string keyword '{keyword}' in rule {rule_index} for target '{target_type}'.")
|
||||||
continue
|
continue
|
||||||
try:
|
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)
|
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})"
|
regex_str = rf"(?:^|{separator})({kw_regex_part})(?:$|{separator})"
|
||||||
compiled_regex = re.compile(regex_str, re.IGNORECASE)
|
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))
|
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}")
|
log.debug(f" Compiled keyword '{keyword}' (rule {rule_index}) for target '{target_type}' as regex: {regex_str}")
|
||||||
except re.error as e:
|
except re.error as e:
|
||||||
log.warning(f"Failed to compile map keyword regex '{keyword}' for target type '{target_type}': {e}. Skipping keyword.")
|
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)
|
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())}")
|
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():
|
if not LLM_SETTINGS_PATH.is_file():
|
||||||
# Log a warning but don't raise an error, allow fallback if possible
|
# 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.")
|
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:
|
try:
|
||||||
with open(LLM_SETTINGS_PATH, 'r', encoding='utf-8') as f:
|
with open(LLM_SETTINGS_PATH, 'r', encoding='utf-8') as f:
|
||||||
settings = json.load(f)
|
settings = json.load(f)
|
||||||
@ -225,10 +189,10 @@ class Configuration:
|
|||||||
return settings
|
return settings
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
log.error(f"Failed to parse LLM configuration file {LLM_SETTINGS_PATH}: Invalid JSON - {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:
|
except Exception as e:
|
||||||
log.error(f"Failed to read LLM configuration file {LLM_SETTINGS_PATH}: {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:
|
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):
|
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.")
|
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()
|
valid_file_type_keys = self._core_settings.get('FILE_TYPE_DEFINITIONS', {}).keys()
|
||||||
if rule['target_type'] not in valid_file_type_keys:
|
if rule['target_type'] not in valid_file_type_keys:
|
||||||
raise ConfigurationError(
|
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.")
|
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):
|
if not isinstance(self._core_settings.get('TARGET_FILENAME_PATTERN'), str):
|
||||||
raise ConfigurationError("Core config 'TARGET_FILENAME_PATTERN' must be a string.")
|
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):
|
if not isinstance(self._core_settings.get('OUTPUT_DIRECTORY_PATTERN'), str):
|
||||||
raise ConfigurationError("Core config 'OUTPUT_DIRECTORY_PATTERN' must be a string.")
|
raise ConfigurationError("Core config 'OUTPUT_DIRECTORY_PATTERN' must be a string.")
|
||||||
if not isinstance(self._core_settings.get('OUTPUT_FILENAME_PATTERN'), str):
|
if not isinstance(self._core_settings.get('OUTPUT_FILENAME_PATTERN'), str):
|
||||||
raise ConfigurationError("Core config 'OUTPUT_FILENAME_PATTERN' must be a string.")
|
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):
|
if not isinstance(self._core_settings.get('IMAGE_RESOLUTIONS'), dict):
|
||||||
raise ConfigurationError("Core config 'IMAGE_RESOLUTIONS' must be a dictionary.")
|
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)}."
|
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:
|
||||||
if self._llm_settings: # Only validate if LLM settings were loaded
|
required_llm_keys = [
|
||||||
required_llm_keys = [ # Indent this block
|
|
||||||
"llm_predictor_examples", "llm_endpoint_url", "llm_api_key",
|
"llm_predictor_examples", "llm_endpoint_url", "llm_api_key",
|
||||||
"llm_model_name", "llm_temperature", "llm_request_timeout",
|
"llm_model_name", "llm_temperature", "llm_request_timeout",
|
||||||
"llm_predictor_prompt"
|
"llm_predictor_prompt"
|
||||||
]
|
]
|
||||||
for key in required_llm_keys: # Indent this block
|
for key in required_llm_keys:
|
||||||
if key not in self._llm_settings: # Indent this block
|
if key not in self._llm_settings:
|
||||||
# Log warning instead of raising error to allow partial functionality
|
# 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
|
log.warning(f"LLM config is missing recommended key: '{key}'. LLM features might not work correctly.")
|
||||||
# raise ConfigurationError(f"LLM config is missing required key: '{key}'.") # Indent this block
|
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
|
@property
|
||||||
def supplier_name(self) -> str:
|
def supplier_name(self) -> str:
|
||||||
@ -344,7 +297,7 @@ class Configuration:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def target_filename_pattern(self) -> str:
|
def target_filename_pattern(self) -> str:
|
||||||
return self._core_settings['TARGET_FILENAME_PATTERN'] # Assumes validation passed
|
return self._core_settings['TARGET_FILENAME_PATTERN']
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def output_directory_pattern(self) -> str:
|
def output_directory_pattern(self) -> str:
|
||||||
@ -362,7 +315,7 @@ class Configuration:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def image_resolutions(self) -> dict[str, int]:
|
def image_resolutions(self) -> dict[str, int]:
|
||||||
return self._core_settings['IMAGE_RESOLUTIONS'] # Assumes validation passed
|
return self._core_settings['IMAGE_RESOLUTIONS']
|
||||||
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -424,7 +377,7 @@ class Configuration:
|
|||||||
@property
|
@property
|
||||||
def jpg_quality(self) -> int:
|
def jpg_quality(self) -> int:
|
||||||
"""Gets the configured JPG quality level."""
|
"""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
|
@property
|
||||||
def resolution_threshold_for_jpg(self) -> int:
|
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
|
# 2. Try to derive base FTD key by stripping common variant suffixes
|
||||||
# Regex to remove trailing suffixes like -<digits>, -<alphanum>, _<alphanum>
|
# Regex to remove trailing suffixes like -<digits>, -<alphanum>, _<alphanum>
|
||||||
base_ftd_key_candidate = re.sub(r"(-[\w\d]+|_[\w\d]+)$", "", map_type_input)
|
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)
|
definition = file_type_definitions.get(base_ftd_key_candidate)
|
||||||
if definition:
|
if definition:
|
||||||
rule = definition.get('bit_depth_rule')
|
rule = definition.get('bit_depth_rule')
|
||||||
@ -501,13 +454,10 @@ class Configuration:
|
|||||||
for _key, definition in file_type_definitions.items():
|
for _key, definition in file_type_definitions.items():
|
||||||
if isinstance(definition, dict):
|
if isinstance(definition, dict):
|
||||||
standard_type = definition.get('standard_type')
|
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():
|
if standard_type and isinstance(standard_type, str) and standard_type.strip():
|
||||||
aliases.add(standard_type)
|
aliases.add(standard_type)
|
||||||
return sorted(list(aliases))
|
return sorted(list(aliases))
|
||||||
|
|
||||||
# --- LLM Prompt Data Accessors ---
|
|
||||||
|
|
||||||
def get_asset_type_definitions(self) -> dict:
|
def get_asset_type_definitions(self) -> dict:
|
||||||
"""Returns the ASSET_TYPE_DEFINITIONS dictionary from core settings."""
|
"""Returns the ASSET_TYPE_DEFINITIONS dictionary from core settings."""
|
||||||
return self._core_settings.get('ASSET_TYPE_DEFINITIONS', {})
|
return self._core_settings.get('ASSET_TYPE_DEFINITIONS', {})
|
||||||
@ -532,7 +482,7 @@ class Configuration:
|
|||||||
@property
|
@property
|
||||||
def llm_predictor_prompt(self) -> str:
|
def llm_predictor_prompt(self) -> str:
|
||||||
"""Returns the LLM predictor prompt string from LLM settings."""
|
"""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
|
@property
|
||||||
def llm_endpoint_url(self) -> str:
|
def llm_endpoint_url(self) -> str:
|
||||||
@ -552,12 +502,12 @@ class Configuration:
|
|||||||
@property
|
@property
|
||||||
def llm_temperature(self) -> float:
|
def llm_temperature(self) -> float:
|
||||||
"""Returns the LLM temperature from LLM settings."""
|
"""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
|
@property
|
||||||
def llm_request_timeout(self) -> int:
|
def llm_request_timeout(self) -> int:
|
||||||
"""Returns the LLM request timeout in seconds from LLM settings."""
|
"""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
|
@property
|
||||||
def keybind_config(self) -> dict[str, list[str]]:
|
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.
|
# For now, we rely on the order they appear in the config.
|
||||||
return keybinds
|
return keybinds
|
||||||
|
|
||||||
# --- Standalone Base Config Functions ---
|
|
||||||
|
|
||||||
def load_base_config() -> dict:
|
def load_base_config() -> dict:
|
||||||
"""
|
"""
|
||||||
Loads only the base configuration from app_settings.json.
|
Loads only the base configuration from app_settings.json.
|
||||||
Does not load presets or perform merging/validation.
|
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():
|
if not APP_SETTINGS_PATH.is_file():
|
||||||
log.error(f"Base configuration file not found: {APP_SETTINGS_PATH}")
|
log.error(f"Base configuration file not found: {APP_SETTINGS_PATH}")
|
||||||
# Return empty dict or raise a specific error if preferred
|
# Return empty dict or raise a specific error if preferred
|
||||||
@ -598,14 +545,13 @@ def load_base_config() -> dict:
|
|||||||
try:
|
try:
|
||||||
with open(APP_SETTINGS_PATH, 'r', encoding='utf-8') as f:
|
with open(APP_SETTINGS_PATH, 'r', encoding='utf-8') as f:
|
||||||
settings = json.load(f)
|
settings = json.load(f)
|
||||||
#log.debug(f"Base config loaded successfully.")
|
|
||||||
return settings
|
return settings
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
log.error(f"Failed to parse base configuration file {APP_SETTINGS_PATH}: Invalid JSON - {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:
|
except Exception as e:
|
||||||
log.error(f"Failed to read base configuration file {APP_SETTINGS_PATH}: {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):
|
def save_llm_config(settings_dict: dict):
|
||||||
"""
|
"""
|
||||||
@ -615,7 +561,8 @@ def save_llm_config(settings_dict: dict):
|
|||||||
try:
|
try:
|
||||||
with open(LLM_SETTINGS_PATH, 'w', encoding='utf-8') as f:
|
with open(LLM_SETTINGS_PATH, 'w', encoding='utf-8') as f:
|
||||||
json.dump(settings_dict, f, indent=4)
|
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:
|
except Exception as e:
|
||||||
log.error(f"Failed to save LLM configuration file {LLM_SETTINGS_PATH}: {e}")
|
log.error(f"Failed to save LLM configuration file {LLM_SETTINGS_PATH}: {e}")
|
||||||
# Re-raise as ConfigurationError to signal failure upstream
|
# Re-raise as ConfigurationError to signal failure upstream
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import logging
|
|||||||
from PySide6.QtCore import QObject, Slot, QModelIndex
|
from PySide6.QtCore import QObject, Slot, QModelIndex
|
||||||
from PySide6.QtGui import QColor # Might be needed if copying logic directly, though unlikely now
|
from PySide6.QtGui import QColor # Might be needed if copying logic directly, though unlikely now
|
||||||
from pathlib import Path
|
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
|
from rule_structure import SourceRule, AssetRule, FileRule
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@ -25,7 +25,7 @@ class AssetRestructureHandler(QObject):
|
|||||||
log.debug("AssetRestructureHandler initialized.")
|
log.debug("AssetRestructureHandler initialized.")
|
||||||
|
|
||||||
@Slot(FileRule, str, QModelIndex)
|
@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.
|
Slot connected to UnifiedViewModel.targetAssetOverrideChanged.
|
||||||
Orchestrates model changes based on the new target asset path.
|
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).
|
new_target_name: The new target asset path (string).
|
||||||
index: The QModelIndex of the changed item (passed by the signal).
|
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.")
|
log.warning(f"Handler received targetAssetOverrideChanged for non-FileRule item: {type(file_rule_item)}. Aborting.")
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -47,14 +47,12 @@ class AssetRestructureHandler(QObject):
|
|||||||
if effective_new_target_name == "": effective_new_target_name = None # Treat empty string as None
|
if effective_new_target_name == "": effective_new_target_name = None # Treat empty string as None
|
||||||
|
|
||||||
# --- Get necessary context ---
|
# --- Get necessary context ---
|
||||||
# Use file_rule_item directly
|
|
||||||
old_parent_asset = getattr(file_rule_item, 'parent_asset', None)
|
old_parent_asset = getattr(file_rule_item, 'parent_asset', None)
|
||||||
if not old_parent_asset:
|
if not old_parent_asset:
|
||||||
log.error(f"Handler: File item '{Path(file_rule_item.file_path).name}' has no parent asset. Cannot restructure.")
|
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.
|
# Note: Data change already happened in setData, cannot easily revert here.
|
||||||
return
|
return
|
||||||
|
|
||||||
# Use file_rule_item directly
|
|
||||||
source_rule = getattr(old_parent_asset, 'parent_source', None)
|
source_rule = getattr(old_parent_asset, 'parent_source', None)
|
||||||
if not source_rule:
|
if not source_rule:
|
||||||
log.error(f"Handler: Could not find SourceRule for parent asset '{old_parent_asset.asset_name}'. Cannot restructure.")
|
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:
|
except ValueError:
|
||||||
log.error(f"Handler: Could not find SourceRule index while looking for target parent '{effective_new_target_name}'.")
|
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
|
target_parent_asset = None # Reset if index is invalid
|
||||||
break # Found the asset
|
break
|
||||||
|
|
||||||
# 2. Handle Move or Creation
|
# 2. Handle Move or Creation
|
||||||
if target_parent_asset: # An existing AssetRule to move to was found
|
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.
|
# 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.
|
# 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.
|
# 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
|
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.")
|
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_asset = new_asset_qmodelindex.internalPointer() # Get the newly created AssetRule object
|
||||||
target_parent_index = new_asset_qmodelindex # The QModelIndex of the new AssetRule
|
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
|
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.")
|
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
|
self.model.removeAssetRule(target_parent_asset) # Attempt to clean up newly created asset
|
||||||
@ -185,8 +183,8 @@ class AssetRestructureHandler(QObject):
|
|||||||
return QModelIndex()
|
return QModelIndex()
|
||||||
return QModelIndex()
|
return QModelIndex()
|
||||||
|
|
||||||
@Slot(AssetRule, str, QModelIndex) # Updated signature
|
@Slot(AssetRule, str, QModelIndex)
|
||||||
def handle_asset_name_changed(self, asset_rule_item: AssetRule, new_name: str, index: QModelIndex): # Ensure AssetRule is imported
|
def handle_asset_name_changed(self, asset_rule_item: AssetRule, new_name: str, index: QModelIndex):
|
||||||
"""
|
"""
|
||||||
Slot connected to UnifiedViewModel.assetNameChanged.
|
Slot connected to UnifiedViewModel.assetNameChanged.
|
||||||
Handles logic when an AssetRule's name is changed.
|
Handles logic when an AssetRule's name is changed.
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# gui/base_prediction_handler.py
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
@ -16,7 +15,7 @@ except ImportError:
|
|||||||
class SourceRule: pass
|
class SourceRule: pass
|
||||||
|
|
||||||
from abc import ABCMeta
|
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
|
# Combine metaclasses to avoid conflict between QObject and ABC
|
||||||
class QtABCMeta(type(QObject), ABCMeta):
|
class QtABCMeta(type(QObject), ABCMeta):
|
||||||
@ -52,7 +51,7 @@ class BasePredictionHandler(QObject, ABC, metaclass=QtABCMeta):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.input_source_identifier = input_source_identifier
|
self.input_source_identifier = input_source_identifier
|
||||||
self._is_running = False
|
self._is_running = False
|
||||||
self._is_cancelled = False # Added cancellation flag
|
self._is_cancelled = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_running(self) -> bool:
|
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.
|
Main execution slot intended to be connected to QThread.started.
|
||||||
Handles the overall process: setup, execution, error handling, signaling.
|
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:
|
if self._is_running:
|
||||||
log.warning(f"Handler for '{self.input_source_identifier}' is already running. Aborting.")
|
log.warning(f"Handler for '{self.input_source_identifier}' is already running. Aborting.")
|
||||||
return
|
return
|
||||||
@ -75,7 +74,7 @@ class BasePredictionHandler(QObject, ABC, metaclass=QtABCMeta):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._is_running = True
|
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
|
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}")
|
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}'...")
|
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}"
|
error_msg = f"Error analyzing '{Path(self.input_source_identifier).name}': {e}"
|
||||||
self.prediction_error.emit(self.input_source_identifier, error_msg)
|
self.prediction_error.emit(self.input_source_identifier, error_msg)
|
||||||
# Status update might be redundant if error is shown elsewhere, but can be useful
|
# 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:
|
finally:
|
||||||
# --- Cleanup ---
|
# --- Cleanup ---
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# gui/config_editor_dialog.py
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
@ -7,14 +6,13 @@ from PySide6.QtWidgets import (
|
|||||||
QPushButton, QFileDialog, QLabel, QTableWidget,
|
QPushButton, QFileDialog, QLabel, QTableWidget,
|
||||||
QTableWidgetItem, QDialogButtonBox, QMessageBox, QListWidget,
|
QTableWidgetItem, QDialogButtonBox, QMessageBox, QListWidget,
|
||||||
QListWidgetItem, QFormLayout, QGroupBox, QStackedWidget,
|
QListWidgetItem, QFormLayout, QGroupBox, QStackedWidget,
|
||||||
QHeaderView, QSizePolicy # Added QHeaderView and QSizePolicy
|
QHeaderView, QSizePolicy
|
||||||
)
|
)
|
||||||
from PySide6.QtGui import QColor, QPainter
|
from PySide6.QtGui import QColor, QPainter
|
||||||
from PySide6.QtCore import Qt, QEvent
|
from PySide6.QtCore import Qt, QEvent
|
||||||
from PySide6.QtWidgets import QColorDialog, QStyledItemDelegate, QApplication
|
from PySide6.QtWidgets import QColorDialog, QStyledItemDelegate, QApplication
|
||||||
|
|
||||||
# Assuming configuration.py is in the parent directory or accessible
|
# Assuming configuration.py is in the parent directory or accessible
|
||||||
# Adjust import path if necessary
|
|
||||||
try:
|
try:
|
||||||
from configuration import load_base_config, save_base_config
|
from configuration import load_base_config, save_base_config
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@ -30,7 +28,6 @@ class ColorDelegate(QStyledItemDelegate):
|
|||||||
if isinstance(color_str, str) and color_str.startswith('#'):
|
if isinstance(color_str, str) and color_str.startswith('#'):
|
||||||
color = QColor(color_str)
|
color = QColor(color_str)
|
||||||
if color.isValid():
|
if color.isValid():
|
||||||
# Fill the background with the color
|
|
||||||
painter.fillRect(option.rect, color)
|
painter.fillRect(option.rect, color)
|
||||||
# Optionally draw text (e.g., the hex code) centered
|
# Optionally draw text (e.g., the hex code) centered
|
||||||
# painter.drawText(option.rect, Qt.AlignCenter, color_str)
|
# 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_edit)
|
||||||
output_dir_layout.addWidget(output_dir_button)
|
output_dir_layout.addWidget(output_dir_button)
|
||||||
form_layout.addRow(output_dir_label, output_dir_layout)
|
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
|
# 2. EXTRA_FILES_SUBDIR: QLineEdit
|
||||||
extra_subdir_label = QLabel("Subdirectory for Extra Files:")
|
extra_subdir_label = QLabel("Subdirectory for Extra Files:")
|
||||||
extra_subdir_edit = QLineEdit()
|
extra_subdir_edit = QLineEdit()
|
||||||
form_layout.addRow(extra_subdir_label, extra_subdir_edit)
|
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
|
# 3. METADATA_FILENAME: QLineEdit
|
||||||
metadata_label = QLabel("Metadata Filename:")
|
metadata_label = QLabel("Metadata Filename:")
|
||||||
metadata_edit = QLineEdit()
|
metadata_edit = QLineEdit()
|
||||||
form_layout.addRow(metadata_label, metadata_edit)
|
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.addLayout(form_layout)
|
||||||
layout.addStretch() # Keep stretch at the end
|
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("RESPECT_VARIANT_MAP_TYPES", None)
|
||||||
self.widgets.pop("ASPECT_RATIO_DECIMALS", None)
|
self.widgets.pop("ASPECT_RATIO_DECIMALS", None)
|
||||||
|
|
||||||
# Main layout for this tab
|
|
||||||
main_tab_layout = QVBoxLayout()
|
main_tab_layout = QVBoxLayout()
|
||||||
|
|
||||||
# Form layout for simple input fields
|
|
||||||
form_layout = QFormLayout()
|
form_layout = QFormLayout()
|
||||||
|
|
||||||
# 1. TARGET_FILENAME_PATTERN: QLineEdit
|
# 1. TARGET_FILENAME_PATTERN: QLineEdit
|
||||||
@ -276,7 +271,6 @@ class ConfigEditorDialog(QDialog):
|
|||||||
|
|
||||||
main_tab_layout.addLayout(form_layout)
|
main_tab_layout.addLayout(form_layout)
|
||||||
|
|
||||||
# Add the main layout to the tab's provided layout
|
|
||||||
layout.addLayout(main_tab_layout)
|
layout.addLayout(main_tab_layout)
|
||||||
layout.addStretch() # Keep stretch at the end of the tab's main 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:
|
for key in keys_to_clear:
|
||||||
self.widgets.pop(key, None)
|
self.widgets.pop(key, None)
|
||||||
|
|
||||||
# Main layout for this tab
|
|
||||||
main_tab_layout = QVBoxLayout()
|
main_tab_layout = QVBoxLayout()
|
||||||
|
|
||||||
# --- IMAGE_RESOLUTIONS Section ---
|
# --- IMAGE_RESOLUTIONS Section ---
|
||||||
@ -332,7 +325,7 @@ class ConfigEditorDialog(QDialog):
|
|||||||
# TODO: Implement custom delegate for "Resolution (px)" column
|
# TODO: Implement custom delegate for "Resolution (px)" column
|
||||||
# TODO: Connect add/remove buttons signals
|
# TODO: Connect add/remove buttons signals
|
||||||
resolutions_layout.addWidget(resolutions_table)
|
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()
|
resolutions_button_layout = QHBoxLayout()
|
||||||
add_res_button = QPushButton("Add Row")
|
add_res_button = QPushButton("Add Row")
|
||||||
@ -398,7 +391,6 @@ class ConfigEditorDialog(QDialog):
|
|||||||
|
|
||||||
main_tab_layout.addLayout(form_layout)
|
main_tab_layout.addLayout(form_layout)
|
||||||
|
|
||||||
# Add the main layout to the tab's provided layout
|
|
||||||
layout.addLayout(main_tab_layout)
|
layout.addLayout(main_tab_layout)
|
||||||
layout.addStretch() # Keep stretch at the end of the tab's main 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)
|
self.widgets.pop("MAP_BIT_DEPTH_RULES_TABLE", None)
|
||||||
|
|
||||||
|
|
||||||
# Overall QVBoxLayout for the "Definitions" tab
|
|
||||||
overall_layout = QVBoxLayout()
|
overall_layout = QVBoxLayout()
|
||||||
|
|
||||||
# --- Top Widget: DEFAULT_ASSET_CATEGORY ---
|
# --- Top Widget: DEFAULT_ASSET_CATEGORY ---
|
||||||
@ -527,7 +518,6 @@ class ConfigEditorDialog(QDialog):
|
|||||||
file_types_button_layout.addStretch()
|
file_types_button_layout.addStretch()
|
||||||
file_types_layout.addLayout(file_types_button_layout)
|
file_types_layout.addLayout(file_types_button_layout)
|
||||||
|
|
||||||
# Add the overall layout to the main tab layout provided
|
|
||||||
layout.addLayout(overall_layout)
|
layout.addLayout(overall_layout)
|
||||||
layout.addStretch() # Keep stretch at the end of the tab's main 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_widget)
|
||||||
nodegroup_layout.addWidget(nodegroup_button)
|
nodegroup_layout.addWidget(nodegroup_button)
|
||||||
form_layout.addRow(nodegroup_label, nodegroup_layout)
|
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
|
# 2. DEFAULT_MATERIALS_BLEND_PATH: QLineEdit + QPushButton
|
||||||
materials_label = QLabel("Default Materials Library (.blend):")
|
materials_label = QLabel("Default Materials Library (.blend):")
|
||||||
@ -655,7 +645,7 @@ class ConfigEditorDialog(QDialog):
|
|||||||
materials_layout.addWidget(materials_widget)
|
materials_layout.addWidget(materials_widget)
|
||||||
materials_layout.addWidget(materials_button)
|
materials_layout.addWidget(materials_button)
|
||||||
form_layout.addRow(materials_label, materials_layout)
|
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
|
# 3. BLENDER_EXECUTABLE_PATH: QLineEdit + QPushButton
|
||||||
blender_label = QLabel("Blender Executable Path:")
|
blender_label = QLabel("Blender Executable Path:")
|
||||||
@ -668,7 +658,7 @@ class ConfigEditorDialog(QDialog):
|
|||||||
blender_layout.addWidget(blender_widget)
|
blender_layout.addWidget(blender_widget)
|
||||||
blender_layout.addWidget(blender_button)
|
blender_layout.addWidget(blender_button)
|
||||||
form_layout.addRow(blender_label, blender_layout)
|
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.addLayout(form_layout)
|
||||||
layout.addStretch()
|
layout.addStretch()
|
||||||
@ -686,7 +676,7 @@ class ConfigEditorDialog(QDialog):
|
|||||||
# TODO: Implement custom delegate for "Examples" column (QLineEdit)
|
# TODO: Implement custom delegate for "Examples" column (QLineEdit)
|
||||||
|
|
||||||
layout.addWidget(table)
|
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):
|
def create_file_type_definitions_table_widget(self, layout, definitions_data):
|
||||||
"""Creates a QTableWidget for editing file type definitions."""
|
"""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)
|
# TODO: Implement custom delegate for "Bit Depth Rule" column (QComboBox)
|
||||||
|
|
||||||
layout.addWidget(table)
|
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):
|
def create_image_resolutions_table_widget(self, layout, resolutions_data):
|
||||||
"""Creates a QTableWidget for editing image resolutions."""
|
"""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)
|
# TODO: Implement custom delegate for "Resolution (px)" column (e.g., QLineEdit with validation or two SpinBoxes)
|
||||||
|
|
||||||
layout.addWidget(table)
|
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):
|
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)."""
|
"""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)
|
# TODO: Implement custom delegate for "Rule" column (QComboBox)
|
||||||
|
|
||||||
layout.addWidget(table)
|
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):
|
def create_map_merge_rules_widget(self, layout, rules_data):
|
||||||
@ -803,7 +793,7 @@ class ConfigEditorDialog(QDialog):
|
|||||||
|
|
||||||
group_layout.addWidget(input_table)
|
group_layout.addWidget(input_table)
|
||||||
self.merge_rule_details_layout.addRow(group)
|
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)".
|
# 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)
|
group_layout.addWidget(defaults_table)
|
||||||
self.merge_rule_details_layout.addRow(group)
|
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".
|
# output_bit_depth: QComboBox (Options: "respect_inputs", "force_8bit", "force_16bit"). Label: "Output Bit Depth".
|
||||||
@ -1175,18 +1165,6 @@ class ConfigEditorDialog(QDialog):
|
|||||||
row += 1
|
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)
|
# Example usage (for testing the dialog independently)
|
||||||
|
|||||||
@ -1,41 +1,34 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
# gui/delegates.py
|
|
||||||
from PySide6.QtWidgets import QStyledItemDelegate, QLineEdit, QComboBox
|
from PySide6.QtWidgets import QStyledItemDelegate, QLineEdit, QComboBox
|
||||||
from PySide6.QtCore import Qt, QModelIndex
|
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 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 json
|
||||||
import logging
|
import logging
|
||||||
import os # Added for path manipulation if needed, though json.dump handles creation
|
import os
|
||||||
from PySide6.QtWidgets import QCompleter # Added QCompleter
|
from PySide6.QtWidgets import QCompleter
|
||||||
|
|
||||||
# Configure logging
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
SUPPLIERS_CONFIG_PATH = "config/suppliers.json"
|
SUPPLIERS_CONFIG_PATH = "config/suppliers.json"
|
||||||
|
|
||||||
class LineEditDelegate(QStyledItemDelegate):
|
class LineEditDelegate(QStyledItemDelegate):
|
||||||
"""Delegate for editing string values using a QLineEdit."""
|
"""Delegate for editing string values using a QLineEdit."""
|
||||||
def createEditor(self, parent, option, index):
|
def createEditor(self, parent, option, index):
|
||||||
# Creates the QLineEdit editor widget used for editing.
|
|
||||||
editor = QLineEdit(parent)
|
editor = QLineEdit(parent)
|
||||||
return editor
|
return editor
|
||||||
|
|
||||||
def setEditorData(self, editor: QLineEdit, index: QModelIndex):
|
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.
|
# Use EditRole to get the raw data suitable for editing.
|
||||||
value = index.model().data(index, Qt.EditRole)
|
value = index.model().data(index, Qt.EditRole)
|
||||||
editor.setText(str(value) if value is not None else "")
|
editor.setText(str(value) if value is not None else "")
|
||||||
|
|
||||||
def setModelData(self, editor: QLineEdit, model, index: QModelIndex):
|
def setModelData(self, editor: QLineEdit, model, index: QModelIndex):
|
||||||
# Commits the editor's data back to the model.
|
|
||||||
value = editor.text()
|
value = editor.text()
|
||||||
# Pass the potentially modified text back to the model's setData.
|
# Pass the potentially modified text back to the model's setData.
|
||||||
model.setData(index, value, Qt.EditRole)
|
model.setData(index, value, Qt.EditRole)
|
||||||
|
|
||||||
def updateEditorGeometry(self, editor, option, index):
|
def updateEditorGeometry(self, editor, option, index):
|
||||||
# Ensures the editor widget is placed correctly within the cell.
|
|
||||||
editor.setGeometry(option.rect)
|
editor.setGeometry(option.rect)
|
||||||
|
|
||||||
|
|
||||||
@ -51,10 +44,9 @@ class ComboBoxDelegate(QStyledItemDelegate):
|
|||||||
# REMOVED self.main_window store
|
# REMOVED self.main_window store
|
||||||
|
|
||||||
def createEditor(self, parent, option, index: QModelIndex):
|
def createEditor(self, parent, option, index: QModelIndex):
|
||||||
# Creates the QComboBox editor widget.
|
|
||||||
editor = QComboBox(parent)
|
editor = QComboBox(parent)
|
||||||
column = index.column()
|
column = index.column()
|
||||||
model = index.model() # GET model from index
|
model = index.model()
|
||||||
|
|
||||||
# Add a "clear" option first, associating None with it.
|
# Add a "clear" option first, associating None with it.
|
||||||
editor.addItem("---", None) # UserData = None
|
editor.addItem("---", None) # UserData = None
|
||||||
@ -74,8 +66,6 @@ class ComboBoxDelegate(QStyledItemDelegate):
|
|||||||
items_keys = model._asset_type_keys # Use cached keys
|
items_keys = model._asset_type_keys # Use cached keys
|
||||||
elif column == COL_ITEM_TYPE:
|
elif column == COL_ITEM_TYPE:
|
||||||
items_keys = model._file_type_keys # Use cached keys
|
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:
|
except Exception as e:
|
||||||
log.error(f"Error getting keys from UnifiedViewModel in ComboBoxDelegate: {e}")
|
log.error(f"Error getting keys from UnifiedViewModel in ComboBoxDelegate: {e}")
|
||||||
@ -98,7 +88,6 @@ class ComboBoxDelegate(QStyledItemDelegate):
|
|||||||
return editor
|
return editor
|
||||||
|
|
||||||
def setEditorData(self, editor: QComboBox, index: QModelIndex):
|
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.
|
# 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
|
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):
|
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.
|
# Get the UserData associated with the currently selected item.
|
||||||
# This will be the string value or None (for the "---" option).
|
# This will be the string value or None (for the "---" option).
|
||||||
value = editor.currentData() # This is either the string or None
|
value = editor.currentData() # This is either the string or None
|
||||||
@ -123,7 +111,6 @@ class ComboBoxDelegate(QStyledItemDelegate):
|
|||||||
model.setData(index, value, Qt.EditRole)
|
model.setData(index, value, Qt.EditRole)
|
||||||
|
|
||||||
def updateEditorGeometry(self, editor, option, index):
|
def updateEditorGeometry(self, editor, option, index):
|
||||||
# Ensures the editor widget is placed correctly within the cell.
|
|
||||||
editor.setGeometry(option.rect)
|
editor.setGeometry(option.rect)
|
||||||
|
|
||||||
class SupplierSearchDelegate(QStyledItemDelegate):
|
class SupplierSearchDelegate(QStyledItemDelegate):
|
||||||
|
|||||||
@ -9,7 +9,7 @@ from PySide6.QtWidgets import (
|
|||||||
from PySide6.QtCore import Slot as pyqtSlot, Signal as pyqtSignal # Use PySide6 equivalents
|
from PySide6.QtCore import Slot as pyqtSlot, Signal as pyqtSignal # Use PySide6 equivalents
|
||||||
|
|
||||||
# Assuming configuration module exists and has relevant functions later
|
# 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
|
# For now, define path directly for initial structure
|
||||||
LLM_CONFIG_PATH = "config/llm_settings.json"
|
LLM_CONFIG_PATH = "config/llm_settings.json"
|
||||||
|
|
||||||
@ -102,10 +102,8 @@ class LLMEditorWidget(QWidget):
|
|||||||
|
|
||||||
def _connect_signals(self):
|
def _connect_signals(self):
|
||||||
"""Connect signals to slots."""
|
"""Connect signals to slots."""
|
||||||
# Save button
|
|
||||||
self.save_button.clicked.connect(self._save_settings)
|
self.save_button.clicked.connect(self._save_settings)
|
||||||
|
|
||||||
# Fields triggering unsaved changes
|
|
||||||
self.prompt_editor.textChanged.connect(self._mark_unsaved)
|
self.prompt_editor.textChanged.connect(self._mark_unsaved)
|
||||||
self.endpoint_url_edit.textChanged.connect(self._mark_unsaved)
|
self.endpoint_url_edit.textChanged.connect(self._mark_unsaved)
|
||||||
self.api_key_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.temperature_spinbox.valueChanged.connect(self._mark_unsaved)
|
||||||
self.timeout_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.add_example_button.clicked.connect(self._add_example_tab)
|
||||||
self.delete_example_button.clicked.connect(self._delete_current_example_tab)
|
self.delete_example_button.clicked.connect(self._delete_current_example_tab)
|
||||||
self.examples_tab_widget.tabCloseRequested.connect(self._remove_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_text = json.dumps(example, indent=4)
|
||||||
example_editor = QTextEdit()
|
example_editor = QTextEdit()
|
||||||
example_editor.setPlainText(example_text)
|
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}")
|
self.examples_tab_widget.addTab(example_editor, f"Example {i+1}")
|
||||||
except TypeError as e:
|
except TypeError as e:
|
||||||
logger.error(f"Error formatting example {i+1}: {e}. Skipping.")
|
logger.error(f"Error formatting example {i+1}: {e}. Skipping.")
|
||||||
@ -277,7 +274,7 @@ class LLMEditorWidget(QWidget):
|
|||||||
logger.debug("Adding new example tab.")
|
logger.debug("Adding new example tab.")
|
||||||
new_example_editor = QTextEdit()
|
new_example_editor = QTextEdit()
|
||||||
new_example_editor.setPlaceholderText("Enter example JSON here...")
|
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
|
# Determine the next example number
|
||||||
next_example_num = self.examples_tab_widget.count() + 1
|
next_example_num = self.examples_tab_widget.count() + 1
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
import json # Added for direct config loading
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@ -8,18 +8,14 @@ from PySide6.QtCore import QObject, Signal, QThread, Slot, QTimer
|
|||||||
# --- Backend Imports ---
|
# --- Backend Imports ---
|
||||||
# Assuming these might be needed based on MainWindow's usage
|
# Assuming these might be needed based on MainWindow's usage
|
||||||
try:
|
try:
|
||||||
# Removed load_base_config import
|
|
||||||
# Removed Configuration import as we load manually now
|
|
||||||
from configuration import ConfigurationError # Keep error class
|
from configuration import ConfigurationError # Keep error class
|
||||||
from .llm_prediction_handler import LLMPredictionHandler # Backend handler
|
from .llm_prediction_handler import LLMPredictionHandler # Backend handler
|
||||||
from rule_structure import SourceRule # For signal emission type hint
|
from rule_structure import SourceRule # For signal emission type hint
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logging.getLogger(__name__).critical(f"Failed to import backend modules for LLMInteractionHandler: {e}")
|
logging.getLogger(__name__).critical(f"Failed to import backend modules for LLMInteractionHandler: {e}")
|
||||||
LLMPredictionHandler = None
|
LLMPredictionHandler = None
|
||||||
# load_base_config = None # Removed
|
|
||||||
ConfigurationError = Exception
|
ConfigurationError = Exception
|
||||||
SourceRule = None # Define as None if import fails
|
SourceRule = None # Define as None if import fails
|
||||||
# Configuration = None # Removed
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
# Define config file paths relative to this handler's location
|
# 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]]):
|
def queue_llm_requests_batch(self, requests: list[tuple[str, list | None]]):
|
||||||
"""Adds multiple requests to the LLM processing queue."""
|
"""Adds multiple requests to the LLM processing queue."""
|
||||||
added_count = 0
|
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:
|
for input_path, file_list in requests:
|
||||||
is_in_queue = any(item[0] == input_path for item in self.llm_processing_queue)
|
is_in_queue = any(item[0] == input_path for item in self.llm_processing_queue)
|
||||||
if not is_in_queue:
|
if not is_in_queue:
|
||||||
@ -108,7 +104,6 @@ class LLMInteractionHandler(QObject):
|
|||||||
|
|
||||||
if added_count > 0:
|
if added_count > 0:
|
||||||
log.info(f"Added {added_count} requests to LLM queue. New size: {len(self.llm_processing_queue)}")
|
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:
|
if not self._is_processing:
|
||||||
QTimer.singleShot(0, self._process_next_llm_item)
|
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}")
|
# log.debug(f"--> Entered LLMPredictionHandler.run() for {self.input_path}")
|
||||||
|
|
||||||
self.llm_prediction_thread.start()
|
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 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:
|
except Exception as e:
|
||||||
# --- Handle errors during setup/start ---
|
# --- 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.
|
# Pass the potentially None file_list. _start_llm_prediction handles extraction if needed.
|
||||||
self._start_llm_prediction(next_dir, file_list=file_list)
|
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 ---
|
# --- 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:
|
except Exception as e:
|
||||||
# This block now catches errors from _start_llm_prediction itself
|
# This block now catches errors from _start_llm_prediction itself
|
||||||
log.exception(f"Error occurred *during* _start_llm_prediction call for {next_dir}: {e}")
|
log.exception(f"Error occurred *during* _start_llm_prediction call for {next_dir}: {e}")
|
||||||
|
|||||||
@ -1,25 +1,23 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import requests
|
import requests
|
||||||
import re # Added import for regex
|
import re
|
||||||
import logging # Add logging
|
import logging
|
||||||
from pathlib import Path # Add Path for basename
|
from pathlib import Path
|
||||||
from PySide6.QtCore import QObject, Slot # Keep QObject for parent type hint, Slot for cancel if kept separate
|
from PySide6.QtCore import QObject, Slot
|
||||||
# Removed Signal, QThread as they are handled by BasePredictionHandler or caller
|
# Removed Signal, QThread as they are handled by BasePredictionHandler or caller
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
# Assuming rule_structure defines SourceRule, AssetRule, FileRule etc.
|
# Assuming rule_structure defines SourceRule, AssetRule, FileRule etc.
|
||||||
# Adjust the import path if necessary based on project structure
|
# 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
|
# Assuming configuration loads app_settings.json
|
||||||
# Adjust the import path if necessary
|
# Adjust the import path if necessary
|
||||||
# Removed Configuration import
|
# Removed Configuration import
|
||||||
# from configuration import Configuration
|
from .base_prediction_handler import BasePredictionHandler
|
||||||
# from configuration import load_base_config # No longer needed here
|
|
||||||
from .base_prediction_handler import BasePredictionHandler # Import base class
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__) # Setup logger
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
class LLMPredictionHandler(BasePredictionHandler):
|
class LLMPredictionHandler(BasePredictionHandler):
|
||||||
"""
|
"""
|
||||||
@ -42,8 +40,8 @@ class LLMPredictionHandler(BasePredictionHandler):
|
|||||||
"""
|
"""
|
||||||
super().__init__(input_source_identifier, parent)
|
super().__init__(input_source_identifier, parent)
|
||||||
# input_source_identifier is stored by the base class as self.input_source_identifier
|
# 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.file_list = file_list
|
||||||
self.settings = settings # Store the settings dictionary
|
self.settings = settings
|
||||||
# Access LLM settings via self.settings['key']
|
# Access LLM settings via self.settings['key']
|
||||||
# _is_running and _is_cancelled are handled by the base class
|
# _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}")
|
log.info(f"Performing LLM prediction for: {self.input_source_identifier}")
|
||||||
base_name = Path(self.input_source_identifier).name
|
base_name = Path(self.input_source_identifier).name
|
||||||
|
|
||||||
# Use the file list passed during initialization
|
|
||||||
if not self.file_list:
|
if not self.file_list:
|
||||||
log.warning(f"No files provided for LLM prediction for {self.input_source_identifier}. Returning empty 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
|
return [] # Return empty list, not an error
|
||||||
|
|
||||||
# Check for cancellation before preparing prompt
|
# Check for cancellation before preparing prompt
|
||||||
@ -82,7 +79,6 @@ class LLMPredictionHandler(BasePredictionHandler):
|
|||||||
# --- Prepare Prompt ---
|
# --- Prepare Prompt ---
|
||||||
self.status_update.emit(f"Preparing LLM input for {base_name}...")
|
self.status_update.emit(f"Preparing LLM input for {base_name}...")
|
||||||
try:
|
try:
|
||||||
# Pass relative file list
|
|
||||||
prompt = self._prepare_prompt(self.file_list)
|
prompt = self._prepare_prompt(self.file_list)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception("Error preparing LLM prompt.")
|
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.
|
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')
|
prompt_template = self.settings.get('predictor_prompt')
|
||||||
if not prompt_template:
|
if not prompt_template:
|
||||||
raise ValueError("LLM predictor prompt template content is empty or missing in settings.")
|
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)
|
asset_defs = json.dumps(self.settings.get('asset_type_definitions', {}), indent=4)
|
||||||
# Combine file type defs and examples (assuming structure from Configuration class)
|
# Combine file type defs and examples (assuming structure from Configuration class)
|
||||||
file_type_defs_combined = {}
|
file_type_defs_combined = {}
|
||||||
@ -151,7 +145,6 @@ class LLMPredictionHandler(BasePredictionHandler):
|
|||||||
# Format *relative* file list as a single string with newlines
|
# Format *relative* file list as a single string with newlines
|
||||||
file_list_str = "\n".join(relative_file_list)
|
file_list_str = "\n".join(relative_file_list)
|
||||||
|
|
||||||
# Replace placeholders
|
|
||||||
prompt = prompt_template.replace('{ASSET_TYPE_DEFINITIONS}', asset_defs)
|
prompt = prompt_template.replace('{ASSET_TYPE_DEFINITIONS}', asset_defs)
|
||||||
prompt = prompt.replace('{FILE_TYPE_DEFINITIONS}', file_defs)
|
prompt = prompt.replace('{FILE_TYPE_DEFINITIONS}', file_defs)
|
||||||
prompt = prompt.replace('{EXAMPLE_INPUT_OUTPUT_PAIRS}', examples)
|
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.
|
ValueError: If the endpoint URL is not configured or the response is invalid.
|
||||||
requests.exceptions.RequestException: For other request-related errors.
|
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:
|
if not endpoint_url:
|
||||||
raise ValueError("LLM endpoint URL is not configured in settings.")
|
raise ValueError("LLM endpoint URL is not configured in settings.")
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Type": "application/json",
|
"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:
|
if api_key:
|
||||||
headers["Authorization"] = f"Bearer {api_key}"
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
|
||||||
# Construct payload based on OpenAI Chat Completions format
|
# Construct payload based on OpenAI Chat Completions format
|
||||||
payload = {
|
payload = {
|
||||||
# Use configured model name from settings dict
|
|
||||||
"model": self.settings.get('model_name', 'local-model'),
|
"model": self.settings.get('model_name', 'local-model'),
|
||||||
"messages": [{"role": "user", "content": prompt}],
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
# Use configured temperature from settings dict
|
|
||||||
"temperature": self.settings.get('temperature', 0.5),
|
"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
|
# 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"--- 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)
|
# Note: Exceptions raised here (Timeout, RequestException, ValueError)
|
||||||
# will be caught by the _perform_prediction method's handler.
|
# will be caught by the _perform_prediction method's handler.
|
||||||
|
|
||||||
# Make the POST request with a timeout
|
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
endpoint_url,
|
endpoint_url,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json=payload,
|
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)
|
response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)
|
||||||
|
|
||||||
# Parse the JSON response
|
|
||||||
response_data = response.json()
|
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)
|
# Extract content - structure depends on the API (OpenAI format assumed)
|
||||||
if "choices" in response_data and len(response_data["choices"]) > 0:
|
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.
|
# will be caught by the _perform_prediction method's handler.
|
||||||
|
|
||||||
# --- Sanitize Input String ---
|
# --- Sanitize Input String ---
|
||||||
clean_json_str = llm_response_json_str.strip()
|
clean_json_str = re.sub(r'/\*.*?\*/', '', llm_response_json_str.strip(), flags=re.DOTALL)
|
||||||
|
|
||||||
# 1. Remove multi-line /* */ comments
|
|
||||||
clean_json_str = re.sub(r'/\*.*?\*/', '', clean_json_str, flags=re.DOTALL)
|
|
||||||
|
|
||||||
# 2. Remove single-line // comments (handle potential URLs carefully)
|
# 2. Remove single-line // comments (handle potential URLs carefully)
|
||||||
# Only remove // if it's likely a comment (e.g., whitespace before it,
|
# 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
|
# 3. Remove markdown code fences
|
||||||
clean_json_str = clean_json_str.strip()
|
clean_json_str = clean_json_str.strip()
|
||||||
if clean_json_str.startswith("```json"):
|
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("```"):
|
if clean_json_str.endswith("```"):
|
||||||
clean_json_str = clean_json_str[:-3] # Remove ```
|
clean_json_str = clean_json_str[:-3].strip()
|
||||||
clean_json_str = clean_json_str.strip() # Remove any extra whitespace
|
|
||||||
|
|
||||||
# 4. Remove <think> tags (just in case)
|
# 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 = re.sub(r'<think>.*?</think>', '', clean_json_str, flags=re.DOTALL | re.IGNORECASE).strip()
|
||||||
clean_json_str = clean_json_str.strip()
|
|
||||||
|
|
||||||
# --- Parse Sanitized JSON ---
|
# --- Parse Sanitized JSON ---
|
||||||
try:
|
try:
|
||||||
@ -327,7 +303,6 @@ class LLMPredictionHandler(BasePredictionHandler):
|
|||||||
|
|
||||||
# --- Prepare for Rule Creation ---
|
# --- Prepare for Rule Creation ---
|
||||||
source_rule = SourceRule(input_path=self.input_source_identifier)
|
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_asset_types = list(self.settings.get('asset_type_definitions', {}).keys())
|
||||||
valid_file_types = list(self.settings.get('file_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
|
asset_rules_map: Dict[str, AssetRule] = {} # Maps group_name to AssetRule
|
||||||
@ -369,17 +344,17 @@ class LLMPredictionHandler(BasePredictionHandler):
|
|||||||
# --- Handle Grouping and Asset Type ---
|
# --- Handle Grouping and Asset Type ---
|
||||||
if not group_name or not isinstance(group_name, str):
|
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.")
|
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)
|
asset_type = response_data["asset_group_classifications"].get(group_name)
|
||||||
|
|
||||||
if not asset_type:
|
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.")
|
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:
|
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}'.")
|
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 ---
|
# --- Construct Absolute Path ---
|
||||||
try:
|
try:
|
||||||
@ -400,14 +375,13 @@ class LLMPredictionHandler(BasePredictionHandler):
|
|||||||
asset_rule = AssetRule(asset_name=group_name, asset_type=asset_type)
|
asset_rule = AssetRule(asset_name=group_name, asset_type=asset_type)
|
||||||
source_rule.assets.append(asset_rule)
|
source_rule.assets.append(asset_rule)
|
||||||
asset_rules_map[group_name] = asset_rule
|
asset_rules_map[group_name] = asset_rule
|
||||||
# else: use existing asset_rule
|
|
||||||
|
|
||||||
# --- Create and Add File Rule ---
|
# --- Create and Add File Rule ---
|
||||||
file_rule = FileRule(
|
file_rule = FileRule(
|
||||||
file_path=file_path_abs,
|
file_path=file_path_abs,
|
||||||
item_type=file_type,
|
item_type=file_type,
|
||||||
item_type_override=file_type, # Initial override based on LLM
|
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,
|
output_format_override=None,
|
||||||
is_gloss_source=False,
|
is_gloss_source=False,
|
||||||
resolution_override=None,
|
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.")
|
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
|
return [source_rule] # Return list containing the single SourceRule
|
||||||
|
|
||||||
# Removed conceptual example usage comments
|
|
||||||
@ -1,4 +1,3 @@
|
|||||||
# gui/log_console_widget.py
|
|
||||||
import logging
|
import logging
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QTextEdit, QLabel, QSizePolicy
|
QWidget, QVBoxLayout, QTextEdit, QLabel, QSizePolicy
|
||||||
@ -18,26 +17,19 @@ class LogConsoleWidget(QWidget):
|
|||||||
def _init_ui(self):
|
def _init_ui(self):
|
||||||
"""Initializes the UI elements for the log console."""
|
"""Initializes the UI elements for the log console."""
|
||||||
layout = QVBoxLayout(self)
|
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:")
|
log_console_label = QLabel("Log Console:")
|
||||||
self.log_console_output = QTextEdit()
|
self.log_console_output = QTextEdit()
|
||||||
self.log_console_output.setReadOnly(True)
|
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
|
self.log_console_output.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) # Allow vertical expansion
|
||||||
|
|
||||||
layout.addWidget(log_console_label)
|
layout.addWidget(log_console_label)
|
||||||
layout.addWidget(self.log_console_output)
|
layout.addWidget(self.log_console_output)
|
||||||
|
|
||||||
# Initially hidden, visibility controlled by MainWindow
|
|
||||||
self.setVisible(False)
|
self.setVisible(False)
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
def _append_log_message(self, message):
|
def _append_log_message(self, message):
|
||||||
"""Appends a log message to the QTextEdit console."""
|
|
||||||
self.log_console_output.append(message)
|
self.log_console_output.append(message)
|
||||||
# Auto-scroll to the bottom
|
|
||||||
self.log_console_output.verticalScrollBar().setValue(self.log_console_output.verticalScrollBar().maximum())
|
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.
|
|
||||||
@ -4,10 +4,9 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import functools # Ensure functools is imported directly for partial
|
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
|
||||||
from PySide6.QtWidgets import QApplication # Added for processEvents
|
from PySide6.QtWidgets import QApplication
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QTableView,
|
QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QTableView,
|
||||||
QPushButton, QComboBox, QTableWidget, QTableWidgetItem, QHeaderView,
|
QPushButton, QComboBox, QTableWidget, QTableWidgetItem, QHeaderView,
|
||||||
@ -16,27 +15,20 @@ from PySide6.QtWidgets import (
|
|||||||
QFormLayout, QGroupBox, QAbstractItemView, QSizePolicy, QTreeView, QMenu
|
QFormLayout, QGroupBox, QAbstractItemView, QSizePolicy, QTreeView, QMenu
|
||||||
)
|
)
|
||||||
from PySide6.QtCore import Qt, Signal, Slot, QPoint, QModelIndex, QTimer
|
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 ---
|
from .delegates import LineEditDelegate, ComboBoxDelegate, SupplierSearchDelegate, ItemTypeSearchDelegate
|
||||||
# Import delegates and models needed by the panel
|
from .unified_view_model import UnifiedViewModel
|
||||||
from .delegates import LineEditDelegate, ComboBoxDelegate, SupplierSearchDelegate, ItemTypeSearchDelegate # Added ItemTypeSearchDelegate
|
|
||||||
from .unified_view_model import UnifiedViewModel # Assuming UnifiedViewModel is passed in
|
|
||||||
|
|
||||||
# --- Backend Imports ---
|
|
||||||
# Import Rule Structures if needed for context menus etc.
|
|
||||||
from rule_structure import SourceRule, AssetRule, FileRule
|
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
|
import configuration
|
||||||
try:
|
try:
|
||||||
from configuration import ConfigurationError, load_base_config
|
from configuration import ConfigurationError, load_base_config
|
||||||
except ImportError:
|
except ImportError:
|
||||||
ConfigurationError = Exception
|
ConfigurationError = Exception
|
||||||
load_base_config = None
|
load_base_config = None
|
||||||
# Define PRESETS_DIR fallback if configuration module fails to load entirely
|
|
||||||
class configuration:
|
class configuration:
|
||||||
PRESETS_DIR = "Presets" # Fallback path
|
PRESETS_DIR = "Presets"
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -49,27 +41,20 @@ class MainPanelWidget(QWidget):
|
|||||||
- Processing controls (Start, Cancel, Clear, LLM Re-interpret)
|
- Processing controls (Start, Cancel, Clear, LLM Re-interpret)
|
||||||
"""
|
"""
|
||||||
# --- Signals Emitted by the Panel ---
|
# --- 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
|
# 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()
|
cancel_requested = Signal()
|
||||||
|
|
||||||
# Request to clear the current queue/view
|
|
||||||
clear_queue_requested = Signal()
|
clear_queue_requested = Signal()
|
||||||
|
|
||||||
# Request to re-interpret selected items using LLM
|
llm_reinterpret_requested = Signal(list)
|
||||||
llm_reinterpret_requested = Signal(list) # Emits list of source paths
|
preset_reinterpret_requested = Signal(list, str)
|
||||||
preset_reinterpret_requested = Signal(list, str) # Emits list[source_paths], preset_name
|
|
||||||
|
|
||||||
# Notify when the output directory changes
|
|
||||||
output_dir_changed = Signal(str)
|
output_dir_changed = Signal(str)
|
||||||
|
|
||||||
# Notify when Blender settings change
|
blender_settings_changed = Signal(bool, str, str)
|
||||||
blender_settings_changed = Signal(bool, str, str) # enabled, ng_path, mat_path
|
|
||||||
|
|
||||||
def __init__(self, unified_model: UnifiedViewModel, parent=None, file_type_keys: list[str] | None = None):
|
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)
|
super().__init__(parent)
|
||||||
self.unified_model = unified_model
|
self.unified_model = unified_model
|
||||||
self.file_type_keys = file_type_keys if file_type_keys else []
|
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
|
script_dir = Path(__file__).parent
|
||||||
self.project_root = script_dir.parent
|
self.project_root = script_dir.parent
|
||||||
|
|
||||||
@ -95,9 +79,8 @@ class MainPanelWidget(QWidget):
|
|||||||
def _setup_ui(self):
|
def _setup_ui(self):
|
||||||
"""Sets up the UI elements for the panel."""
|
"""Sets up the UI elements for the panel."""
|
||||||
main_layout = QVBoxLayout(self)
|
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()
|
output_layout = QHBoxLayout()
|
||||||
self.output_dir_label = QLabel("Output Directory:")
|
self.output_dir_label = QLabel("Output Directory:")
|
||||||
self.output_path_edit = QLineEdit()
|
self.output_path_edit = QLineEdit()
|
||||||
@ -107,8 +90,6 @@ class MainPanelWidget(QWidget):
|
|||||||
output_layout.addWidget(self.browse_output_button)
|
output_layout.addWidget(self.browse_output_button)
|
||||||
main_layout.addLayout(output_layout)
|
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:
|
if load_base_config:
|
||||||
try:
|
try:
|
||||||
base_config = load_base_config()
|
base_config = load_base_config()
|
||||||
@ -127,36 +108,27 @@ class MainPanelWidget(QWidget):
|
|||||||
self.output_path_edit.setText("")
|
self.output_path_edit.setText("")
|
||||||
|
|
||||||
|
|
||||||
# --- Unified View Setup ---
|
|
||||||
self.unified_view = QTreeView()
|
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)
|
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
|
# TODO: Revisit ComboBoxDelegate dependency
|
||||||
comboBoxDelegate = ComboBoxDelegate(self) # Pass only parent (self)
|
comboBoxDelegate = ComboBoxDelegate(self)
|
||||||
supplierSearchDelegate = SupplierSearchDelegate(self) # Pass parent
|
supplierSearchDelegate = SupplierSearchDelegate(self)
|
||||||
# Pass file_type_keys to ItemTypeSearchDelegate
|
|
||||||
itemTypeSearchDelegate = ItemTypeSearchDelegate(self.file_type_keys, 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_SUPPLIER, supplierSearchDelegate)
|
||||||
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_ASSET_TYPE, comboBoxDelegate)
|
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_ASSET_TYPE, comboBoxDelegate)
|
||||||
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_TARGET_ASSET, lineEditDelegate)
|
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_ITEM_TYPE, itemTypeSearchDelegate)
|
||||||
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_NAME, lineEditDelegate) # Assign LineEditDelegate for AssetRule names
|
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_NAME, lineEditDelegate)
|
||||||
|
|
||||||
# Configure View Appearance
|
|
||||||
self.unified_view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
self.unified_view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
self.unified_view.setAlternatingRowColors(True)
|
self.unified_view.setAlternatingRowColors(True)
|
||||||
self.unified_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
self.unified_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||||
self.unified_view.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked | QAbstractItemView.EditTrigger.SelectedClicked | QAbstractItemView.EditTrigger.EditKeyPressed)
|
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 = self.unified_view.header()
|
||||||
header.setStretchLastSection(False)
|
header.setStretchLastSection(False)
|
||||||
header.setSectionResizeMode(UnifiedViewModel.COL_NAME, QHeaderView.ResizeMode.ResizeToContents)
|
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_ASSET_TYPE, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
header.setSectionResizeMode(UnifiedViewModel.COL_ITEM_TYPE, QHeaderView.ResizeMode.ResizeToContents)
|
header.setSectionResizeMode(UnifiedViewModel.COL_ITEM_TYPE, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
|
|
||||||
# Enable custom context menu
|
|
||||||
self.unified_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
self.unified_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||||
|
|
||||||
# --- Enable Drag and Drop ---
|
|
||||||
self.unified_view.setDragEnabled(True)
|
self.unified_view.setDragEnabled(True)
|
||||||
self.unified_view.setAcceptDrops(True)
|
self.unified_view.setAcceptDrops(True)
|
||||||
self.unified_view.setDropIndicatorShown(True)
|
self.unified_view.setDropIndicatorShown(True)
|
||||||
self.unified_view.setDefaultDropAction(Qt.MoveAction)
|
self.unified_view.setDefaultDropAction(Qt.MoveAction)
|
||||||
# Use InternalMove for handling drops within the model itself
|
|
||||||
self.unified_view.setDragDropMode(QAbstractItemView.InternalMove)
|
self.unified_view.setDragDropMode(QAbstractItemView.InternalMove)
|
||||||
# Ensure ExtendedSelection is set (already done above, but good practice)
|
|
||||||
self.unified_view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
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)
|
||||||
main_layout.addWidget(self.unified_view, 1) # Give it stretch factor 1
|
|
||||||
|
|
||||||
# --- Progress Bar ---
|
|
||||||
self.progress_bar = QProgressBar()
|
self.progress_bar = QProgressBar()
|
||||||
self.progress_bar.setValue(0)
|
self.progress_bar.setValue(0)
|
||||||
self.progress_bar.setTextVisible(True)
|
self.progress_bar.setTextVisible(True)
|
||||||
self.progress_bar.setFormat("Idle") # Initial format
|
self.progress_bar.setFormat("Idle")
|
||||||
main_layout.addWidget(self.progress_bar)
|
main_layout.addWidget(self.progress_bar)
|
||||||
|
|
||||||
# --- Blender Integration Controls ---
|
|
||||||
blender_group = QGroupBox("Blender Post-Processing")
|
blender_group = QGroupBox("Blender Post-Processing")
|
||||||
blender_layout = QVBoxLayout(blender_group)
|
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.")
|
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)
|
blender_layout.addWidget(self.blender_integration_checkbox)
|
||||||
|
|
||||||
# Nodegroup Blend Path
|
|
||||||
nodegroup_layout = QHBoxLayout()
|
nodegroup_layout = QHBoxLayout()
|
||||||
nodegroup_layout.addWidget(QLabel("Nodegroup .blend:"))
|
nodegroup_layout.addWidget(QLabel("Nodegroup .blend:"))
|
||||||
self.nodegroup_blend_path_input = QLineEdit()
|
self.nodegroup_blend_path_input = QLineEdit()
|
||||||
@ -207,7 +170,6 @@ class MainPanelWidget(QWidget):
|
|||||||
nodegroup_layout.addWidget(self.browse_nodegroup_blend_button)
|
nodegroup_layout.addWidget(self.browse_nodegroup_blend_button)
|
||||||
blender_layout.addLayout(nodegroup_layout)
|
blender_layout.addLayout(nodegroup_layout)
|
||||||
|
|
||||||
# Materials Blend Path
|
|
||||||
materials_layout = QHBoxLayout()
|
materials_layout = QHBoxLayout()
|
||||||
materials_layout.addWidget(QLabel("Materials .blend:"))
|
materials_layout.addWidget(QLabel("Materials .blend:"))
|
||||||
self.materials_blend_path_input = QLineEdit()
|
self.materials_blend_path_input = QLineEdit()
|
||||||
@ -217,8 +179,6 @@ class MainPanelWidget(QWidget):
|
|||||||
materials_layout.addWidget(self.browse_materials_blend_button)
|
materials_layout.addWidget(self.browse_materials_blend_button)
|
||||||
blender_layout.addLayout(materials_layout)
|
blender_layout.addLayout(materials_layout)
|
||||||
|
|
||||||
# Initialize paths from config (Copied from MainWindow)
|
|
||||||
# Consider passing these defaults from MainWindow
|
|
||||||
if load_base_config:
|
if load_base_config:
|
||||||
try:
|
try:
|
||||||
base_config = load_base_config()
|
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.")
|
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.nodegroup_blend_path_input.setEnabled(False)
|
||||||
self.browse_nodegroup_blend_button.setEnabled(False)
|
self.browse_nodegroup_blend_button.setEnabled(False)
|
||||||
self.materials_blend_path_input.setEnabled(False)
|
self.materials_blend_path_input.setEnabled(False)
|
||||||
self.browse_materials_blend_button.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()
|
bottom_controls_layout = QHBoxLayout()
|
||||||
self.overwrite_checkbox = QCheckBox("Overwrite Existing")
|
self.overwrite_checkbox = QCheckBox("Overwrite Existing")
|
||||||
self.overwrite_checkbox.setToolTip("If checked, existing output folders for processed assets will be deleted and replaced.")
|
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.addWidget(self.workers_spinbox)
|
||||||
bottom_controls_layout.addStretch(1)
|
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.clear_queue_button = QPushButton("Clear Queue")
|
||||||
self.start_button = QPushButton("Start Processing")
|
self.start_button = QPushButton("Start Processing")
|
||||||
@ -281,37 +234,30 @@ class MainPanelWidget(QWidget):
|
|||||||
|
|
||||||
def _connect_signals(self):
|
def _connect_signals(self):
|
||||||
"""Connect internal UI signals to slots or emit panel signals."""
|
"""Connect internal UI signals to slots or emit panel signals."""
|
||||||
# Output Directory
|
|
||||||
self.browse_output_button.clicked.connect(self._browse_for_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)
|
self.unified_view.customContextMenuRequested.connect(self._show_unified_view_context_menu)
|
||||||
|
|
||||||
# Blender Controls
|
|
||||||
self.blender_integration_checkbox.toggled.connect(self._toggle_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_nodegroup_blend_button.clicked.connect(self._browse_for_nodegroup_blend)
|
||||||
self.browse_materials_blend_button.clicked.connect(self._browse_for_materials_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.nodegroup_blend_path_input.editingFinished.connect(self._emit_blender_settings_changed)
|
||||||
self.materials_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)
|
self.blender_integration_checkbox.toggled.connect(self._emit_blender_settings_changed)
|
||||||
|
|
||||||
|
|
||||||
# Bottom Buttons
|
self.clear_queue_button.clicked.connect(self.clear_queue_requested)
|
||||||
self.clear_queue_button.clicked.connect(self.clear_queue_requested) # Emit signal directly
|
self.start_button.clicked.connect(self._on_start_processing_clicked)
|
||||||
self.start_button.clicked.connect(self._on_start_processing_clicked) # Use slot to gather data
|
self.cancel_button.clicked.connect(self.cancel_requested)
|
||||||
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
|
|
||||||
|
|
||||||
# --- Slots for Internal UI Logic ---
|
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def _browse_for_output_directory(self):
|
def _browse_for_output_directory(self):
|
||||||
"""Opens a dialog to select the output directory."""
|
"""Opens a dialog to select the output directory."""
|
||||||
current_path = self.output_path_edit.text()
|
current_path = self.output_path_edit.text()
|
||||||
if not current_path or not Path(current_path).is_dir():
|
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(
|
directory = QFileDialog.getExistingDirectory(
|
||||||
self,
|
self,
|
||||||
@ -321,7 +267,7 @@ class MainPanelWidget(QWidget):
|
|||||||
)
|
)
|
||||||
if directory:
|
if directory:
|
||||||
self.output_path_edit.setText(directory)
|
self.output_path_edit.setText(directory)
|
||||||
self._on_output_path_changed() # Explicitly call the change handler
|
self._on_output_path_changed()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def _on_output_path_changed(self):
|
def _on_output_path_changed(self):
|
||||||
@ -335,7 +281,6 @@ class MainPanelWidget(QWidget):
|
|||||||
self.browse_nodegroup_blend_button.setEnabled(checked)
|
self.browse_nodegroup_blend_button.setEnabled(checked)
|
||||||
self.materials_blend_path_input.setEnabled(checked)
|
self.materials_blend_path_input.setEnabled(checked)
|
||||||
self.browse_materials_blend_button.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):
|
def _browse_for_blend_file(self, line_edit_widget: QLineEdit):
|
||||||
"""Opens a dialog to select a .blend file and updates the line edit."""
|
"""Opens a dialog to select a .blend file and updates the line edit."""
|
||||||
@ -350,7 +295,7 @@ class MainPanelWidget(QWidget):
|
|||||||
)
|
)
|
||||||
if file_path:
|
if file_path:
|
||||||
line_edit_widget.setText(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()
|
@Slot()
|
||||||
def _browse_for_nodegroup_blend(self):
|
def _browse_for_nodegroup_blend(self):
|
||||||
@ -376,7 +321,6 @@ class MainPanelWidget(QWidget):
|
|||||||
QMessageBox.warning(self, "Missing Output Directory", "Please select an output directory.")
|
QMessageBox.warning(self, "Missing Output Directory", "Please select an output directory.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Basic validation (MainWindow should do more thorough validation)
|
|
||||||
try:
|
try:
|
||||||
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -393,8 +337,6 @@ class MainPanelWidget(QWidget):
|
|||||||
}
|
}
|
||||||
self.process_requested.emit(settings)
|
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]:
|
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.")
|
log.error("Unified view model not found.")
|
||||||
return unique_source_dirs
|
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:
|
for index in selected_indexes:
|
||||||
if not index.isValid():
|
if not index.isValid():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Use the model's getItem method for robust node retrieval
|
|
||||||
item_node = model.getItem(index)
|
item_node = model.getItem(index)
|
||||||
source_rule_node = None
|
source_rule_node = None
|
||||||
|
|
||||||
# Find the parent SourceRule node by traversing upwards using the index
|
|
||||||
source_rule_node = None
|
source_rule_node = None
|
||||||
current_index = index # Start with the index of the selected item
|
current_index = index
|
||||||
while current_index.isValid():
|
while current_index.isValid():
|
||||||
current_item = model.getItem(current_index)
|
current_item = model.getItem(current_index)
|
||||||
if isinstance(current_item, SourceRule):
|
if isinstance(current_item, SourceRule):
|
||||||
@ -429,11 +369,9 @@ class MainPanelWidget(QWidget):
|
|||||||
# If loop finishes without break, source_rule_node remains None
|
# If loop finishes without break, source_rule_node remains None
|
||||||
|
|
||||||
if source_rule_node:
|
if source_rule_node:
|
||||||
# Use input_path attribute as defined in SourceRule
|
|
||||||
source_path = getattr(source_rule_node, 'input_path', None)
|
source_path = getattr(source_rule_node, 'input_path', None)
|
||||||
if source_path and source_path not in processed_source_paths:
|
if source_path and source_path not in processed_source_paths:
|
||||||
source_path_obj = Path(source_path)
|
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'):
|
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}")
|
log.debug(f"Identified source path for re-interpretation: {source_path}")
|
||||||
unique_source_dirs.add(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):
|
def _on_reinterpret_preset_selected(self, preset_name: str, index: QModelIndex):
|
||||||
"""Handles the selection of a preset from the re-interpret context sub-menu."""
|
"""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()}'")
|
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()
|
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)
|
unique_source_dirs = self._get_unique_source_dirs_from_selection(selected_indexes)
|
||||||
|
|
||||||
if not unique_source_dirs:
|
if not unique_source_dirs:
|
||||||
@ -494,12 +430,11 @@ class MainPanelWidget(QWidget):
|
|||||||
|
|
||||||
model = self.unified_view.model()
|
model = self.unified_view.model()
|
||||||
if not model: return
|
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
|
# Find the SourceRule node associated with the clicked index
|
||||||
source_rule_node = None
|
source_rule_node = None
|
||||||
current_index = index # Start with the clicked index
|
current_index = index
|
||||||
while current_index.isValid():
|
while current_index.isValid():
|
||||||
current_item = model.getItem(current_index)
|
current_item = model.getItem(current_index)
|
||||||
if isinstance(current_item, SourceRule):
|
if isinstance(current_item, SourceRule):
|
||||||
@ -510,11 +445,9 @@ class MainPanelWidget(QWidget):
|
|||||||
|
|
||||||
menu = QMenu(self)
|
menu = QMenu(self)
|
||||||
|
|
||||||
# --- Re-interpret Menu ---
|
|
||||||
if source_rule_node: # Only show if we clicked on or within a SourceRule item
|
if source_rule_node: # Only show if we clicked on or within a SourceRule item
|
||||||
reinterpet_menu = menu.addMenu("Re-interpret selected source")
|
reinterpet_menu = menu.addMenu("Re-interpret selected source")
|
||||||
|
|
||||||
# Get Preset Names (Option B: Direct File Listing)
|
|
||||||
preset_names = []
|
preset_names = []
|
||||||
try:
|
try:
|
||||||
presets_dir = configuration.PRESETS_DIR
|
presets_dir = configuration.PRESETS_DIR
|
||||||
@ -523,17 +456,15 @@ class MainPanelWidget(QWidget):
|
|||||||
if filename.endswith(".json") and filename != "_template.json":
|
if filename.endswith(".json") and filename != "_template.json":
|
||||||
preset_name = os.path.splitext(filename)[0]
|
preset_name = os.path.splitext(filename)[0]
|
||||||
preset_names.append(preset_name)
|
preset_names.append(preset_name)
|
||||||
preset_names.sort() # Sort alphabetically
|
preset_names.sort()
|
||||||
else:
|
else:
|
||||||
log.warning(f"Presets directory not found or not a directory: {presets_dir}")
|
log.warning(f"Presets directory not found or not a directory: {presets_dir}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(f"Error listing presets in {configuration.PRESETS_DIR}: {e}")
|
log.exception(f"Error listing presets in {configuration.PRESETS_DIR}: {e}")
|
||||||
|
|
||||||
# Populate Sub-Menu with Presets
|
|
||||||
if preset_names:
|
if preset_names:
|
||||||
for preset_name in preset_names:
|
for preset_name in preset_names:
|
||||||
preset_action = QAction(preset_name, self)
|
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))
|
preset_action.triggered.connect(functools.partial(self._on_reinterpret_preset_selected, preset_name, index))
|
||||||
reinterpet_menu.addAction(preset_action)
|
reinterpet_menu.addAction(preset_action)
|
||||||
else:
|
else:
|
||||||
@ -542,39 +473,31 @@ class MainPanelWidget(QWidget):
|
|||||||
reinterpet_menu.addAction(no_presets_action)
|
reinterpet_menu.addAction(no_presets_action)
|
||||||
|
|
||||||
|
|
||||||
# Add LLM Option (Static)
|
|
||||||
reinterpet_menu.addSeparator()
|
reinterpet_menu.addSeparator()
|
||||||
llm_action = QAction("LLM", self)
|
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)
|
llm_action.triggered.connect(self._on_llm_reinterpret_clicked)
|
||||||
# Disable if LLM is currently processing
|
|
||||||
llm_action.setEnabled(not self.llm_processing_active)
|
llm_action.setEnabled(not self.llm_processing_active)
|
||||||
reinterpet_menu.addAction(llm_action)
|
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
|
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 = 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.")
|
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))
|
copy_llm_example_action.triggered.connect(lambda: self._copy_llm_example_to_clipboard(source_rule_node))
|
||||||
menu.addAction(copy_llm_example_action)
|
menu.addAction(copy_llm_example_action)
|
||||||
# menu.addSeparator() # Removed redundant separator
|
|
||||||
|
|
||||||
# Add other general actions here if needed...
|
|
||||||
|
|
||||||
if not menu.isEmpty():
|
if not menu.isEmpty():
|
||||||
menu.exec(self.unified_view.viewport().mapToGlobal(point))
|
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):
|
def _copy_llm_example_to_clipboard(self, source_rule_node: SourceRule | None):
|
||||||
"""Copies a JSON structure for the given SourceRule node to the clipboard."""
|
"""Copies a JSON structure for the given SourceRule node to the clipboard."""
|
||||||
if not source_rule_node:
|
if not source_rule_node:
|
||||||
log.warning(f"No SourceRule node provided to copy LLM example.")
|
log.warning(f"No SourceRule node provided to copy LLM example.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# We already have the source_rule_node passed in
|
|
||||||
source_rule: SourceRule = source_rule_node
|
source_rule: SourceRule = source_rule_node
|
||||||
log.info(f"Attempting to generate LLM example JSON for source: {source_rule.input_path}")
|
log.info(f"Attempting to generate LLM example JSON for source: {source_rule.input_path}")
|
||||||
|
|
||||||
@ -612,11 +535,10 @@ class MainPanelWidget(QWidget):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
json_string = json.dumps(llm_example, indent=2)
|
json_string = json.dumps(llm_example, indent=2)
|
||||||
clipboard = QGuiApplication.clipboard() # Use QGuiApplication
|
clipboard = QGuiApplication.clipboard()
|
||||||
if clipboard:
|
if clipboard:
|
||||||
clipboard.setText(json_string)
|
clipboard.setText(json_string)
|
||||||
log.info(f"Copied LLM example JSON to clipboard for source: {source_rule.input_path}")
|
log.info(f"Copied LLM example JSON to clipboard for source: {source_rule.input_path}")
|
||||||
# Cannot show status bar message here
|
|
||||||
else:
|
else:
|
||||||
log.error("Failed to get system clipboard.")
|
log.error("Failed to get system clipboard.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -630,10 +552,10 @@ class MainPanelWidget(QWidget):
|
|||||||
"""Updates the progress bar display."""
|
"""Updates the progress bar display."""
|
||||||
if total_count > 0:
|
if total_count > 0:
|
||||||
percentage = int((current_count / total_count) * 100)
|
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.setValue(percentage)
|
||||||
self.progress_bar.setFormat(f"%p% ({current_count}/{total_count})")
|
self.progress_bar.setFormat(f"%p% ({current_count}/{total_count})")
|
||||||
QApplication.processEvents() # Force GUI update
|
QApplication.processEvents()
|
||||||
else:
|
else:
|
||||||
self.progress_bar.setValue(0)
|
self.progress_bar.setValue(0)
|
||||||
self.progress_bar.setFormat("0/0")
|
self.progress_bar.setFormat("0/0")
|
||||||
@ -642,7 +564,6 @@ class MainPanelWidget(QWidget):
|
|||||||
def set_progress_bar_text(self, text: str):
|
def set_progress_bar_text(self, text: str):
|
||||||
"""Sets the text format of the progress bar."""
|
"""Sets the text format of the progress bar."""
|
||||||
self.progress_bar.setFormat(text)
|
self.progress_bar.setFormat(text)
|
||||||
# Reset value if setting text like "Idle" or "Waiting..."
|
|
||||||
if not "%" in text:
|
if not "%" in text:
|
||||||
self.progress_bar.setValue(0)
|
self.progress_bar.setValue(0)
|
||||||
|
|
||||||
@ -650,7 +571,6 @@ class MainPanelWidget(QWidget):
|
|||||||
@Slot(bool)
|
@Slot(bool)
|
||||||
def set_controls_enabled(self, enabled: bool):
|
def set_controls_enabled(self, enabled: bool):
|
||||||
"""Enables or disables controls within the panel."""
|
"""Enables or disables controls within the panel."""
|
||||||
# Enable/disable most controls based on the 'enabled' flag
|
|
||||||
self.output_path_edit.setEnabled(enabled)
|
self.output_path_edit.setEnabled(enabled)
|
||||||
self.browse_output_button.setEnabled(enabled)
|
self.browse_output_button.setEnabled(enabled)
|
||||||
self.unified_view.setEnabled(enabled)
|
self.unified_view.setEnabled(enabled)
|
||||||
@ -670,8 +590,6 @@ class MainPanelWidget(QWidget):
|
|||||||
self.materials_blend_path_input.setEnabled(blender_paths_enabled)
|
self.materials_blend_path_input.setEnabled(blender_paths_enabled)
|
||||||
self.browse_materials_blend_button.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)
|
@Slot(bool)
|
||||||
@ -696,11 +614,11 @@ class MainPanelWidget(QWidget):
|
|||||||
# No button state to update directly, but context menu will check this flag when built.
|
# 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
|
# 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()
|
return self.output_path_edit.text().strip()
|
||||||
|
|
||||||
# TODO: Add method to get current Blender settings if needed by MainWindow before processing
|
# 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 {
|
return {
|
||||||
"enabled": self.blender_integration_checkbox.isChecked(),
|
"enabled": self.blender_integration_checkbox.isChecked(),
|
||||||
"nodegroup_blend_path": self.nodegroup_blend_path_input.text(),
|
"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
|
# 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()
|
return self.workers_spinbox.value()
|
||||||
|
|
||||||
# TODO: Add method to get current overwrite setting if needed by MainWindow before processing
|
# 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()
|
return self.overwrite_checkbox.isChecked()
|
||||||
|
|
||||||
# --- Delegate Dependency ---
|
def get_llm_source_preset_name() -> str | None:
|
||||||
# This method might be needed by ComboBoxDelegate if it relies on MainWindow's logic
|
|
||||||
def get_llm_source_preset_name(self) -> str | None:
|
|
||||||
"""
|
"""
|
||||||
Placeholder for providing context to delegates.
|
Placeholder for providing context to delegates.
|
||||||
Ideally, the required info (like last preset name) should be passed
|
Ideally, the required info (like last preset name) should be passed
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,14 +1,12 @@
|
|||||||
# gui/rule_based_prediction_handler.py
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
import re # Import regex
|
import re
|
||||||
import tempfile # Added for temporary extraction directory
|
import tempfile
|
||||||
import zipfile # Added for zip file handling
|
import zipfile
|
||||||
# import patoolib # Potential import for rar/7z - Add later if zip works
|
from collections import defaultdict, Counter
|
||||||
from collections import defaultdict, Counter # Added Counter
|
from typing import List, Dict, Any
|
||||||
from typing import List, Dict, Any # For type hinting
|
|
||||||
|
|
||||||
# --- PySide6 Imports ---
|
# --- PySide6 Imports ---
|
||||||
from PySide6.QtCore import QObject, Slot # Keep QObject for parent type hint, Slot for classify_files if kept as method
|
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))
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
try:
|
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 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
|
BACKEND_AVAILABLE = True
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
# Update error message source
|
|
||||||
print(f"ERROR (RuleBasedPredictionHandler): Failed to import backend/config/base modules: {e}")
|
print(f"ERROR (RuleBasedPredictionHandler): Failed to import backend/config/base modules: {e}")
|
||||||
# Define placeholders if imports fail
|
|
||||||
Configuration = None
|
Configuration = None
|
||||||
load_base_config = None # Placeholder
|
load_base_config = None
|
||||||
ConfigurationError = Exception
|
ConfigurationError = Exception
|
||||||
# AssetProcessingError = Exception
|
SourceRule, AssetRule, FileRule = (None,)*3
|
||||||
SourceRule, AssetRule, FileRule = (None,)*3 # Placeholder for rule structures
|
|
||||||
# Removed: AssetType, ItemType = (None,)*2 # Placeholder for types
|
|
||||||
# Removed: app_config = None # Placeholder for config
|
|
||||||
BACKEND_AVAILABLE = False
|
BACKEND_AVAILABLE = False
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
# Basic config if logger hasn't been set up elsewhere
|
|
||||||
if not log.hasHandlers():
|
if not log.hasHandlers():
|
||||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s (RuleBasedPredictHandler): %(message)s')
|
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]]]:
|
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
|
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.
|
Returns an empty dict if classification fails or no files are provided.
|
||||||
"""
|
"""
|
||||||
temp_grouped_files = defaultdict(list)
|
temp_grouped_files = defaultdict(list)
|
||||||
extra_files_to_associate = [] # Store tuples: (file_path_str, filename) for Pass 2 association
|
extra_files_to_associate = []
|
||||||
primary_asset_names = set() # Store asset names derived *only* from primary map files (populated in Pass 1)
|
primary_asset_names = set()
|
||||||
primary_assignments = set() # Stores tuples: (asset_name, target_type) (populated *only* in Pass 1)
|
primary_assignments = set()
|
||||||
processed_in_pass1 = set() # Keep track of files handled in Pass 1
|
processed_in_pass1 = set()
|
||||||
|
|
||||||
# --- Validation ---
|
# --- Validation ---
|
||||||
if not file_list or not config:
|
if not file_list or not config:
|
||||||
log.warning("Classification skipped: Missing file list or config.")
|
log.warning("Classification skipped: Missing file list or config.")
|
||||||
return {}
|
return {}
|
||||||
# Access compiled regex directly from the config object
|
|
||||||
if not hasattr(config, 'compiled_map_keyword_regex') or not config.compiled_map_keyword_regex:
|
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.")
|
log.warning("Classification skipped: Missing compiled map keyword regex in config.")
|
||||||
if not hasattr(config, 'compiled_extra_regex'):
|
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:
|
if match:
|
||||||
log.debug(f"PASS 1: File '{filename}' matched PRIORITIZED bit depth variant for type '{target_type}'.")
|
log.debug(f"PASS 1: File '{filename}' matched PRIORITIZED bit depth variant for type '{target_type}'.")
|
||||||
matched_item_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:
|
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.")
|
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:
|
else:
|
||||||
primary_assignments.add((asset_name, matched_item_type))
|
primary_assignments.add((asset_name, matched_item_type))
|
||||||
log.debug(f" PASS 1: Added primary assignment: ({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_in_pass1.add(file_path_str)
|
||||||
processed = True
|
processed = True
|
||||||
break # Stop checking other variant patterns for this file
|
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} ---")
|
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:
|
for file_path_str in file_list:
|
||||||
if file_path_str in processed_in_pass1:
|
if file_path_str in processed_in_pass1:
|
||||||
log.debug(f"PASS 2: Skipping '{Path(file_path_str).name}' (processed in Pass 1).")
|
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)
|
file_path = Path(file_path_str)
|
||||||
filename = file_path.name
|
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:
|
for extra_pattern in compiled_extra_regex:
|
||||||
if extra_pattern.search(filename):
|
if extra_pattern.search(filename):
|
||||||
log.debug(f"PASS 2: File '{filename}' matched EXTRA pattern: {extra_pattern.pattern}")
|
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))
|
extra_files_to_associate.append((file_path_str, filename))
|
||||||
is_extra = True
|
is_extra = True
|
||||||
break
|
break
|
||||||
|
|
||||||
if is_extra:
|
if is_extra:
|
||||||
continue # Move to the next file if it's an extra
|
continue
|
||||||
|
|
||||||
# 2. Check for General Map Files in Pass 2
|
# 2. Check for General Map Files in Pass 2
|
||||||
for target_type, patterns_list in compiled_map_regex.items():
|
for target_type, patterns_list in compiled_map_regex.items():
|
||||||
for compiled_regex, original_keyword, rule_index in patterns_list:
|
for compiled_regex, original_keyword, rule_index in patterns_list:
|
||||||
match = compiled_regex.search(filename)
|
match = compiled_regex.search(filename)
|
||||||
if match:
|
if match:
|
||||||
# Access rule details
|
|
||||||
is_gloss_flag = False
|
is_gloss_flag = False
|
||||||
try:
|
try:
|
||||||
map_type_mapping_list = config.map_type_mapping
|
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:
|
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.")
|
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"
|
matched_item_type = "EXTRA"
|
||||||
is_gloss_flag = False # Extras are not gloss sources
|
is_gloss_flag = False
|
||||||
else:
|
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}'.")
|
log.debug(f"PASS 2: File '{filename}' matched '{original_keyword}' for item_type '{target_type}'.")
|
||||||
matched_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({
|
temp_grouped_files[asset_name].append({
|
||||||
'file_path': file_path_str,
|
'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,
|
'asset_name': asset_name,
|
||||||
'is_gloss_source': is_gloss_flag
|
'is_gloss_source': is_gloss_flag
|
||||||
})
|
})
|
||||||
is_map = True
|
is_map = True
|
||||||
break # Stop checking patterns for this file
|
break
|
||||||
if is_map:
|
if is_map:
|
||||||
break # Stop checking target types for this file
|
break
|
||||||
|
|
||||||
# 3. Handle Unmatched Files in Pass 2 (Not Extra, Not Map)
|
# 3. Handle Unmatched Files in Pass 2 (Not Extra, Not Map)
|
||||||
if not is_extra and not is_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) ---
|
# --- Determine Primary Asset Name for Extra Association (using Pass 1 results) ---
|
||||||
final_primary_asset_name = None
|
final_primary_asset_name = None
|
||||||
if primary_asset_names: # Use names derived only from Pass 1 (prioritized variants)
|
if primary_asset_names:
|
||||||
# Find the most common name among those derived from primary maps identified in Pass 1
|
|
||||||
primary_map_asset_names_pass1 = [
|
primary_map_asset_names_pass1 = [
|
||||||
f_info['asset_name']
|
f_info['asset_name']
|
||||||
for asset_files in temp_grouped_files.values()
|
for asset_files in temp_grouped_files.values()
|
||||||
for f_info in asset_files
|
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:
|
if primary_map_asset_names_pass1:
|
||||||
name_counts = Counter(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.")
|
log.warning("Primary asset names set (from Pass 1) was populated, but no corresponding groups found. Falling back.")
|
||||||
|
|
||||||
if not final_primary_asset_name:
|
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:
|
if temp_grouped_files and extra_files_to_associate:
|
||||||
fallback_name = sorted(temp_grouped_files.keys())[0]
|
fallback_name = sorted(temp_grouped_files.keys())[0]
|
||||||
final_primary_asset_name = fallback_name
|
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:
|
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}'")
|
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:
|
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]):
|
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({
|
temp_grouped_files[final_primary_asset_name].append({
|
||||||
'file_path': file_path_str,
|
'file_path': file_path_str,
|
||||||
@ -293,7 +270,6 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
|
|||||||
else:
|
else:
|
||||||
log.debug(f"Skipping duplicate association of extra file: {filename}")
|
log.debug(f"Skipping duplicate association of extra file: {filename}")
|
||||||
elif extra_files_to_associate:
|
elif extra_files_to_associate:
|
||||||
# Logged warning above if final_primary_asset_name couldn't be determined
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@ -321,8 +297,6 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
|
|||||||
super().__init__(input_source_identifier, parent)
|
super().__init__(input_source_identifier, parent)
|
||||||
self.original_input_paths = original_input_paths
|
self.original_input_paths = original_input_paths
|
||||||
self.preset_name = preset_name
|
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_input_path = None
|
||||||
self._current_file_list = None
|
self._current_file_list = None
|
||||||
self._current_preset_name = 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)
|
# 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:
|
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}'.")
|
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
|
return
|
||||||
|
|
||||||
self._is_running = True
|
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_input_path = input_source_identifier
|
||||||
self._current_file_list = original_input_paths
|
self._current_file_list = original_input_paths
|
||||||
self._current_preset_name = preset_name
|
self._current_preset_name = preset_name
|
||||||
|
|
||||||
log.info(f"Starting rule-based prediction for: {input_source_identifier} using preset: {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 = []
|
source_rules_list = []
|
||||||
try:
|
try:
|
||||||
@ -362,9 +334,8 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
|
|||||||
if not preset_name:
|
if not preset_name:
|
||||||
log.warning("No preset selected for prediction.")
|
log.warning("No preset selected for prediction.")
|
||||||
self.status_update.emit("No preset selected.")
|
self.status_update.emit("No preset selected.")
|
||||||
# Emit empty list for non-critical issues, signal completion
|
|
||||||
self.prediction_ready.emit(input_source_identifier, [])
|
self.prediction_ready.emit(input_source_identifier, [])
|
||||||
self._is_running = False # Mark as finished
|
self._is_running = False
|
||||||
return
|
return
|
||||||
|
|
||||||
source_path = Path(input_source_identifier)
|
source_path = Path(input_source_identifier)
|
||||||
@ -391,15 +362,13 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
|
|||||||
if not classified_assets:
|
if not classified_assets:
|
||||||
log.warning(f"Classification yielded no assets for source '{input_source_identifier}'.")
|
log.warning(f"Classification yielded no assets for source '{input_source_identifier}'.")
|
||||||
self.status_update.emit("No assets identified from files.")
|
self.status_update.emit("No assets identified from files.")
|
||||||
# Emit empty list, signal completion
|
|
||||||
self.prediction_ready.emit(input_source_identifier, [])
|
self.prediction_ready.emit(input_source_identifier, [])
|
||||||
self._is_running = False # Mark as finished
|
self._is_running = False
|
||||||
return
|
return
|
||||||
|
|
||||||
# --- Build the Hierarchy ---
|
# --- Build the Hierarchy ---
|
||||||
self.status_update.emit(f"Building rule hierarchy for '{source_path.name}'...")
|
self.status_update.emit(f"Building rule hierarchy for '{source_path.name}'...")
|
||||||
try:
|
try:
|
||||||
# (Hierarchy building logic remains the same as before)
|
|
||||||
supplier_identifier = config.supplier_name
|
supplier_identifier = config.supplier_name
|
||||||
source_rule = SourceRule(
|
source_rule = SourceRule(
|
||||||
input_path=input_source_identifier,
|
input_path=input_source_identifier,
|
||||||
@ -407,7 +376,6 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
|
|||||||
preset_name=preset_name
|
preset_name=preset_name
|
||||||
)
|
)
|
||||||
asset_rules = []
|
asset_rules = []
|
||||||
# asset_type_definitions = config._core_settings.get('ASSET_TYPE_DEFINITIONS', {}) # Use accessor
|
|
||||||
file_type_definitions = config._core_settings.get('FILE_TYPE_DEFINITIONS', {})
|
file_type_definitions = config._core_settings.get('FILE_TYPE_DEFINITIONS', {})
|
||||||
|
|
||||||
for asset_name, files_info in classified_assets.items():
|
for asset_name, files_info in classified_assets.items():
|
||||||
@ -415,7 +383,7 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
|
|||||||
if not files_info: continue
|
if not files_info: continue
|
||||||
|
|
||||||
asset_category_rules = config.asset_category_rules
|
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())
|
asset_type_keys = list(asset_type_definitions.keys())
|
||||||
|
|
||||||
# Initialize predicted_asset_type using the validated default
|
# Initialize predicted_asset_type using the validated default
|
||||||
@ -427,9 +395,8 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
|
|||||||
|
|
||||||
# Check for Model type based on file patterns
|
# Check for Model type based on file patterns
|
||||||
if "Model" in asset_type_keys:
|
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:
|
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"]:
|
if f_info['item_type'] in ["EXTRA", "FILE_IGNORE"]:
|
||||||
continue
|
continue
|
||||||
file_path_obj = Path(f_info['file_path'])
|
file_path_obj = Path(f_info['file_path'])
|
||||||
@ -447,9 +414,9 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
|
|||||||
decal_keywords = asset_category_rules.get('decal_keywords', [])
|
decal_keywords = asset_category_rules.get('decal_keywords', [])
|
||||||
for keyword in decal_keywords:
|
for keyword in decal_keywords:
|
||||||
# Ensure keyword is a string before trying to escape it
|
# 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:
|
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"
|
predicted_asset_type = "Decal"
|
||||||
determined_by_rule = True
|
determined_by_rule = True
|
||||||
log.debug(f"Asset '{asset_name}' classified as 'Decal' due to keyword '{keyword}'.")
|
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:
|
except re.error as e_re:
|
||||||
log.warning(f"Regex error with decal_keyword '{keyword}': {e_re}")
|
log.warning(f"Regex error with decal_keyword '{keyword}': {e_re}")
|
||||||
if determined_by_rule:
|
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)
|
# 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:
|
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.")
|
log.debug(f"Asset '{asset_name}' classified as 'Surface' due to material indicators.")
|
||||||
|
|
||||||
# 3. Final validation: Ensure predicted_asset_type is a valid key.
|
# 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:
|
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. "
|
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}'.")
|
f"Falling back to default: '{config.default_asset_category}'.")
|
||||||
predicted_asset_type = 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)
|
asset_rule = AssetRule(asset_name=asset_name, asset_type=predicted_asset_type)
|
||||||
file_rules = []
|
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.")
|
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"
|
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)
|
is_gloss_source_value = file_info.get('is_gloss_source', False)
|
||||||
|
|
||||||
@ -540,22 +501,20 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
|
|||||||
|
|
||||||
# --- Emit Success Signal ---
|
# --- Emit Success Signal ---
|
||||||
log.info(f"Rule-based prediction finished successfully for '{input_source_identifier}'.")
|
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:
|
except Exception as e:
|
||||||
# --- Emit Error Signal ---
|
# --- Emit Error Signal ---
|
||||||
log.exception(f"Error during rule-based prediction for '{input_source_identifier}': {e}")
|
log.exception(f"Error during rule-based prediction for '{input_source_identifier}': {e}")
|
||||||
error_msg = f"Error analyzing '{Path(input_source_identifier).name}': {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:
|
finally:
|
||||||
# --- Cleanup ---
|
|
||||||
self._is_running = False
|
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_file_list = None
|
||||||
self._current_preset_name = None
|
self._current_preset_name = None
|
||||||
log.info(f"Finished rule-based prediction run for: {input_source_identifier}")
|
log.info(f"Finished rule-based prediction run for: {input_source_identifier}")
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
"""Returns True if the handler is currently processing a prediction request."""
|
"""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
|
return self._is_running
|
||||||
|
|||||||
@ -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
|
# Assuming project root is parent of the directory containing this file
|
||||||
script_dir = Path(__file__).parent
|
script_dir = Path(__file__).parent
|
||||||
project_root = script_dir.parent
|
project_root = script_dir.parent
|
||||||
PRESETS_DIR = project_root / "Presets" # Corrected path
|
PRESETS_DIR = project_root / "Presets"
|
||||||
TEMPLATE_PATH = PRESETS_DIR / "_template.json"
|
TEMPLATE_PATH = PRESETS_DIR / "_template.json"
|
||||||
APP_SETTINGS_PATH_LOCAL = project_root / "config" / "app_settings.json"
|
APP_SETTINGS_PATH_LOCAL = project_root / "config" / "app_settings.json"
|
||||||
|
|
||||||
@ -51,10 +51,10 @@ class PresetEditorWidget(QWidget):
|
|||||||
self._init_ui()
|
self._init_ui()
|
||||||
|
|
||||||
# --- Initial State ---
|
# --- Initial State ---
|
||||||
self._ftd_keys = self._get_file_type_definition_keys() # Load FTD keys
|
self._ftd_keys = self._get_file_type_definition_keys()
|
||||||
self._clear_editor() # Clear/disable editor fields initially
|
self._clear_editor()
|
||||||
self._set_editor_enabled(False) # Disable editor initially
|
self._set_editor_enabled(False)
|
||||||
self.populate_presets() # Populate preset list
|
self.populate_presets()
|
||||||
|
|
||||||
# --- Connect Editor Signals ---
|
# --- Connect Editor Signals ---
|
||||||
self._connect_editor_change_signals()
|
self._connect_editor_change_signals()
|
||||||
@ -91,7 +91,7 @@ class PresetEditorWidget(QWidget):
|
|||||||
selector_layout.addWidget(QLabel("Presets:"))
|
selector_layout.addWidget(QLabel("Presets:"))
|
||||||
self.editor_preset_list = QListWidget()
|
self.editor_preset_list = QListWidget()
|
||||||
self.editor_preset_list.currentItemChanged.connect(self._load_selected_preset_for_editing)
|
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()
|
list_button_layout = QHBoxLayout()
|
||||||
self.editor_new_button = QPushButton("New")
|
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_new_button)
|
||||||
list_button_layout.addWidget(self.editor_delete_button)
|
list_button_layout.addWidget(self.editor_delete_button)
|
||||||
selector_layout.addLayout(list_button_layout)
|
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
|
# Editor Tabs
|
||||||
self.json_editor_container = QWidget()
|
self.json_editor_container = QWidget()
|
||||||
@ -121,7 +121,7 @@ class PresetEditorWidget(QWidget):
|
|||||||
save_button_layout = QHBoxLayout()
|
save_button_layout = QHBoxLayout()
|
||||||
self.editor_save_button = QPushButton("Save")
|
self.editor_save_button = QPushButton("Save")
|
||||||
self.editor_save_as_button = QPushButton("Save As...")
|
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_button.clicked.connect(self._save_current_preset)
|
||||||
self.editor_save_as_button.clicked.connect(self._save_preset_as)
|
self.editor_save_as_button.clicked.connect(self._save_preset_as)
|
||||||
save_button_layout.addStretch()
|
save_button_layout.addStretch()
|
||||||
@ -129,7 +129,7 @@ class PresetEditorWidget(QWidget):
|
|||||||
save_button_layout.addWidget(self.editor_save_as_button)
|
save_button_layout.addWidget(self.editor_save_as_button)
|
||||||
editor_layout.addLayout(save_button_layout)
|
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):
|
def _create_editor_general_tab(self):
|
||||||
"""Creates the widgets and layout for the 'General & Naming' editor tab."""
|
"""Creates the widgets and layout for the 'General & Naming' editor tab."""
|
||||||
@ -206,7 +206,7 @@ class PresetEditorWidget(QWidget):
|
|||||||
list_widget = QListWidget()
|
list_widget = QListWidget()
|
||||||
list_widget.setAlternatingRowColors(True)
|
list_widget.setAlternatingRowColors(True)
|
||||||
list_widget.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked | QAbstractItemView.EditTrigger.SelectedClicked | QAbstractItemView.EditTrigger.EditKeyPressed)
|
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("+")
|
add_button = QPushButton("+")
|
||||||
remove_button = QPushButton("-")
|
remove_button = QPushButton("-")
|
||||||
@ -231,7 +231,7 @@ class PresetEditorWidget(QWidget):
|
|||||||
# Connections
|
# Connections
|
||||||
add_button.clicked.connect(partial(self._editor_add_list_item, list_widget))
|
add_button.clicked.connect(partial(self._editor_add_list_item, list_widget))
|
||||||
remove_button.clicked.connect(partial(self._editor_remove_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):
|
def _setup_table_widget_with_controls(self, parent_layout, label_text, attribute_name, columns):
|
||||||
"""Adds a QTableWidget with Add/Remove buttons to a layout."""
|
"""Adds a QTableWidget with Add/Remove buttons to a layout."""
|
||||||
@ -239,7 +239,7 @@ class PresetEditorWidget(QWidget):
|
|||||||
table_widget.setColumnCount(len(columns))
|
table_widget.setColumnCount(len(columns))
|
||||||
table_widget.setHorizontalHeaderLabels(columns)
|
table_widget.setHorizontalHeaderLabels(columns)
|
||||||
table_widget.setAlternatingRowColors(True)
|
table_widget.setAlternatingRowColors(True)
|
||||||
setattr(self, attribute_name, table_widget) # Store table widget
|
setattr(self, attribute_name, table_widget)
|
||||||
|
|
||||||
add_button = QPushButton("+ Row")
|
add_button = QPushButton("+ Row")
|
||||||
remove_button = QPushButton("- Row")
|
remove_button = QPushButton("- Row")
|
||||||
@ -259,7 +259,7 @@ class PresetEditorWidget(QWidget):
|
|||||||
# Connections
|
# Connections
|
||||||
add_button.clicked.connect(partial(self._editor_add_table_row, table_widget))
|
add_button.clicked.connect(partial(self._editor_add_table_row, table_widget))
|
||||||
remove_button.clicked.connect(partial(self._editor_remove_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 ---
|
# --- Preset Population and Handling ---
|
||||||
def populate_presets(self):
|
def populate_presets(self):
|
||||||
@ -271,14 +271,12 @@ class PresetEditorWidget(QWidget):
|
|||||||
self.editor_preset_list.clear()
|
self.editor_preset_list.clear()
|
||||||
log.debug("Preset list cleared.")
|
log.debug("Preset list cleared.")
|
||||||
|
|
||||||
# Add the "Select a Preset" placeholder item
|
|
||||||
placeholder_item = QListWidgetItem("--- Select a Preset ---")
|
placeholder_item = QListWidgetItem("--- Select a Preset ---")
|
||||||
placeholder_item.setFlags(placeholder_item.flags() & ~Qt.ItemFlag.ItemIsSelectable & ~Qt.ItemFlag.ItemIsEditable)
|
placeholder_item.setFlags(placeholder_item.flags() & ~Qt.ItemFlag.ItemIsSelectable & ~Qt.ItemFlag.ItemIsEditable)
|
||||||
placeholder_item.setData(Qt.ItemDataRole.UserRole, "__PLACEHOLDER__")
|
placeholder_item.setData(Qt.ItemDataRole.UserRole, "__PLACEHOLDER__")
|
||||||
self.editor_preset_list.addItem(placeholder_item)
|
self.editor_preset_list.addItem(placeholder_item)
|
||||||
log.debug("Added '--- Select a Preset ---' placeholder item.")
|
log.debug("Added '--- Select a Preset ---' placeholder item.")
|
||||||
|
|
||||||
# Add LLM Option
|
|
||||||
llm_item = QListWidgetItem("- LLM Interpretation -")
|
llm_item = QListWidgetItem("- LLM Interpretation -")
|
||||||
llm_item.setData(Qt.ItemDataRole.UserRole, "__LLM__") # Special identifier
|
llm_item.setData(Qt.ItemDataRole.UserRole, "__LLM__") # Special identifier
|
||||||
self.editor_preset_list.addItem(llm_item)
|
self.editor_preset_list.addItem(llm_item)
|
||||||
@ -287,7 +285,6 @@ class PresetEditorWidget(QWidget):
|
|||||||
if not PRESETS_DIR.is_dir():
|
if not PRESETS_DIR.is_dir():
|
||||||
msg = f"Error: Presets directory not found at {PRESETS_DIR}"
|
msg = f"Error: Presets directory not found at {PRESETS_DIR}"
|
||||||
log.error(msg)
|
log.error(msg)
|
||||||
# Consider emitting a status signal to MainWindow?
|
|
||||||
return
|
return
|
||||||
|
|
||||||
presets = sorted([f for f in PRESETS_DIR.glob("*.json") if f.is_file() and not f.name.startswith('_')])
|
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:
|
else:
|
||||||
for preset_path in presets:
|
for preset_path in presets:
|
||||||
item = QListWidgetItem(preset_path.stem)
|
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)
|
self.editor_preset_list.addItem(item)
|
||||||
log.info(f"Loaded {len(presets)} presets into editor list.")
|
log.info(f"Loaded {len(presets)} presets into editor list.")
|
||||||
|
|
||||||
# Select the "Select a Preset" item by default
|
# Select the "Select a Preset" item by default
|
||||||
log.debug("Preset list populated. Selecting '--- Select a Preset ---' item.")
|
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 ---
|
# --- Preset Editor Methods ---
|
||||||
|
|
||||||
@ -335,7 +332,7 @@ class PresetEditorWidget(QWidget):
|
|||||||
combo_box.addItems(self._ftd_keys)
|
combo_box.addItems(self._ftd_keys)
|
||||||
else:
|
else:
|
||||||
log.warning("FILE_TYPE_DEFINITIONS keys not available for ComboBox in map_type_mapping.")
|
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)
|
table_widget.setCellWidget(row_count, 0, combo_box)
|
||||||
# Column 1: Input Keywords (QTableWidgetItem)
|
# Column 1: Input Keywords (QTableWidgetItem)
|
||||||
table_widget.setItem(row_count, 1, QTableWidgetItem(""))
|
table_widget.setItem(row_count, 1, QTableWidgetItem(""))
|
||||||
@ -358,9 +355,6 @@ class PresetEditorWidget(QWidget):
|
|||||||
if self._is_loading_editor: return
|
if self._is_loading_editor: return
|
||||||
self.editor_unsaved_changes = True
|
self.editor_unsaved_changes = True
|
||||||
self.editor_save_button.setEnabled(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):
|
def _connect_editor_change_signals(self):
|
||||||
"""Connect signals from all editor widgets to mark_editor_unsaved."""
|
"""Connect signals from all editor widgets to mark_editor_unsaved."""
|
||||||
@ -417,7 +411,6 @@ class PresetEditorWidget(QWidget):
|
|||||||
self.current_editing_preset_path = None
|
self.current_editing_preset_path = None
|
||||||
self.editor_unsaved_changes = False
|
self.editor_unsaved_changes = False
|
||||||
self.editor_save_button.setEnabled(False)
|
self.editor_save_button.setEnabled(False)
|
||||||
# self.window().setWindowTitle("Asset Processor Tool") # Reset window title (handled by MainWindow)
|
|
||||||
self._set_editor_enabled(False)
|
self._set_editor_enabled(False)
|
||||||
finally:
|
finally:
|
||||||
self._is_loading_editor = False
|
self._is_loading_editor = False
|
||||||
@ -465,7 +458,7 @@ class PresetEditorWidget(QWidget):
|
|||||||
else:
|
else:
|
||||||
log.warning("FILE_TYPE_DEFINITIONS keys not available for ComboBox in map_type_mapping during population.")
|
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)
|
self.editor_table_map_type_mapping.setCellWidget(i, 0, combo_box)
|
||||||
|
|
||||||
# Column 1: Input Keywords (QTableWidgetItem)
|
# Column 1: Input Keywords (QTableWidgetItem)
|
||||||
@ -514,7 +507,6 @@ class PresetEditorWidget(QWidget):
|
|||||||
self.current_editing_preset_path = file_path
|
self.current_editing_preset_path = file_path
|
||||||
self.editor_unsaved_changes = False
|
self.editor_unsaved_changes = False
|
||||||
self.editor_save_button.setEnabled(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.")
|
log.info(f"Preset '{file_path.name}' loaded into editor.")
|
||||||
except json.JSONDecodeError as json_err:
|
except json.JSONDecodeError as json_err:
|
||||||
log.error(f"Invalid JSON in {file_path.name}: {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()}")
|
log.debug(f"Loading preset for editing: {current_item.text()}")
|
||||||
preset_path = item_data
|
preset_path = item_data
|
||||||
self._load_preset_for_editing(preset_path)
|
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"
|
mode = "preset"
|
||||||
preset_name = self._last_valid_preset_name
|
preset_name = self._last_valid_preset_name
|
||||||
else:
|
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)
|
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_unsaved_changes = False
|
||||||
self.editor_save_button.setEnabled(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()
|
||||||
self.presets_changed_signal.emit() # Signal that presets changed
|
|
||||||
log.info("Preset saved successfully.")
|
log.info("Preset saved successfully.")
|
||||||
# Refresh list within the editor
|
|
||||||
self.populate_presets()
|
self.populate_presets()
|
||||||
# Reselect the saved item
|
# Reselect the saved item
|
||||||
items = self.editor_preset_list.findItems(self.current_editing_preset_path.stem, Qt.MatchFlag.MatchExactly)
|
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
|
if reply == QMessageBox.StandardButton.No: log.debug("Save As overwrite cancelled."); return False
|
||||||
log.info(f"Saving preset as: {save_path.name}")
|
log.info(f"Saving preset as: {save_path.name}")
|
||||||
with open(save_path, 'w', encoding='utf-8') as f: f.write(content_to_save)
|
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_unsaved_changes = False
|
||||||
self.editor_save_button.setEnabled(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()
|
||||||
self.presets_changed_signal.emit() # Signal change
|
|
||||||
log.info("Preset saved successfully (Save As).")
|
log.info("Preset saved successfully (Save As).")
|
||||||
# Refresh list and select the new item
|
# Refresh list and select the new item
|
||||||
self.populate_presets()
|
self.populate_presets()
|
||||||
@ -718,18 +707,15 @@ class PresetEditorWidget(QWidget):
|
|||||||
self._populate_editor_from_data(template_data)
|
self._populate_editor_from_data(template_data)
|
||||||
# Override specific fields for a new preset
|
# Override specific fields for a new preset
|
||||||
self.editor_preset_name.setText("NewPreset")
|
self.editor_preset_name.setText("NewPreset")
|
||||||
# self.window().setWindowTitle("Asset Processor Tool - New Preset*") # Handled by MainWindow
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(f"Error loading template preset file {TEMPLATE_PATH}: {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}")
|
QMessageBox.critical(self, "Error", f"Could not load template preset file:\n{TEMPLATE_PATH}\n\nError: {e}")
|
||||||
self._clear_editor()
|
self._clear_editor()
|
||||||
# self.window().setWindowTitle("Asset Processor Tool - New Preset*") # Handled by MainWindow
|
self.editor_supplier_name.setText("MySupplier")
|
||||||
self.editor_supplier_name.setText("MySupplier") # Set a default supplier name
|
|
||||||
else:
|
else:
|
||||||
log.warning("Presets/_template.json not found. Creating empty preset.")
|
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_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._set_editor_enabled(True)
|
||||||
self.editor_unsaved_changes = True
|
self.editor_unsaved_changes = True
|
||||||
self.editor_save_button.setEnabled(True)
|
self.editor_save_button.setEnabled(True)
|
||||||
@ -761,8 +747,7 @@ class PresetEditorWidget(QWidget):
|
|||||||
preset_path.unlink()
|
preset_path.unlink()
|
||||||
log.info("Preset deleted successfully.")
|
log.info("Preset deleted successfully.")
|
||||||
if self.current_editing_preset_path == preset_path: self._clear_editor()
|
if self.current_editing_preset_path == preset_path: self._clear_editor()
|
||||||
self.presets_changed_signal.emit() # Signal change
|
self.presets_changed_signal.emit()
|
||||||
# Refresh list
|
|
||||||
self.populate_presets()
|
self.populate_presets()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(f"Error deleting preset file {preset_path}: {e}")
|
log.exception(f"Error deleting preset file {preset_path}: {e}")
|
||||||
|
|||||||
@ -1,15 +1,14 @@
|
|||||||
import logging # Import logging
|
import logging
|
||||||
import time # For logging timestamps
|
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
|
from PySide6.QtGui import QColor
|
||||||
|
|
||||||
log = logging.getLogger(__name__) # Get logger
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Define colors for alternating asset groups
|
# Define colors for alternating asset groups
|
||||||
COLOR_ASSET_GROUP_1 = QColor("#292929") # Dark grey 1
|
COLOR_ASSET_GROUP_1 = QColor("#292929") # Dark grey 1
|
||||||
COLOR_ASSET_GROUP_2 = QColor("#343434") # Dark grey 2
|
COLOR_ASSET_GROUP_2 = QColor("#343434") # Dark grey 2
|
||||||
|
|
||||||
# Define text colors for statuses
|
|
||||||
class PreviewTableModel(QAbstractTableModel):
|
class PreviewTableModel(QAbstractTableModel):
|
||||||
"""
|
"""
|
||||||
Custom table model for the GUI preview table.
|
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._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._sorted_unique_assets = [] # Store sorted unique asset names for coloring
|
||||||
self._headers_simple = ["Input Path"]
|
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):
|
def set_simple_mode(self, enabled: bool):
|
||||||
"""Toggles the model between detailed and simple view modes."""
|
"""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}")
|
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:
|
if self._simple_mode != enabled:
|
||||||
log.info(f"[{time.time():.4f}][T:{thread_id}] Calling beginResetModel()...")
|
log.info(f"[{time.time():.4f}][T:{thread_id}] Calling beginResetModel()...")
|
||||||
@ -78,7 +77,6 @@ class PreviewTableModel(QAbstractTableModel):
|
|||||||
if parent.isValid():
|
if parent.isValid():
|
||||||
return 0
|
return 0
|
||||||
row_count = len(self._simple_data) if self._simple_mode else len(self._table_rows) # Use _table_rows for detailed mode
|
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
|
return row_count
|
||||||
|
|
||||||
def columnCount(self, parent=QModelIndex()):
|
def columnCount(self, parent=QModelIndex()):
|
||||||
@ -86,7 +84,6 @@ class PreviewTableModel(QAbstractTableModel):
|
|||||||
if parent.isValid():
|
if parent.isValid():
|
||||||
return 0
|
return 0
|
||||||
col_count = len(self._headers_simple) if self._simple_mode else len(self._headers_detailed) # Use updated headers_detailed
|
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
|
return col_count
|
||||||
|
|
||||||
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
|
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
|
||||||
@ -100,8 +97,7 @@ class PreviewTableModel(QAbstractTableModel):
|
|||||||
# --- Simple Mode ---
|
# --- Simple Mode ---
|
||||||
if self._simple_mode:
|
if self._simple_mode:
|
||||||
if row >= len(self._simple_data):
|
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
|
||||||
return None # Bounds check
|
|
||||||
source_asset_path = self._simple_data[row]
|
source_asset_path = self._simple_data[row]
|
||||||
if role == Qt.ItemDataRole.DisplayRole:
|
if role == Qt.ItemDataRole.DisplayRole:
|
||||||
if col == self.COL_SIMPLE_PATH:
|
if col == self.COL_SIMPLE_PATH:
|
||||||
@ -113,12 +109,10 @@ class PreviewTableModel(QAbstractTableModel):
|
|||||||
|
|
||||||
# --- Detailed Mode ---
|
# --- Detailed Mode ---
|
||||||
if row >= len(self._table_rows): # Use _table_rows
|
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
|
||||||
return None # Bounds check
|
|
||||||
row_data = self._table_rows[row] # Get data from the structured row
|
row_data = self._table_rows[row] # Get data from the structured row
|
||||||
|
|
||||||
# --- Handle Custom Internal Roles ---
|
# --- Handle Custom Internal Roles ---
|
||||||
# These roles are now handled by the proxy model based on the structured data
|
|
||||||
if role == self.ROLE_RAW_STATUS:
|
if role == self.ROLE_RAW_STATUS:
|
||||||
# Return status of the main file if it exists, otherwise a placeholder for additional rows
|
# Return status of the main file if it exists, otherwise a placeholder for additional rows
|
||||||
main_file = row_data.get('main_file')
|
main_file = row_data.get('main_file')
|
||||||
@ -132,7 +126,7 @@ class PreviewTableModel(QAbstractTableModel):
|
|||||||
main_file = row_data.get('main_file')
|
main_file = row_data.get('main_file')
|
||||||
if main_file:
|
if main_file:
|
||||||
raw_status = main_file.get('status', '[No Status]')
|
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
|
# Implement status text simplification
|
||||||
if raw_status == "Unmatched Extra":
|
if raw_status == "Unmatched Extra":
|
||||||
@ -268,14 +262,6 @@ class PreviewTableModel(QAbstractTableModel):
|
|||||||
return None
|
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
|
return None
|
||||||
@ -291,7 +277,7 @@ class PreviewTableModel(QAbstractTableModel):
|
|||||||
def set_data(self, data: list):
|
def set_data(self, data: list):
|
||||||
"""Sets the model's data, extracts simple data, and emits signals."""
|
"""Sets the model's data, extracts simple data, and emits signals."""
|
||||||
# Removed diagnostic import here
|
# 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}] --> Entered PreviewTableModel.set_data. Received {len(data)} items.")
|
||||||
log.info(f"[{time.time():.4f}][T:{thread_id}] Calling beginResetModel()...")
|
log.info(f"[{time.time():.4f}][T:{thread_id}] Calling beginResetModel()...")
|
||||||
self.beginResetModel()
|
self.beginResetModel()
|
||||||
@ -357,7 +343,7 @@ class PreviewTableModel(QAbstractTableModel):
|
|||||||
|
|
||||||
def clear_data(self):
|
def clear_data(self):
|
||||||
"""Clears the model's data."""
|
"""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.")
|
log.info(f"[{time.time():.4f}][T:{thread_id}] PreviewTableModel.clear_data called.")
|
||||||
self.set_data([])
|
self.set_data([])
|
||||||
|
|
||||||
@ -398,21 +384,18 @@ class PreviewSortFilterProxyModel(QSortFilterProxyModel):
|
|||||||
"""
|
"""
|
||||||
model = self.sourceModel()
|
model = self.sourceModel()
|
||||||
if not model:
|
if not model:
|
||||||
# log.debug("ProxyModel.lessThan: No source model.")
|
|
||||||
return super().lessThan(left, right) # Fallback if no source model
|
return super().lessThan(left, right) # Fallback if no source model
|
||||||
|
|
||||||
# If in simple mode, sort by the simple path column
|
# If in simple mode, sort by the simple path column
|
||||||
if isinstance(model, PreviewTableModel) and model._simple_mode:
|
if isinstance(model, PreviewTableModel) and model._simple_mode:
|
||||||
left_path = model.data(left.siblingAtColumn(model.COL_SIMPLE_PATH), Qt.ItemDataRole.DisplayRole)
|
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)
|
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 left_path: return True
|
||||||
if not right_path: return False
|
if not right_path: return False
|
||||||
return left_path < right_path
|
return left_path < right_path
|
||||||
|
|
||||||
|
|
||||||
# --- Detailed Mode Sorting ---
|
# --- Detailed Mode Sorting ---
|
||||||
# log.debug("ProxyModel.lessThan (Detailed Mode).")
|
|
||||||
# Get the full row data from the source model's _table_rows
|
# Get the full row data from the source model's _table_rows
|
||||||
left_row_data = model._table_rows[left.row()]
|
left_row_data = model._table_rows[left.row()]
|
||||||
right_row_data = model._table_rows[right.row()]
|
right_row_data = model._table_rows[right.row()]
|
||||||
|
|||||||
@ -3,16 +3,12 @@ from PySide6.QtWidgets import (QApplication, QWidget, QVBoxLayout, QLabel, QLine
|
|||||||
QFormLayout, QComboBox, QCheckBox, QSpinBox, QDoubleSpinBox)
|
QFormLayout, QComboBox, QCheckBox, QSpinBox, QDoubleSpinBox)
|
||||||
from PySide6.QtCore import Signal, Slot, QObject
|
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):
|
class RuleEditorWidget(QWidget):
|
||||||
"""
|
"""
|
||||||
A widget to display and edit hierarchical processing rules (Source, Asset, File).
|
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):
|
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.
|
parent: The parent widget.
|
||||||
"""
|
"""
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.asset_types = asset_types if asset_types else [] # Store asset types
|
self.asset_types = asset_types if asset_types else []
|
||||||
self.file_types = file_types if file_types else [] # Store file types
|
self.file_types = file_types if file_types else []
|
||||||
self.current_rule_type = None
|
self.current_rule_type = None
|
||||||
self.current_rule_object = None
|
self.current_rule_object = None
|
||||||
|
|
||||||
@ -65,7 +61,6 @@ class RuleEditorWidget(QWidget):
|
|||||||
editor_widget = self._create_editor_widget(attr_name, attr_value)
|
editor_widget = self._create_editor_widget(attr_name, attr_value)
|
||||||
if editor_widget:
|
if editor_widget:
|
||||||
self.form_layout.addRow(label, editor_widget)
|
self.form_layout.addRow(label, editor_widget)
|
||||||
# Connect signal to update rule object
|
|
||||||
self._connect_editor_signal(editor_widget, attr_name)
|
self._connect_editor_signal(editor_widget, attr_name)
|
||||||
|
|
||||||
def _create_editor_widget(self, attr_name, attr_value):
|
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
|
# 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':
|
if attr_value is None and attr_name == 'asset_type_override':
|
||||||
# Optionally add a placeholder like "<None>" or "<Default>"
|
# Optionally add a placeholder like "<None>" or "<Default>"
|
||||||
# widget.insertItem(0, "<Default>") # Example placeholder
|
|
||||||
widget.setCurrentIndex(-1) # No selection or placeholder
|
widget.setCurrentIndex(-1) # No selection or placeholder
|
||||||
elif attr_value in self.asset_types:
|
elif attr_value in self.asset_types:
|
||||||
widget.setCurrentText(attr_value)
|
widget.setCurrentText(attr_value)
|
||||||
@ -114,12 +108,6 @@ class RuleEditorWidget(QWidget):
|
|||||||
widget = QLineEdit()
|
widget = QLineEdit()
|
||||||
widget.setText(str(attr_value) if attr_value is not None else "")
|
widget.setText(str(attr_value) if attr_value is not None else "")
|
||||||
return widget
|
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:
|
else:
|
||||||
# For unsupported types, just display the value
|
# For unsupported types, just display the value
|
||||||
label = QLabel(str(attr_value))
|
label = QLabel(str(attr_value))
|
||||||
@ -140,7 +128,6 @@ class RuleEditorWidget(QWidget):
|
|||||||
elif isinstance(editor_widget, QComboBox):
|
elif isinstance(editor_widget, QComboBox):
|
||||||
# Use currentTextChanged to get the string value directly
|
# Use currentTextChanged to get the string value directly
|
||||||
editor_widget.currentTextChanged.connect(lambda text: self._update_rule_attribute(attr_name, text))
|
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):
|
def _update_rule_attribute(self, attr_name, value):
|
||||||
"""
|
"""
|
||||||
@ -162,7 +149,6 @@ class RuleEditorWidget(QWidget):
|
|||||||
converted_value = value # Fallback for other types
|
converted_value = value # Fallback for other types
|
||||||
setattr(self.current_rule_object, attr_name, converted_value)
|
setattr(self.current_rule_object, attr_name, converted_value)
|
||||||
self.rule_updated.emit(self.current_rule_object)
|
self.rule_updated.emit(self.current_rule_object)
|
||||||
# print(f"Updated {attr_name} to {converted_value} in {self.current_rule_type}") # Debugging
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# Handle potential conversion errors (e.g., non-numeric input for int/float)
|
# Handle potential conversion errors (e.g., non-numeric input for int/float)
|
||||||
print(f"Error converting value '{value}' for attribute '{attr_name}'")
|
print(f"Error converting value '{value}' for attribute '{attr_name}'")
|
||||||
@ -202,8 +188,8 @@ if __name__ == '__main__':
|
|||||||
file_setting_y: str = "default_file_string"
|
file_setting_y: str = "default_file_string"
|
||||||
|
|
||||||
# Example usage: Provide asset types during instantiation
|
# Example usage: Provide asset types during instantiation
|
||||||
asset_types_from_config = ["Surface", "Model", "Decal", "Atlas", "UtilityMap"] # 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"] # 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"]
|
||||||
editor = RuleEditorWidget(asset_types=asset_types_from_config, file_types=file_types_from_config)
|
editor = RuleEditorWidget(asset_types=asset_types_from_config, file_types=file_types_from_config)
|
||||||
|
|
||||||
# Test loading different rule types
|
# Test loading different rule types
|
||||||
@ -212,8 +198,6 @@ if __name__ == '__main__':
|
|||||||
file_rule = FileRule()
|
file_rule = FileRule()
|
||||||
|
|
||||||
editor.load_rule(source_rule, "SourceRule")
|
editor.load_rule(source_rule, "SourceRule")
|
||||||
# editor.load_rule(asset_rule, "AssetRule")
|
|
||||||
# editor.load_rule(file_rule, "FileRule")
|
|
||||||
|
|
||||||
|
|
||||||
editor.show()
|
editor.show()
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal, Slot
|
from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal, Slot
|
||||||
from PySide6.QtGui import QIcon # Assuming we might want icons later
|
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):
|
class RuleHierarchyModel(QAbstractItemModel):
|
||||||
"""
|
"""
|
||||||
@ -57,15 +57,6 @@ class RuleHierarchyModel(QAbstractItemModel):
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
# Add other roles as needed (e.g., Qt.ItemDataRole.DecorationRole for icons)
|
# 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
|
return None
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user