From 1ac23eb25217e4b8de1b08b725045074585a114a Mon Sep 17 00:00:00 2001 From: Rusfort Date: Thu, 1 May 2025 22:54:05 +0200 Subject: [PATCH] Preferences, Config.py Migration, Bug Fixes, and Documention updates --- .../04_Configuration_and_Presets.md | 9 +- Documentation/01_User_Guide/05_Usage_GUI.md | 6 +- .../02_Developer_Guide/01_Architecture.md | 8 +- .../04_Configuration_System_and_Presets.md | 7 +- .../05_Processing_Pipeline.md | 6 +- .../02_Developer_Guide/06_GUI_Internals.md | 10 +- ProjectNotes/PreferencesLayout.md | 52 + config/app_settings.json | 47 +- gui/config_editor_dialog.py | 1306 ++++++++++++----- gui/main_window.py | 155 +- gui/unified_view_model.py | 36 +- main.py | 42 +- processing_engine.py | 50 +- 13 files changed, 1236 insertions(+), 498 deletions(-) create mode 100644 ProjectNotes/PreferencesLayout.md diff --git a/Documentation/01_User_Guide/04_Configuration_and_Presets.md b/Documentation/01_User_Guide/04_Configuration_and_Presets.md index a9ba0bc..e869545 100644 --- a/Documentation/01_User_Guide/04_Configuration_and_Presets.md +++ b/Documentation/01_User_Guide/04_Configuration_and_Presets.md @@ -6,13 +6,18 @@ This document explains how to configure the Asset Processor Tool and use presets The tool's core settings are now stored in `config/app_settings.json`. This JSON file contains the base configuration for the application. -The `configuration.py` module is responsible for loading the settings from `app_settings.json` and merging them with the rules from the selected preset file. +The `configuration.py` module is responsible for loading the settings from `app_settings.json` (including loading and saving the JSON content), merging them with the rules from the selected preset file, and providing the base configuration via the `load_base_config()` function. Note that the old `config.py` file has been deleted. + +The `app_settings.json` file is structured into several key sections, including: +* `FILE_TYPE_DEFINITIONS`: Defines known file types (like different texture maps, models, etc.) and their properties. Each definition now includes a `"standard_type"` key for aliasing to a common type and a `"bit_depth_rule"` key specifying how to handle bit depth for this file type. The separate `MAP_BIT_DEPTH_RULES` section has been removed. +* `ASSET_TYPE_DEFINITIONS`: Defines known asset types (like Surface, Model, Decal) and their properties. +* `MAP_MERGE_RULES`: Defines how multiple input maps can be merged into a single output map (e.g., combining Normal and Roughness into one). ## GUI Configuration Editor You can modify the `app_settings.json` file using the built-in GUI editor. Access it via the **Edit** -> **Preferences...** menu. -This editor allows you to view and change the core application settings. Note that any changes made through the GUI editor require an application restart to take effect. +This editor provides a tabbed interface (e.g., "General", "Output & Naming") to view and change the core application settings defined in `app_settings.json`. Settings in the editor directly correspond to the structure and values within the JSON file. Note that any changes made through the GUI editor require an application restart to take effect. *(Ideally, a screenshot of the GUI Configuration Editor would be included here.)* diff --git a/Documentation/01_User_Guide/05_Usage_GUI.md b/Documentation/01_User_Guide/05_Usage_GUI.md index e54ce34..3995601 100644 --- a/Documentation/01_User_Guide/05_Usage_GUI.md +++ b/Documentation/01_User_Guide/05_Usage_GUI.md @@ -21,8 +21,8 @@ python -m gui.main_window * **Preset Selector:** Choose the preset to use for *processing* the current queue. * **Output Directory:** Set the output path (defaults to `config/app_settings.json`, use "Browse...") * **Drag and Drop Area:** Add asset `.zip`, `.rar`, `.7z` files, or folders by dragging and dropping them here. - * **Preview Table:** Shows queued assets. Initially, this area displays a message prompting you to select a preset. Once a preset is selected from the Preset List, the detailed file preview will load here. The mode of the preview depends on the "View" menu: - * **Detailed Preview (Default):** Lists all files, predicted status (`Mapped`, `Model`, `Extra`, `Unrecognised`, `Ignored`, `Error`), output name, etc., based on the selected *processing* preset. Text colors are applied to cells based on the status of the individual file they represent. Rows use alternating background colors per asset group for visual separation. + * **Preview Table:** Shows queued assets in a hierarchical view (Source -> Asset -> File). Initially, this area displays a message prompting you to select a preset. Once a preset is selected from the Preset List, the detailed file preview will load here. The mode of the preview depends on the "View" menu: + * **Detailed Preview (Default):** Lists all files, predicted status (`Mapped`, `Model`, `Extra`, `Unrecognised`, `Ignored`, `Error`), output name, etc., based on the selected *processing* preset. The columns displayed are: Name, Target Asset, Supplier, Asset Type, Item Type. The "Target Asset" column stretches to fill available space, while others resize to content. The previous "Status" and "Output Path" columns have been removed. Text colors are applied to cells based on the status of the individual file they represent. Rows use alternating background colors per asset group for visual separation. * **Simple View (Preview Disabled):** Lists only top-level input asset paths. * **Progress Bar:** Shows overall processing progress. * **Blender Post-Processing:** Checkbox to enable Blender scripts. If enabled, shows fields and browse buttons for target `.blend` files (defaults from `config/app_settings.json`). @@ -36,7 +36,7 @@ python -m gui.main_window ## GUI Configuration Editor -Access the GUI Configuration Editor via the **Edit** -> **Preferences...** menu. This dialog allows you to directly edit the `config/app_settings.json` file, which contains the core application settings. +Access the GUI Configuration Editor via the **Edit** -> **Preferences...** menu. This dialog allows you to directly edit the `config/app_settings.json` file, which contains the core application settings. The editor uses a tabbed layout (e.g., "General", "Output & Naming") to organize settings. Any changes made in the GUI Configuration Editor require you to restart the application for them to take effect. diff --git a/Documentation/02_Developer_Guide/01_Architecture.md b/Documentation/02_Developer_Guide/01_Architecture.md index 370b7d1..2475fb6 100644 --- a/Documentation/02_Developer_Guide/01_Architecture.md +++ b/Documentation/02_Developer_Guide/01_Architecture.md @@ -27,16 +27,16 @@ This hierarchy allows for fine-grained control over processing parameters. The G ## Core Components -* `config.py`: Defines core, global settings, constants, and centralized definitions for allowed asset and file types (`ASSET_TYPE_DEFINITIONS`, `FILE_TYPE_DEFINITIONS`), including metadata like colors and descriptions. +* `config/app_settings.json`: Defines core, global settings, constants, and centralized definitions for allowed asset and file types (`ASSET_TYPE_DEFINITIONS`, `FILE_TYPE_DEFINITIONS`), including metadata like colors and descriptions. This replaces the old `config.py` file. * `config/suppliers.json`: A persistent JSON file storing known supplier names for GUI auto-completion. * `Presets/*.json`: Supplier-specific JSON files defining rules for file interpretation and initial prediction. -* `configuration.py` (`Configuration` class): Loads `config.py` settings and merges them with a selected preset, pre-compiling regex patterns for efficiency. This static configuration is used by the processing engine. +* `configuration.py` (`Configuration` class): Loads `config/app_settings.json` settings and merges them with a selected preset, pre-compiling regex patterns for efficiency. This static configuration is used by the processing engine. * `rule_structure.py`: Defines the `SourceRule`, `AssetRule`, and `FileRule` dataclasses used to represent the hierarchical processing rules. * `gui/`: Directory containing modules for the Graphical User Interface (GUI), built with PySide6. The GUI is responsible for generating and managing the `SourceRule` hierarchy via the Unified View, accumulating prediction results, and interacting with background handlers (`ProcessingHandler`, `PredictionHandler`). * `unified_view_model.py`: Implements the `QAbstractItemModel` for the Unified Hierarchical View, holding the `SourceRule` data, handling inline editing (including direct model restructuring for `target_asset_name_override`), and managing row coloring based on config definitions. * `delegates.py`: Contains custom `QStyledItemDelegate` implementations for inline editing in the Unified View, including the new `SupplierSearchDelegate` for supplier name auto-completion and management. - * `prediction_handler.py`: Generates the initial `SourceRule` hierarchy with predicted values for a single input source based on its files and the selected preset. -* `processing_engine.py` (`ProcessingEngine` class): The new core component that executes the processing pipeline for a single `SourceRule` object using the static `Configuration`. It contains no internal prediction or fallback logic. + * `prediction_handler.py`: Generates the initial `SourceRule` hierarchy with predicted values for a single input source based on its files and the selected preset. It uses the `"standard_type"` from the configuration's `FILE_TYPE_DEFINITIONS` to populate `FileRule.standard_map_type` and implements a two-pass classification logic to handle and prioritize bit-depth variants (e.g., `_DISP16_` vs `_DISP_`). + * `processing_engine.py` (`ProcessingEngine` class): The new core component that executes the processing pipeline for a single `SourceRule` object using the static `Configuration`. A new instance is created per task for state isolation. It contains no internal prediction or fallback logic. Supplier overrides from the GUI are correctly preserved and used by the engine for output path generation and metadata. * `asset_processor.py` (`AssetProcessor` class): The older processing engine, kept for reference but not used in the main processing flow. * `main.py`: The entry point for the Command-Line Interface (CLI). It handles argument parsing, logging, parallel processing orchestration, and triggering Blender scripts. It now orchestrates processing by passing `SourceRule` objects to the `ProcessingEngine`. * `monitor.py`: Implements the directory monitoring feature using `watchdog`. diff --git a/Documentation/02_Developer_Guide/04_Configuration_System_and_Presets.md b/Documentation/02_Developer_Guide/04_Configuration_System_and_Presets.md index 18bbad2..26a9234 100644 --- a/Documentation/02_Developer_Guide/04_Configuration_System_and_Presets.md +++ b/Documentation/02_Developer_Guide/04_Configuration_System_and_Presets.md @@ -6,10 +6,10 @@ This document provides technical details about the configuration system and the The tool utilizes a two-tiered configuration system managed by the `configuration.py` module: -1. **Application Settings (`config/app_settings.json`):** This JSON file defines the core global default settings, constants, and rules that apply generally across different asset sources. Examples include default output paths, standard image resolutions, map merge rules, output format rules, Blender executable paths, and default map types. It also centrally defines metadata for allowed asset and file types. +1. **Application Settings (`config/app_settings.json`):** This JSON file defines the core global default settings, constants, and rules that apply generally across different asset sources. Examples include default output paths, standard image resolutions, map merge rules, output format rules, Blender executable paths, and default map types. It also centrally defines metadata for allowed asset and file types. Key sections include `FILE_TYPE_DEFINITIONS`, `ASSET_TYPE_DEFINITIONS`, and `MAP_MERGE_RULES`. 2. **Preset Files (`Presets/*.json`):** These JSON files define supplier-specific rules and overrides. They contain patterns (often regular expressions) to interpret filenames, classify map types, handle variants, define naming conventions, and specify other source-specific behaviors. -The `configuration.py` module is responsible for loading the base settings from `config/app_settings.json` and then merging them with the rules from the selected preset file. Preset values generally override core settings where applicable. +The `configuration.py` module is responsible for loading the base settings from `config/app_settings.json` (including loading and saving the JSON content), merging them with the rules from the selected preset file, and providing the base configuration via the `load_base_config()` function. Preset values generally override core settings where applicable. Note that the old `config.py` file has been deleted. ## Supplier Management (`config/suppliers.json`) @@ -37,7 +37,8 @@ An instance of `Configuration` is created within each worker process (`main.proc The GUI includes a dedicated editor for modifying the `config/app_settings.json` file. This is implemented in `gui/config_editor_dialog.py`. * **Purpose:** Provides a user-friendly interface for viewing and editing the core application settings defined in `app_settings.json`. -* **Implementation:** The dialog loads the JSON content of `app_settings.json`, presents it in an editable format (likely using standard GUI widgets mapped to the JSON structure), and saves the changes back to the file. +* **Implementation:** The dialog loads the JSON content of `app_settings.json`, presents it in a tabbed layout ("General", "Output & Naming", etc.) using standard GUI widgets mapped to the JSON structure, and saves the changes back to the file. It supports editing basic fields, tables for definitions (`FILE_TYPE_DEFINITIONS`, `ASSET_TYPE_DEFINITIONS`), and a list/detail view for merge rules (`MAP_MERGE_RULES`). The definitions tables include dynamic color editing features. +* **Limitations:** Currently, editing complex fields like `IMAGE_RESOLUTIONS` or the full details of `MAP_MERGE_RULES` via the UI is not fully supported. * **Note:** Changes made through the GUI editor are written directly to `config/app_settings.json` but require an application restart to be loaded and applied by the `Configuration` class. ## Preset File Structure (`Presets/*.json`) diff --git a/Documentation/02_Developer_Guide/05_Processing_Pipeline.md b/Documentation/02_Developer_Guide/05_Processing_Pipeline.md index f2db9b2..f086c81 100644 --- a/Documentation/02_Developer_Guide/05_Processing_Pipeline.md +++ b/Documentation/02_Developer_Guide/05_Processing_Pipeline.md @@ -1,6 +1,6 @@ # Developer Guide: Processing Pipeline -This document details the step-by-step technical process executed by the `ProcessingEngine` class (`processing_engine.py`) when processing a single asset. +This document details the step-by-step technical process executed by the `ProcessingEngine` class (`processing_engine.py`) when processing a single asset. A new instance of `ProcessingEngine` is created for each processing task to ensure state isolation. The `ProcessingEngine.process()` method orchestrates the following pipeline based *solely* on the provided `SourceRule` object and the static `Configuration` object passed during engine initialization. It contains no internal prediction, classification, or fallback logic. All necessary overrides and static configuration values are accessed directly from these inputs. @@ -21,7 +21,7 @@ The pipeline steps are: * Sorts potential map variants based on the order provided in the `SourceRule` or static configuration. 4. **Base Metadata Determination (`_determine_base_metadata`, `_determine_single_asset_metadata`)**: - * Determines the base asset name, category, and archetype using the explicit values provided in the input `SourceRule` object and the static configuration from the `Configuration` object. Overrides (like `supplier_identifier`, `asset_type`, and `asset_name_override`) are taken directly from the `SourceRule`. + * Determines the base asset name, category, and archetype using the explicit values provided in the input `SourceRule` object and the static configuration from the `Configuration` object. Overrides (like `supplier_identifier`, `asset_type`, and `asset_name_override`), including supplier overrides from the GUI, are taken directly from the `SourceRule`. 5. **Skip Check**: * If the `overwrite` flag (passed during initialization) is `False`, the tool checks if the final output directory for the determined asset name already exists and contains a `metadata.json` file. @@ -57,7 +57,7 @@ The pipeline steps are: * Writes this collected data into the `metadata.json` file within the temporary workspace using `json.dump`. 9. **Output Organization (`_organize_output_files`)**: - * Creates the final structured output directory: `///`. + * Creates the final structured output directory: `///`. The `supplier_name` used here is derived from the `SourceRule`, ensuring that supplier overrides from the GUI are respected in the output path. * Creates subdirectories `Extra/`, `Unrecognised/`, and `Ignored/` within the asset directory. * Moves the processed maps, merged maps, model files, `metadata.json`, and files classified as Extra, Unrecognised, or Ignored from the temporary workspace into their respective locations in the final output directory structure. diff --git a/Documentation/02_Developer_Guide/06_GUI_Internals.md b/Documentation/02_Developer_Guide/06_GUI_Internals.md index ed9d214..545221e 100644 --- a/Documentation/02_Developer_Guide/06_GUI_Internals.md +++ b/Documentation/02_Developer_Guide/06_GUI_Internals.md @@ -40,12 +40,15 @@ The GUI includes an integrated preset editor panel. This allows users to interac ## Unified Hierarchical View (`gui/unified_view_model.py`, `gui/delegates.py`) -The core of the GUI's rule editing interface is the Unified Hierarchical View, implemented using a `QTreeView` with a custom model and delegates. +## Unified Hierarchical View (`gui/unified_view_model.py`, `gui/delegates.py`, `gui/main_window.py`) + +The core of the GUI's rule editing interface is the Unified Hierarchical View, implemented using a `QTreeView` with a custom model and delegates. This view is managed within the `MainWindow`. * **`Unified View Model` (`gui/unified_view_model.py`):** This class implements a `QAbstractItemModel` to expose the structure of a list of `SourceRule` objects (Source -> Asset -> File) to the `QTreeView`. It holds the `SourceRule` data that is the single source of truth for the GUI's processing rules. It provides data and flags for display in multiple columns and supports inline editing of specific rule attributes (e.g., asset type, item type override, target asset name override) by interacting with delegates. + * **Column Order and Resizing:** The view currently displays the following columns in order: Name, Target Asset, Supplier, Asset Type, Item Type. The "Target Asset" column is set to stretch to fill available space, while other columns resize to their contents. The previous "Status" and "Output Path" columns have been removed. * **Direct Model Restructuring:** The `setData` method now includes logic to directly restructure the underlying `SourceRule` hierarchy when the `target_asset_name_override` field of a `FileRule` is edited. This involves moving the `FileRule` to a different `AssetRule` (creating a new one if necessary) and removing the old `AssetRule` if it becomes empty. This replaces the previous mechanism of re-running prediction after an edit. * **Row Coloring:** Row background colors are dynamically determined based on the `asset_type` (for `AssetRule`s) and `item_type` or `item_type_override` (for `FileRule`s), using the color metadata defined in the `ASSET_TYPE_DEFINITIONS` and `FILE_TYPE_DEFINITIONS` dictionaries sourced from the configuration loaded by `configuration.py` (which includes data from `config/app_settings.json`). `SourceRule` rows have a fixed color. - * **`Delegates` (`gui/delegates.py`):** This module contains custom `QStyledItemDelegate` implementations used by the `QTreeView` to provide inline editors for specific data types or rule attributes. +* **`Delegates` (`gui/delegates.py`):** This module contains custom `QStyledItemDelegate` implementations used by the `QTreeView` to provide inline editors for specific data types or rule attributes. * **`ComboBoxDelegate`:** Used for selecting from predefined lists (e.g., allowed asset types, allowed file types sourced from the configuration loaded by `configuration.py`). * **`LineEditDelegate`:** Used for free-form text editing (e.g., target asset name override). * **`SupplierSearchDelegate`:** A new delegate used for the "Supplier" column. It provides a `QLineEdit` with auto-completion suggestions loaded from `config/suppliers.json`. It also handles adding new, unique supplier names entered by the user to the list and saving the updated list back to the JSON file. @@ -84,7 +87,8 @@ The GUI provides a "Cancel" button to stop ongoing processing. The `ProcessingHa A dedicated dialog, implemented in `gui/config_editor_dialog.py`, provides a graphical interface for editing the core application settings stored in `config/app_settings.json`. -* **Functionality:** This dialog loads the current content of `config/app_settings.json`, presents it in an editable format (likely using standard Qt widgets), and allows the user to save modifications back to the file. +* **Functionality:** This dialog loads the current content of `config/app_settings.json` and presents it in a tabbed layout (e.g., "General", "Output & Naming") using standard GUI widgets mapped to the JSON structure. It supports editing basic fields, tables for definitions (`FILE_TYPE_DEFINITIONS`, `ASSET_TYPE_DEFINITIONS`), and a list/detail view for merge rules (`MAP_MERGE_RULES`). The definitions tables include dynamic color editing features. +* **Limitations:** Currently, editing complex fields like `IMAGE_RESOLUTIONS` or the full details of `MAP_MERGE_RULES` via the UI is not fully supported. * **Integration:** The `MainWindow` is responsible for creating and displaying an instance of this dialog when the user selects the "Edit" -> "Preferences..." menu option. * **Persistence:** Changes saved via this editor are written directly to the `config/app_settings.json` file, ensuring they persist across application sessions. However, the `Configuration` class loads settings at application startup, so a restart is required for changes made in the editor to take effect in the application's processing logic. diff --git a/ProjectNotes/PreferencesLayout.md b/ProjectNotes/PreferencesLayout.md new file mode 100644 index 0000000..8dc51fb --- /dev/null +++ b/ProjectNotes/PreferencesLayout.md @@ -0,0 +1,52 @@ +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". \ No newline at end of file diff --git a/config/app_settings.json b/config/app_settings.json index 5eecb19..7d4d691 100644 --- a/config/app_settings.json +++ b/config/app_settings.json @@ -2,7 +2,7 @@ "ASSET_TYPE_DEFINITIONS": { "Surface": { "description": "Standard PBR material set for a surface.", - "color": "#87CEEB", + "color": "#1f3e5d", "examples": [ "WoodFloor01", "MetalPlate05" @@ -44,7 +44,7 @@ "FILE_TYPE_DEFINITIONS": { "MAP_COL": { "description": "Color/Albedo Map", - "color": "#FFFFE0", + "color": "#3d3021", "examples": [ "_col.", "_basecolor." @@ -54,7 +54,7 @@ }, "MAP_NRM": { "description": "Normal Map", - "color": "#E6E6FA", + "color": "#23263d", "examples": [ "_nrm.", "_normal." @@ -64,7 +64,7 @@ }, "MAP_METAL": { "description": "Metalness Map", - "color": "#C0C0C0", + "color": "#1f1f1f", "examples": [ "_metal.", "_met." @@ -74,7 +74,7 @@ }, "MAP_ROUGH": { "description": "Roughness Map", - "color": "#A0522D", + "color": "#3d1f11", "examples": [ "_rough.", "_rgh." @@ -84,7 +84,7 @@ }, "MAP_AO": { "description": "Ambient Occlusion Map", - "color": "#A9A9A9", + "color": "#3d3d3d", "examples": [ "_ao.", "_ambientocclusion." @@ -94,7 +94,7 @@ }, "MAP_DISP": { "description": "Displacement/Height Map", - "color": "#FFB6C1", + "color": "#35343d", "examples": [ "_disp.", "_height." @@ -104,7 +104,7 @@ }, "MAP_REFL": { "description": "Reflection/Specular Map", - "color": "#E0FFFF", + "color": "#363d3d", "examples": [ "_refl.", "_specular." @@ -114,7 +114,7 @@ }, "MAP_SSS": { "description": "Subsurface Scattering Map", - "color": "#FFDAB9", + "color": "#3d342c", "examples": [ "_sss.", "_subsurface." @@ -124,7 +124,7 @@ }, "MAP_FUZZ": { "description": "Fuzz/Sheen Map", - "color": "#FFA07A", + "color": "#3d261d", "examples": [ "_fuzz.", "_sheen." @@ -134,7 +134,7 @@ }, "MAP_IDMAP": { "description": "ID Map (for masking)", - "color": "#F08080", + "color": "#3d2121", "examples": [ "_id.", "_matid." @@ -144,7 +144,7 @@ }, "MAP_MASK": { "description": "Generic Mask Map", - "color": "#FFFFFF", + "color": "#3d3d3d", "examples": [ "_mask." ], @@ -153,7 +153,7 @@ }, "MAP_IMPERFECTION": { "description": "Imperfection Map (scratches, dust)", - "color": "#F0E68C", + "color": "#3d3a24", "examples": [ "_imp.", "_imperfection." @@ -163,30 +163,33 @@ }, "MODEL": { "description": "3D Model File", - "color": "#FFA500", + "color": "#3d2700", "examples": [ ".fbx", ".obj" ], - "bit_depth_rule": "respect" + "standard_type": "", + "bit_depth_rule": "" }, "EXTRA": { "description": "Non-standard/Unclassified File", - "color": "#778899", + "color": "#2f363d", "examples": [ ".txt", ".zip" ], - "bit_depth_rule": "respect" + "standard_type": "", + "bit_depth_rule": "" }, "FILE_IGNORE": { "description": "File to be ignored", - "color": "#2F4F4F", + "color": "#243d3d", "examples": [ "Thumbs.db", ".DS_Store" ], - "bit_depth_rule": "respect" + "standard_type": "", + "bit_depth_rule": "" } }, "TARGET_FILENAME_PATTERN": "{base_name}_{map_type}_{resolution}.{ext}", @@ -203,7 +206,9 @@ "IDMAP", "MASK" ], - "RESPECT_VARIANT_MAP_TYPES": "COL", + "RESPECT_VARIANT_MAP_TYPES": [ + "COL" + ], "EXTRA_FILES_SUBDIR": "Extra", "OUTPUT_BASE_DIR": "../Asset_Processor_Output", "METADATA_FILENAME": "metadata.json", @@ -220,7 +225,7 @@ "1K": 1024 }, "ASPECT_RATIO_DECIMALS": 2, - "OUTPUT_FORMAT_16BIT_PRIMARY": "png", + "OUTPUT_FORMAT_16BIT_PRIMARY": "exr", "OUTPUT_FORMAT_16BIT_FALLBACK": "png", "OUTPUT_FORMAT_8BIT": "png", "MAP_MERGE_RULES": [ diff --git a/gui/config_editor_dialog.py b/gui/config_editor_dialog.py index 08cacef..4cd1e86 100644 --- a/gui/config_editor_dialog.py +++ b/gui/config_editor_dialog.py @@ -1,16 +1,17 @@ # gui/config_editor_dialog.py import json -from PySide6.QtWidgets import ( # Changed from PyQt5 +from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget, QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox, QCheckBox, - QPushButton, QFileDialog, QLabel, QTableWidget, # Removed QColorDialog + QPushButton, QFileDialog, QLabel, QTableWidget, QTableWidgetItem, QDialogButtonBox, QMessageBox, QListWidget, - QListWidgetItem, QFormLayout, QGroupBox + QListWidgetItem, QFormLayout, QGroupBox, QStackedWidget, + QHeaderView, QSizePolicy # Added QHeaderView and QSizePolicy ) -from PySide6.QtGui import QColor # Changed from PyQt5 -from PySide6.QtCore import Qt # Changed from PyQt5 -from PySide6.QtWidgets import QColorDialog # Import QColorDialog separately for PySide6 +from PySide6.QtGui import QColor, QPainter +from PySide6.QtCore import Qt, QEvent +from PySide6.QtWidgets import QColorDialog, QStyledItemDelegate, QApplication # Assuming configuration.py is in the parent directory or accessible # Adjust import path if necessary @@ -21,6 +22,47 @@ except ImportError: from ..configuration import load_base_config, save_base_config +# --- Custom Delegate for Color Editing --- +class ColorDelegate(QStyledItemDelegate): + def paint(self, painter: QPainter, option, index): + # Get color string from model data (EditRole is where we store it) + color_str = index.model().data(index, Qt.EditRole) + if isinstance(color_str, str) and color_str.startswith('#'): + color = QColor(color_str) + if color.isValid(): + # Fill the background with the color + painter.fillRect(option.rect, color) + # Optionally draw text (e.g., the hex code) centered + # painter.drawText(option.rect, Qt.AlignCenter, color_str) + return # Prevent default painting + + # Fallback to default painting if no valid color + super().paint(painter, option, index) + + def createEditor(self, parent, option, index): + # No editor needed, handled by editorEvent + return None + + def editorEvent(self, event, model, option, index): + if event.type() == QEvent.MouseButtonPress and event.button() == Qt.LeftButton: + current_color_str = model.data(index, Qt.EditRole) + initial_color = QColor(current_color_str) if isinstance(current_color_str, str) else Qt.white + + color = QColorDialog.getColor(initial_color, None, "Select Color") + + if color.isValid(): + new_color_str = color.name() # Get #RRGGBB format + model.setData(index, new_color_str, Qt.EditRole) + # Trigger update for the background role as well, although paint should handle it + # model.setData(index, QColor(new_color_str), Qt.BackgroundRole) + return True # Event handled + return False # Event not handled + + def setModelData(self, editor, model, index): + # Not strictly needed as setData is called in editorEvent + pass + + class ConfigEditorDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) @@ -41,17 +83,13 @@ class ConfigEditorDialog(QDialog): self.load_settings() # Load settings FIRST self.create_tabs() # THEN create widgets based on settings - # self.populate_widgets() # Removed as population is now in load_settings + self.populate_widgets_from_settings() # Populate widgets after creation def load_settings(self): - """Loads settings from the configuration file and populates widgets.""" + """Loads settings from the configuration file.""" try: self.settings = load_base_config() print("Configuration loaded successfully.") # Debug print - - # Populate widgets after loading settings and creating tabs - self.populate_widgets_from_settings() - except Exception as e: QMessageBox.critical(self, "Loading Error", f"Failed to load configuration: {e}") self.settings = {} # Use empty settings on failure @@ -100,26 +138,7 @@ class ConfigEditorDialog(QDialog): layout_to_add = None # Use this for widgets needing extra controls (like browse button) if isinstance(value, str): - if 'PATH' in key.upper() or 'DIR' in key.upper() or key == "BLENDER_EXECUTABLE_PATH": - widget = QLineEdit(value) - button = QPushButton("Browse...") - # Determine if it's a file or directory browse - is_dir = 'DIR' in key.upper() - button.clicked.connect(lambda checked, w=widget, k=full_key, is_dir=is_dir: self.browse_path(w, k, is_dir)) - h_layout = QHBoxLayout() - h_layout.addWidget(widget) - h_layout.addWidget(button) - layout_to_add = h_layout - elif 'COLOR' in key.upper() or 'COLOUR' in key.upper(): - widget = QLineEdit(value) - button = QPushButton("Pick Color...") - button.clicked.connect(lambda checked, w=widget: self.pick_color(w)) - h_layout = QHBoxLayout() - h_layout.addWidget(widget) - h_layout.addWidget(button) - layout_to_add = h_layout - else: - widget = QLineEdit(value) + widget = QLineEdit(value) elif isinstance(value, int): widget = QSpinBox() widget.setRange(-2147483648, 2147483647) @@ -131,265 +150,631 @@ class ConfigEditorDialog(QDialog): elif isinstance(value, bool): widget = QCheckBox() widget.setChecked(value) - elif isinstance(value, list) and key != "MAP_MERGE_RULES": # Handle simple lists (excluding complex ones) - # Assuming list of strings or simple types + elif isinstance(value, list): # Handle simple lists as comma-separated strings widget = QLineEdit(", ".join(map(str, value))) # Complex dicts/lists like ASSET_TYPE_DEFINITIONS, MAP_MERGE_RULES etc. are handled in dedicated methods - if widget or layout_to_add: - if layout_to_add: - parent_layout.addRow(label, layout_to_add) - else: - parent_layout.addRow(label, widget) - - # Store reference using the full key only if a widget was created - if widget: - self.widgets[full_key] = widget + if widget: + parent_layout.addRow(label, widget) + self.widgets[full_key] = widget else: # Optionally handle unsupported types or log a warning # print(f"Skipping widget creation for key '{full_key}' with unsupported type: {type(value)}") pass - def populate_definitions_tab(self, layout): - """Populates the Definitions tab.""" - # Reuse existing methods for Asset and File Type Definitions - if "ASSET_TYPE_DEFINITIONS" in self.settings: - group = QGroupBox("Asset Type Definitions") - group_layout = QVBoxLayout(group) - self.create_asset_definitions_widget(group_layout, self.settings["ASSET_TYPE_DEFINITIONS"]) - layout.addWidget(group) - - if "FILE_TYPE_DEFINITIONS" in self.settings: - group = QGroupBox("File Type Definitions") - group_layout = QVBoxLayout(group) - self.create_file_type_definitions_widget(group_layout, self.settings["FILE_TYPE_DEFINITIONS"]) - layout.addWidget(group) - - # Add STANDARD_MAP_TYPES and RESPECT_VARIANT_MAP_TYPES here - form_layout = QFormLayout() - if "STANDARD_MAP_TYPES" in self.settings: - self.create_widget_for_setting(form_layout, "STANDARD_MAP_TYPES", self.settings["STANDARD_MAP_TYPES"]) - if "RESPECT_VARIANT_MAP_TYPES" in self.settings: - self.create_widget_for_setting(form_layout, "RESPECT_VARIANT_MAP_TYPES", self.settings["RESPECT_VARIANT_MAP_TYPES"]) - - layout.addLayout(form_layout) - layout.addStretch() - - def populate_general_tab(self, layout): - """Populates the General tab.""" + """Populates the General tab according to the plan.""" + # Clear existing widgets in the layout first + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget: + widget.deleteLater() + sub_layout = item.layout() + if sub_layout: + # Basic clearing for sub-layouts like QHBoxLayout used below + while sub_layout.count(): + sub_item = sub_layout.takeAt(0) + sub_widget = sub_item.widget() + if sub_widget: + sub_widget.deleteLater() + + # Clear any potentially lingering widget references for this tab + self.widgets.pop("OUTPUT_BASE_DIR", None) + self.widgets.pop("EXTRA_FILES_SUBDIR", None) + self.widgets.pop("METADATA_FILENAME", None) + form_layout = QFormLayout() - # Settings from app_settings.json that fit 'General' - keys_to_include = [ - "DEFAULT_ASSET_CATEGORY" - ] - for key in keys_to_include: - if key in self.settings: - self.create_widget_for_setting(form_layout, key, self.settings[key]) + + # 1. OUTPUT_BASE_DIR: QLineEdit + QPushButton + output_dir_label = QLabel("Output Base Directory:") + output_dir_edit = QLineEdit() + output_dir_button = QPushButton("Browse...") + # Ensure lambda captures the correct widget reference + output_dir_button.clicked.connect( + lambda checked=False, w=output_dir_edit: self.browse_path(w, "OUTPUT_BASE_DIR", is_dir=True) + ) + output_dir_layout = QHBoxLayout() + output_dir_layout.addWidget(output_dir_edit) + output_dir_layout.addWidget(output_dir_button) + form_layout.addRow(output_dir_label, output_dir_layout) + self.widgets["OUTPUT_BASE_DIR"] = output_dir_edit # Store reference + + # 2. EXTRA_FILES_SUBDIR: QLineEdit + extra_subdir_label = QLabel("Subdirectory for Extra Files:") + extra_subdir_edit = QLineEdit() + form_layout.addRow(extra_subdir_label, extra_subdir_edit) + self.widgets["EXTRA_FILES_SUBDIR"] = extra_subdir_edit # Store reference + + # 3. METADATA_FILENAME: QLineEdit + metadata_label = QLabel("Metadata Filename:") + metadata_edit = QLineEdit() + form_layout.addRow(metadata_label, metadata_edit) + self.widgets["METADATA_FILENAME"] = metadata_edit # Store reference + layout.addLayout(form_layout) - layout.addStretch() + layout.addStretch() # Keep stretch at the end def populate_output_naming_tab(self, layout): - """Populates the Output & Naming tab.""" + """Populates the Output & Naming tab according to the plan.""" + # Clear existing widgets in the layout first + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget: + widget.deleteLater() + sub_layout = item.layout() + if sub_layout: + # Basic clearing for sub-layouts + while sub_layout.count(): + sub_item = sub_layout.takeAt(0) + sub_widget = sub_item.widget() + if sub_widget: + sub_widget.deleteLater() + sub_sub_layout = sub_item.layout() + if sub_sub_layout: # Clear nested layouts (like the button HBox) + while sub_sub_layout.count(): + ss_item = sub_sub_layout.takeAt(0) + ss_widget = ss_item.widget() + if ss_widget: + ss_widget.deleteLater() + + + # Clear potentially lingering widget references for this tab + self.widgets.pop("TARGET_FILENAME_PATTERN", None) + self.widgets.pop("STANDARD_MAP_TYPES_LIST", None) + self.widgets.pop("RESPECT_VARIANT_MAP_TYPES", None) + self.widgets.pop("ASPECT_RATIO_DECIMALS", None) + + # Main layout for this tab + main_tab_layout = QVBoxLayout() + + # Form layout for simple input fields form_layout = QFormLayout() - # Settings from app_settings.json that fit 'Output & Naming' - keys_to_include = [ - "OUTPUT_BASE_DIR", "EXTRA_FILES_SUBDIR", "METADATA_FILENAME", - "TARGET_FILENAME_PATTERN", "TEMP_DIR_PREFIX" - ] - for key in keys_to_include: - if key in self.settings: - self.create_widget_for_setting(form_layout, key, self.settings[key]) - layout.addLayout(form_layout) - layout.addStretch() + + # 1. TARGET_FILENAME_PATTERN: QLineEdit + target_filename_label = QLabel("Output Filename Pattern:") + target_filename_edit = QLineEdit() + target_filename_edit.setToolTip( + "Define the output filename structure.\n" + "Placeholders: {asset_name}, {map_type}, {resolution}, {variant}, {udim}" + ) + form_layout.addRow(target_filename_label, target_filename_edit) + self.widgets["TARGET_FILENAME_PATTERN"] = target_filename_edit + + # 2. RESPECT_VARIANT_MAP_TYPES: QLineEdit + respect_variant_label = QLabel("Map Types Respecting Variants (comma-separated):") + respect_variant_edit = QLineEdit() + form_layout.addRow(respect_variant_label, respect_variant_edit) + self.widgets["RESPECT_VARIANT_MAP_TYPES"] = respect_variant_edit + + # 3. ASPECT_RATIO_DECIMALS: QSpinBox + aspect_ratio_label = QLabel("Aspect Ratio Precision (Decimals):") + aspect_ratio_spinbox = QSpinBox() + aspect_ratio_spinbox.setRange(0, 6) # Min: 0, Max: ~6 + form_layout.addRow(aspect_ratio_label, aspect_ratio_spinbox) + self.widgets["ASPECT_RATIO_DECIMALS"] = aspect_ratio_spinbox + + main_tab_layout.addLayout(form_layout) + + # 4. STANDARD_MAP_TYPES: QListWidget + Add/Remove Buttons + standard_maps_layout = QVBoxLayout() + standard_maps_label = QLabel("Standard Map Types:") + standard_maps_layout.addWidget(standard_maps_label) + + self.standard_map_types_list = QListWidget() + self.standard_map_types_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) # Allow list to expand + standard_maps_layout.addWidget(self.standard_map_types_list) + self.widgets["STANDARD_MAP_TYPES_LIST"] = self.standard_map_types_list # Store list widget reference + + standard_maps_button_layout = QHBoxLayout() + add_button = QPushButton("Add") + remove_button = QPushButton("Remove") + # TODO: Connect add/remove buttons signals + standard_maps_button_layout.addWidget(add_button) + standard_maps_button_layout.addWidget(remove_button) + standard_maps_button_layout.addStretch() # Push buttons left + standard_maps_layout.addLayout(standard_maps_button_layout) + + main_tab_layout.addLayout(standard_maps_layout) + + # Add the main layout to the tab's provided layout + layout.addLayout(main_tab_layout) + layout.addStretch() # Keep stretch at the end of the tab's main layout + def populate_image_processing_tab(self, layout): - """Populates the Image Processing tab.""" - form_layout = QFormLayout() - # Simple settings from app_settings.json that fit 'Image Processing' - simple_keys = [ - "PNG_COMPRESSION_LEVEL", "JPG_QUALITY", "RESOLUTION_THRESHOLD_FOR_JPG", - "ASPECT_RATIO_DECIMALS", "CALCULATE_STATS_RESOLUTION", - "OUTPUT_FORMAT_16BIT_PRIMARY", "OUTPUT_FORMAT_16BIT_FALLBACK", - "OUTPUT_FORMAT_8BIT" + """Populates the Image Processing tab according to the plan.""" + # Clear existing widgets in the layout first + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget: + widget.deleteLater() + sub_layout = item.layout() + if sub_layout: + # Basic clearing for sub-layouts + while sub_layout.count(): + sub_item = sub_layout.takeAt(0) + sub_widget = sub_item.widget() + if sub_widget: + sub_widget.deleteLater() + sub_sub_layout = sub_item.layout() + if sub_sub_layout: # Clear nested layouts (like button HBox) + while sub_sub_layout.count(): + ss_item = sub_sub_layout.takeAt(0) + ss_widget = ss_item.widget() + if ss_widget: + ss_widget.deleteLater() + + # Clear potentially lingering widget references for this tab + keys_to_clear = [ + "IMAGE_RESOLUTIONS_TABLE", "CALCULATE_STATS_RESOLUTION", + "PNG_COMPRESSION_LEVEL", "JPG_QUALITY", + "RESOLUTION_THRESHOLD_FOR_JPG", "OUTPUT_FORMAT_8BIT", + "OUTPUT_FORMAT_16BIT_PRIMARY", "OUTPUT_FORMAT_16BIT_FALLBACK" ] - for key in simple_keys: - if key in self.settings: - self.create_widget_for_setting(form_layout, key, self.settings[key]) - layout.addLayout(form_layout) + for key in keys_to_clear: + self.widgets.pop(key, None) - # Complex widgets for Image Processing - if "IMAGE_RESOLUTIONS" in self.settings: - group = QGroupBox("Image Resolutions") - group_layout = QVBoxLayout(group) - # IMAGE_RESOLUTIONS is a dict in app_settings, need to adapt create_image_resolutions_widget - # For now, display as a simple text field or defer - # Deferring complex dict/list handling for now, except for Definitions and Map Merging - # self.create_image_resolutions_widget(group_layout, self.settings["IMAGE_RESOLUTIONS"]) - # Placeholder for IMAGE_RESOLUTIONS - layout.addWidget(QLabel("Image Resolutions (complex structure - deferred)")) - # Add a simple widget for now to show the data - if "IMAGE_RESOLUTIONS" in self.settings: - self.create_widget_for_setting(form_layout, "IMAGE_RESOLUTIONS", str(self.settings["IMAGE_RESOLUTIONS"])) + # Main layout for this tab + main_tab_layout = QVBoxLayout() + + # --- IMAGE_RESOLUTIONS Section --- + resolutions_layout = QVBoxLayout() + resolutions_label = QLabel("Defined Image Resolutions") + resolutions_layout.addWidget(resolutions_label) + + resolutions_table = QTableWidget() + resolutions_table.setColumnCount(2) + resolutions_table.setHorizontalHeaderLabels(["Name", "Resolution (px)"]) + resolutions_table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) # Allow table to expand vertically + resolutions_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) # Stretch Name column + resolutions_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) # Resize Resolution column to contents + # TODO: Implement custom delegate for "Resolution (px)" column + # TODO: Connect add/remove buttons signals + resolutions_layout.addWidget(resolutions_table) + self.widgets["IMAGE_RESOLUTIONS_TABLE"] = resolutions_table # Store table reference + + resolutions_button_layout = QHBoxLayout() + add_res_button = QPushButton("Add Row") + remove_res_button = QPushButton("Remove Row") + resolutions_button_layout.addWidget(add_res_button) + resolutions_button_layout.addWidget(remove_res_button) + resolutions_button_layout.addStretch() # Push buttons left + resolutions_layout.addLayout(resolutions_button_layout) + + main_tab_layout.addLayout(resolutions_layout) + + # --- Form Layout for other settings --- + form_layout = QFormLayout() + + # CALCULATE_STATS_RESOLUTION: QComboBox + stats_res_label = QLabel("Resolution for Stats Calculation:") + stats_res_combo = QComboBox() + # Population deferred - will be populated from IMAGE_RESOLUTIONS_TABLE + form_layout.addRow(stats_res_label, stats_res_combo) + self.widgets["CALCULATE_STATS_RESOLUTION"] = stats_res_combo + + # PNG_COMPRESSION_LEVEL: QSpinBox + png_level_label = QLabel("PNG Compression Level:") + png_level_spinbox = QSpinBox() + png_level_spinbox.setRange(0, 9) + form_layout.addRow(png_level_label, png_level_spinbox) + self.widgets["PNG_COMPRESSION_LEVEL"] = png_level_spinbox + + # JPG_QUALITY: QSpinBox + jpg_quality_label = QLabel("JPG Quality:") + jpg_quality_spinbox = QSpinBox() + jpg_quality_spinbox.setRange(1, 100) + form_layout.addRow(jpg_quality_label, jpg_quality_spinbox) + self.widgets["JPG_QUALITY"] = jpg_quality_spinbox + + # RESOLUTION_THRESHOLD_FOR_JPG: QComboBox + jpg_threshold_label = QLabel("Use JPG Above Resolution:") + jpg_threshold_combo = QComboBox() + # Population deferred - will be populated from IMAGE_RESOLUTIONS_TABLE + "Never"/"Always" + form_layout.addRow(jpg_threshold_label, jpg_threshold_combo) + self.widgets["RESOLUTION_THRESHOLD_FOR_JPG"] = jpg_threshold_combo + + # OUTPUT_FORMAT_8BIT: QComboBox + format_8bit_label = QLabel("Output Format (8-bit):") + format_8bit_combo = QComboBox() + format_8bit_combo.addItems(["png", "jpg"]) + form_layout.addRow(format_8bit_label, format_8bit_combo) + self.widgets["OUTPUT_FORMAT_8BIT"] = format_8bit_combo + + # OUTPUT_FORMAT_16BIT_PRIMARY: QComboBox + format_16bit_primary_label = QLabel("Primary Output Format (16-bit+):") + format_16bit_primary_combo = QComboBox() + format_16bit_primary_combo.addItems(["png", "exr", "tif"]) + form_layout.addRow(format_16bit_primary_label, format_16bit_primary_combo) + self.widgets["OUTPUT_FORMAT_16BIT_PRIMARY"] = format_16bit_primary_combo + + # OUTPUT_FORMAT_16BIT_FALLBACK: QComboBox + format_16bit_fallback_label = QLabel("Fallback Output Format (16-bit+):") + format_16bit_fallback_combo = QComboBox() + format_16bit_fallback_combo.addItems(["png", "exr", "tif"]) + form_layout.addRow(format_16bit_fallback_label, format_16bit_fallback_combo) + self.widgets["OUTPUT_FORMAT_16BIT_FALLBACK"] = format_16bit_fallback_combo + + main_tab_layout.addLayout(form_layout) + + # Add the main layout to the tab's provided layout + layout.addLayout(main_tab_layout) + layout.addStretch() # Keep stretch at the end of the tab's main layout + + def populate_definitions_tab(self, layout): + """Populates the Definitions tab according to the plan.""" + # Clear existing widgets in the layout first + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget: + widget.deleteLater() + sub_layout = item.layout() + if sub_layout: + # Recursively clear sub-layouts + while sub_layout.count(): + sub_item = sub_layout.takeAt(0) + sub_widget = sub_item.widget() + if sub_widget: + sub_widget.deleteLater() + sub_sub_layout = sub_item.layout() + if sub_sub_layout: + # Clear nested layouts (like button HBox or inner tabs) + while sub_sub_layout.count(): + ss_item = sub_sub_layout.takeAt(0) + ss_widget = ss_item.widget() + if ss_widget: + ss_widget.deleteLater() + # Add more levels if necessary, but this covers the planned structure + + # Clear potentially lingering widget references for this tab + self.widgets.pop("DEFAULT_ASSET_CATEGORY", None) + self.widgets.pop("ASSET_TYPE_DEFINITIONS_TABLE", None) + self.widgets.pop("FILE_TYPE_DEFINITIONS_TABLE", None) + # Remove references to widgets no longer used in this tab's structure + self.widgets.pop("MAP_BIT_DEPTH_RULES_TABLE", None) - if "MAP_BIT_DEPTH_RULES" in self.settings: - group = QGroupBox("Map Bit Depth Rules") - group_layout = QVBoxLayout(group) - # MAP_BIT_DEPTH_RULES is a dict in app_settings, need to adapt create_map_bit_depth_rules_widget - # For now, display as a simple text field or defer - # Deferring complex dict/list handling for now, except for Definitions and Map Merging - # self.create_map_bit_depth_rules_widget(group_layout, self.settings["MAP_BIT_DEPTH_RULES"]) - # Placeholder for MAP_BIT_DEPTH_RULES - layout.addWidget(QLabel("Map Bit Depth Rules (complex structure - deferred)")) - # Add a simple widget for now to show the data - if "MAP_BIT_DEPTH_RULES" in self.settings: - self.create_widget_for_setting(form_layout, "MAP_BIT_DEPTH_RULES", str(self.settings["MAP_BIT_DEPTH_RULES"])) + # Overall QVBoxLayout for the "Definitions" tab + overall_layout = QVBoxLayout() + # --- Top Widget: DEFAULT_ASSET_CATEGORY --- + default_category_layout = QHBoxLayout() # Use QHBox for label + combo + default_category_label = QLabel("Default Asset Category:") + default_category_combo = QComboBox() + # Population is deferred, will happen in populate_widgets_from_settings + default_category_layout.addWidget(default_category_label) + default_category_layout.addWidget(default_category_combo) + default_category_layout.addStretch() # Push label/combo left + overall_layout.addLayout(default_category_layout) + self.widgets["DEFAULT_ASSET_CATEGORY"] = default_category_combo + + # --- Bottom Widget: Inner QTabWidget --- + inner_tab_widget = QTabWidget() + inner_tab_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) # Allow inner tabs to expand + overall_layout.addWidget(inner_tab_widget) + + # --- Inner Tab 1: Asset Types --- + asset_types_tab = QWidget() + asset_types_layout = QVBoxLayout(asset_types_tab) + inner_tab_widget.addTab(asset_types_tab, "Asset Types") + + # Asset Types Table + asset_types_table = QTableWidget() + asset_types_table.setColumnCount(4) + asset_types_table.setHorizontalHeaderLabels(["Type Name", "Description", "Color", "Examples (comma-sep.)"]) + asset_types_table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) # Allow table to expand + # Set column resize modes + header = asset_types_table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.ResizeToContents) # Type Name + header.setSectionResizeMode(1, QHeaderView.Stretch) # Description - Stretch + header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Color + header.setSectionResizeMode(3, QHeaderView.Stretch) # Examples - Stretch + + # Apply Color Delegate + color_delegate = ColorDelegate(self) + asset_types_table.setItemDelegateForColumn(2, color_delegate) # Column 2 is "Color" + + # TODO: Implement custom delegate for "Examples" later + asset_types_layout.addWidget(asset_types_table) + self.widgets["ASSET_TYPE_DEFINITIONS_TABLE"] = asset_types_table + + # Asset Types Add/Remove Buttons + asset_types_button_layout = QHBoxLayout() + add_asset_type_button = QPushButton("Add Row") + remove_asset_type_button = QPushButton("Remove Row") + # TODO: Connect button signals later + asset_types_button_layout.addWidget(add_asset_type_button) + asset_types_button_layout.addWidget(remove_asset_type_button) + asset_types_button_layout.addStretch() + asset_types_layout.addLayout(asset_types_button_layout) + + # --- Inner Tab 2: File Types --- + file_types_tab = QWidget() + file_types_layout = QVBoxLayout(file_types_tab) + inner_tab_widget.addTab(file_types_tab, "File Types") + + # File Types Table + file_types_table = QTableWidget() + file_types_table.setColumnCount(6) + file_types_table.setHorizontalHeaderLabels(["Type ID", "Description", "Color", "Examples (comma-sep.)", "Standard Type", "Bit Depth Rule"]) + file_types_table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) # Allow table to expand + # Set column resize modes + header = file_types_table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.ResizeToContents) # Type ID + header.setSectionResizeMode(1, QHeaderView.Stretch) # Description - Stretch + header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Color + header.setSectionResizeMode(3, QHeaderView.Stretch) # Examples - Stretch + header.setSectionResizeMode(4, QHeaderView.ResizeToContents) # Standard Type + header.setSectionResizeMode(5, QHeaderView.ResizeToContents) # Bit Depth Rule + + # Apply Color Delegate (reuse instance or create new) + # color_delegate = ColorDelegate(self) # Reuse if appropriate + file_types_table.setItemDelegateForColumn(2, color_delegate) # Column 2 is "Color" + + # TODO: Implement custom delegates for "Examples", "Standard Type", "Bit Depth Rule" later + file_types_layout.addWidget(file_types_table) + self.widgets["FILE_TYPE_DEFINITIONS_TABLE"] = file_types_table + + # File Types Add/Remove Buttons + file_types_button_layout = QHBoxLayout() + add_file_type_button = QPushButton("Add Row") + remove_file_type_button = QPushButton("Remove Row") + # TODO: Connect button signals later + file_types_button_layout.addWidget(add_file_type_button) + file_types_button_layout.addWidget(remove_file_type_button) + file_types_button_layout.addStretch() + file_types_layout.addLayout(file_types_button_layout) + + # Add the overall layout to the main tab layout provided + layout.addLayout(overall_layout) + layout.addStretch() # Keep stretch at the end of the tab's main layout - layout.addStretch() def populate_map_merging_tab(self, layout): - """Populates the Map Merging tab.""" - # Implement Map Merging UI (ListWidget + Details Form) - if "MAP_MERGE_RULES" in self.settings: - self.create_map_merge_rules_widget(layout, self.settings["MAP_MERGE_RULES"]) - layout.addStretch() + """Populates the Map Merging tab according to the plan.""" + # Clear existing widgets in the layout first + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget: + widget.deleteLater() + sub_layout = item.layout() + if sub_layout: + # Basic clearing for sub-layouts + while sub_layout.count(): + sub_item = sub_layout.takeAt(0) + sub_widget = sub_item.widget() + if sub_widget: + sub_widget.deleteLater() + # Clear nested layouts if needed (e.g., button layout) + sub_sub_layout = sub_item.layout() + if sub_sub_layout: + while sub_sub_layout.count(): + ss_item = sub_sub_layout.takeAt(0) + ss_widget = ss_item.widget() + if ss_widget: + ss_widget.deleteLater() - def populate_postprocess_scripts_tab(self, layout): - """Populates the Postprocess Scripts tab.""" - form_layout = QFormLayout() - # No explicit settings for postprocess scripts in app_settings.json currently - layout.addWidget(QLabel("No postprocess script settings found.")) - layout.addLayout(form_layout) - layout.addStretch() - - # Remove the old create_widgets_for_section method as it's replaced - # def create_widgets_for_section(self, layout, section_data, section_key): - # ... (old implementation removed) ... + # Clear potentially lingering widget references for this tab + self.widgets.pop("MAP_MERGE_RULES_DATA", None) + # Clear references to the list and details group if they exist + if hasattr(self, 'merge_rules_list'): + del self.merge_rules_list + if hasattr(self, 'merge_rule_details_group'): + del self.merge_rule_details_group + if hasattr(self, 'merge_rule_details_layout'): + del self.merge_rule_details_layout + if hasattr(self, 'merge_rule_widgets'): + del self.merge_rule_widgets - def create_asset_definitions_widget(self, layout, definitions_data): - """Creates a widget for editing asset type definitions.""" - table = QTableWidget() - table.setColumnCount(3) # Asset Type, Description, Color - table.setHorizontalHeaderLabels(["Asset Type", "Description", "Color"]) - table.setRowCount(len(definitions_data)) - - row = 0 - for asset_type, details in definitions_data.items(): - table.setItem(row, 0, QTableWidgetItem(asset_type)) - table.setItem(row, 1, QTableWidgetItem(details.get("description", ""))) - - color_widget = QLineEdit(details.get("color", "")) - color_button = QPushButton("Pick Color...") - color_button.clicked.connect(lambda checked, w=color_widget: self.pick_color(w)) - h_layout = QHBoxLayout() - h_layout.addWidget(color_widget) - h_layout.addWidget(color_button) - - cell_widget = QWidget() - cell_widget.setLayout(h_layout) - table.setCellWidget(row, 2, cell_widget) - - row += 1 - - table.horizontalHeader().setStretchLastSection(True) - layout.addWidget(table) - self.widgets["DEFINITION_SETTINGS.ASSET_TYPE_DEFINITIONS"] = table # Store table reference - - def create_file_type_definitions_widget(self, layout, definitions_data): - """Creates a widget for editing file type definitions.""" - table = QTableWidget() - table.setColumnCount(3) # File Type, Description, Color - table.setHorizontalHeaderLabels(["File Type", "Description", "Color"]) - table.setRowCount(len(definitions_data)) - - row = 0 - for file_type, details in definitions_data.items(): - table.setItem(row, 0, QTableWidgetItem(file_type)) - table.setItem(row, 1, QTableWidgetItem(details.get("description", ""))) - - color_widget = QLineEdit(details.get("color", "")) - color_button = QPushButton("Pick Color...") - color_button.clicked.connect(lambda checked, w=color_widget: self.pick_color(w)) - h_layout = QHBoxLayout() - h_layout.addWidget(color_widget) - h_layout.addWidget(color_button) - - cell_widget = QWidget() - cell_widget.setLayout(h_layout) - table.setCellWidget(row, 2, cell_widget) - - row += 1 - - table.horizontalHeader().setStretchLastSection(True) - layout.addWidget(table) - self.widgets["DEFINITION_SETTINGS.FILE_TYPE_DEFINITIONS"] = table # Store table reference - - def create_image_resolutions_widget(self, layout, resolutions_data): - """Creates a widget for editing image resolutions.""" - table = QTableWidget() - table.setColumnCount(2) # Width, Height - table.setHorizontalHeaderLabels(["Width", "Height"]) - table.setRowCount(len(resolutions_data)) - - for row, resolution in enumerate(resolutions_data): - table.setItem(row, 0, QTableWidgetItem(str(resolution[0]))) - table.setItem(row, 1, QTableWidgetItem(str(resolution[1]))) - - table.horizontalHeader().setStretchLastSection(True) - layout.addWidget(table) - self.widgets["IMAGE_PROCESSING_SETTINGS.IMAGE_RESOLUTIONS"] = table # Store table reference - - def create_map_bit_depth_rules_widget(self, layout, rules_data: dict): - """Creates a widget for editing map bit depth rules (Map Type -> Rule).""" - table = QTableWidget() - table.setColumnCount(2) # Map Type, Rule - table.setHorizontalHeaderLabels(["Map Type", "Rule (respect/force_8bit)"]) - table.setRowCount(len(rules_data)) - - # Iterate through dictionary items (key-value pairs) - for row, (map_type, rule_string) in enumerate(rules_data.items()): - table.setItem(row, 0, QTableWidgetItem(map_type)) - # Optionally use a ComboBox for the rule selection later - table.setItem(row, 1, QTableWidgetItem(str(rule_string))) - - table.horizontalHeader().setStretchLastSection(True) - layout.addWidget(table) - # Store reference using a more specific key if needed, or handle in save_settings - self.widgets["MAP_BIT_DEPTH_RULES_TABLE"] = table # Use a distinct key for the table widget - - def create_map_merge_rules_widget(self, layout, rules_data): - """Creates a widget for editing map merge rules.""" - # This is a more complex structure (list of dicts) - # Using a ListWidget to select rules and a separate form to edit details + # Layout: QHBoxLayout. h_layout = QHBoxLayout() layout.addLayout(h_layout) + # Left Side: QListWidget displaying output_map_type for each rule. + left_layout = QVBoxLayout() + left_layout.addWidget(QLabel("Merge Rules:")) self.merge_rules_list = QListWidget() + self.merge_rules_list.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) # Allow list to expand vertically self.merge_rules_list.currentItemChanged.connect(self.display_merge_rule_details) - h_layout.addWidget(self.merge_rules_list, 1) # Give list more space + left_layout.addWidget(self.merge_rules_list) + button_layout = QHBoxLayout() + add_button = QPushButton("Add Rule") + remove_button = QPushButton("Remove Rule") + # TODO: Connect add/remove buttons + button_layout.addWidget(add_button) + button_layout.addWidget(remove_button) + left_layout.addLayout(button_layout) + + h_layout.addLayout(left_layout, 1) # Give list more space + + # Right Side: QStackedWidget or dynamically populated QWidget showing details self.merge_rule_details_group = QGroupBox("Rule Details") + self.merge_rule_details_group.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) # Allow groupbox to expand horizontally self.merge_rule_details_layout = QFormLayout(self.merge_rule_details_group) h_layout.addWidget(self.merge_rule_details_group, 2) # Give details form more space self.merge_rule_widgets = {} # Widgets for the currently displayed rule - self.populate_merge_rules_list(rules_data) - self.widgets["IMAGE_PROCESSING_SETTINGS.MAP_MERGE_RULES"] = rules_data # Store original data reference + if "MAP_MERGE_RULES" in self.settings: + self.populate_merge_rules_list(self.settings["MAP_MERGE_RULES"]) + self.widgets["MAP_MERGE_RULES_DATA"] = self.settings["MAP_MERGE_RULES"] # Store original data reference + + layout.addStretch() + + + def populate_postprocess_scripts_tab(self, layout): + """Populates the Postprocess Scripts tab according to the plan.""" + # Clear existing widgets in the layout first + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget: + widget.deleteLater() + sub_layout = item.layout() + if sub_layout: + # Basic clearing for sub-layouts (like the QHBoxLayouts used below) + while sub_layout.count(): + sub_item = sub_layout.takeAt(0) + sub_widget = sub_item.widget() + if sub_widget: + sub_widget.deleteLater() + + # Clear potentially lingering widget references for this tab + self.widgets.pop("DEFAULT_NODEGROUP_BLEND_PATH", None) + self.widgets.pop("DEFAULT_MATERIALS_BLEND_PATH", None) + self.widgets.pop("BLENDER_EXECUTABLE_PATH", None) + + form_layout = QFormLayout() + + # 1. DEFAULT_NODEGROUP_BLEND_PATH: QLineEdit + QPushButton + nodegroup_label = QLabel("Default Node Group Library (.blend):") + nodegroup_widget = QLineEdit() + nodegroup_button = QPushButton("Browse...") + nodegroup_button.clicked.connect( + lambda checked=False, w=nodegroup_widget: self.browse_path(w, "DEFAULT_NODEGROUP_BLEND_PATH") + ) + nodegroup_layout = QHBoxLayout() + nodegroup_layout.addWidget(nodegroup_widget) + nodegroup_layout.addWidget(nodegroup_button) + form_layout.addRow(nodegroup_label, nodegroup_layout) + self.widgets["DEFAULT_NODEGROUP_BLEND_PATH"] = nodegroup_widget # Store reference + + # 2. DEFAULT_MATERIALS_BLEND_PATH: QLineEdit + QPushButton + materials_label = QLabel("Default Materials Library (.blend):") + materials_widget = QLineEdit() + materials_button = QPushButton("Browse...") + materials_button.clicked.connect( + lambda checked=False, w=materials_widget: self.browse_path(w, "DEFAULT_MATERIALS_BLEND_PATH") + ) + materials_layout = QHBoxLayout() + materials_layout.addWidget(materials_widget) + materials_layout.addWidget(materials_button) + form_layout.addRow(materials_label, materials_layout) + self.widgets["DEFAULT_MATERIALS_BLEND_PATH"] = materials_widget # Store reference + + # 3. BLENDER_EXECUTABLE_PATH: QLineEdit + QPushButton + blender_label = QLabel("Blender Executable Path:") + blender_widget = QLineEdit() + blender_button = QPushButton("Browse...") + blender_button.clicked.connect( + lambda checked=False, w=blender_widget: self.browse_path(w, "BLENDER_EXECUTABLE_PATH") + ) + blender_layout = QHBoxLayout() + blender_layout.addWidget(blender_widget) + blender_layout.addWidget(blender_button) + form_layout.addRow(blender_label, blender_layout) + self.widgets["BLENDER_EXECUTABLE_PATH"] = blender_widget # Store reference + + layout.addLayout(form_layout) + layout.addStretch() + + def create_asset_definitions_table_widget(self, layout, definitions_data): + """Creates a QTableWidget for editing asset type definitions.""" + table = QTableWidget() + # Columns: "Type Name", "Description", "Color", "Examples (comma-sep.)" + table.setColumnCount(4) + table.setHorizontalHeaderLabels(["Type Name", "Description", "Color", "Examples (comma-sep.)"]) + # Row count will be set when populating + + # TODO: Implement "Add Row" and "Remove Row" buttons + # TODO: Implement custom delegate for "Color" column (QPushButton) + # TODO: Implement custom delegate for "Examples" column (QLineEdit) + + layout.addWidget(table) + self.widgets["ASSET_TYPE_DEFINITIONS_TABLE"] = table # Store table reference + + def create_file_type_definitions_table_widget(self, layout, definitions_data): + """Creates a QTableWidget for editing file type definitions.""" + table = QTableWidget() + # Columns: "Type ID", "Description", "Color", "Examples (comma-sep.)", "Standard Type", "Bit Depth Rule" + table.setColumnCount(6) + table.setHorizontalHeaderLabels(["Type ID", "Description", "Color", "Examples (comma-sep.)", "Standard Type", "Bit Depth Rule"]) + # Row count will be set when populating + + # TODO: Implement "Add Row" and "Remove Row" buttons + # TODO: Implement custom delegate for "Color" column (QPushButton) + # TODO: Implement custom delegate for "Examples" column (QLineEdit) + # TODO: Implement custom delegate for "Standard Type" column (QComboBox) + # TODO: Implement custom delegate for "Bit Depth Rule" column (QComboBox) + + layout.addWidget(table) + self.widgets["FILE_TYPE_DEFINITIONS_TABLE"] = table # Store table reference + + def create_image_resolutions_table_widget(self, layout, resolutions_data): + """Creates a QTableWidget for editing image resolutions.""" + table = QTableWidget() + # Columns: "Name", "Resolution (px)" + table.setColumnCount(2) + table.setHorizontalHeaderLabels(["Name", "Resolution (px)"]) + # Row count will be set when populating + + # TODO: Implement "Add Row" and "Remove Row" buttons + # TODO: Implement custom delegate for "Resolution (px)" column (e.g., QLineEdit with validation or two SpinBoxes) + + layout.addWidget(table) + self.widgets["IMAGE_RESOLUTIONS_TABLE"] = table # Store table reference + + 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).""" + table = QTableWidget() + # Columns: "Map Type", "Rule (respect/force_8bit)" + table.setColumnCount(2) + table.setHorizontalHeaderLabels(["Map Type", "Rule (respect/force_8bit)"]) + # Row count will be set when populating + + # TODO: Implement "Add Row" and "Remove Row" buttons + # TODO: Implement custom delegate for "Rule" column (QComboBox) + + layout.addWidget(table) + self.widgets["MAP_BIT_DEPTH_RULES_TABLE"] = table # Store table reference + + + def create_map_merge_rules_widget(self, layout, rules_data): + """Creates the Map Merging UI (ListWidget + Details Form) according to the plan.""" + # This method is called by populate_map_merging_tab and sets up the QHBoxLayout, + # ListWidget, and details group box. The details population is handled by + # display_merge_rule_details. + pass # Structure is already set up in populate_map_merging_tab def populate_merge_rules_list(self, rules_data): """Populates the list widget with map merge rules.""" self.merge_rules_list.clear() for rule in rules_data: - item = QListWidgetItem(rule.get("output_name", "Unnamed Rule")) + # Use output_map_type for the display text + item_text = rule.get("output_map_type", "Unnamed Rule") + item = QListWidgetItem(item_text) item.setData(Qt.UserRole, rule) # Store the rule dictionary in the item self.merge_rules_list.addItem(item) def display_merge_rule_details(self, current, previous): - """Displays details of the selected merge rule.""" + """Displays details of the selected merge rule according to the plan.""" # Clear previous widgets for i in reversed(range(self.merge_rule_details_layout.count())): widget_item = self.merge_rule_details_layout.itemAt(i) @@ -414,39 +799,77 @@ class ConfigEditorDialog(QDialog): if current: rule_data = current.data(Qt.UserRole) if rule_data: - for key, value in rule_data.items(): - label = QLabel(key.replace('_', ' ').title() + ":") - if isinstance(value, str): - widget = QLineEdit(value) - elif isinstance(value, (int, float)): - if isinstance(value, int): - widget = QSpinBox() - widget.setRange(-2147483648, 2147483647) - widget.setValue(value) - else: - widget = QDoubleSpinBox() - widget.setRange(-1.7976931348623157e+308, 1.7976931348623157e+308) - widget.setValue(value) - elif isinstance(value, bool): - widget = QCheckBox() - widget.setChecked(value) - elif isinstance(value, list): - # Assuming list of strings or simple types for now - widget = QLineEdit(", ".join(map(str, value))) - elif isinstance(value, dict): - # Assuming simple key-value dicts for now - widget = QLineEdit(json.dumps(value)) # Display as JSON string - else: - widget = QLabel(f"Unsupported type: {type(value)}") - + # Rule Detail Form: + # output_map_type: QLineEdit. Label: "Output Map Type Name". + if "output_map_type" in rule_data: + label = QLabel("Output Map Type Name:") + widget = QLineEdit(rule_data["output_map_type"]) self.merge_rule_details_layout.addRow(label, widget) - self.merge_rule_widgets[key] = widget # Store widget reference + self.merge_rule_widgets["output_map_type"] = widget + + # inputs: QTableWidget (Fixed Rows: R, G, B, A. Columns: "Channel", "Input Map Type"). Label: "Channel Inputs". + if "inputs" in rule_data and isinstance(rule_data["inputs"], dict): + group = QGroupBox("Channel Inputs") + group_layout = QVBoxLayout(group) + input_table = QTableWidget(4, 2) # R, G, B, A rows, 2 columns + input_table.setHorizontalHeaderLabels(["Channel", "Input Map Type"]) + input_table.setVerticalHeaderLabels(["R", "G", "B", "A"]) + + # Populate table with current input data + channels = ["R", "G", "B", "A"] + for i, channel in enumerate(channels): + input_map_type = rule_data["inputs"].get(channel, "") + input_table.setItem(i, 0, QTableWidgetItem(channel)) + # TODO: Implement custom delegate for "Input Map Type" column (QComboBox) + input_table.setItem(i, 1, QTableWidgetItem(input_map_type)) # Placeholder + + group_layout.addWidget(input_table) + self.merge_rule_details_layout.addRow(group) + self.merge_rule_widgets["inputs_table"] = input_table # Store table reference - def browse_path(self, widget, key): - """Opens a file or directory dialog based on the setting key.""" - if 'DIR' in key.upper(): + # defaults: QTableWidget (Fixed Rows: R, G, B, A. Columns: "Channel", "Default Value"). Label: "Channel Defaults (if input missing)". + if "defaults" in rule_data and isinstance(rule_data["defaults"], dict): + group = QGroupBox("Channel Defaults (if input missing)") + group_layout = QVBoxLayout(group) + defaults_table = QTableWidget(4, 2) # R, G, B, A rows, 2 columns + defaults_table.setHorizontalHeaderLabels(["Channel", "Default Value"]) + defaults_table.setVerticalHeaderLabels(["R", "G", "B", "A"]) + + # Populate table with current default data + channels = ["R", "G", "B", "A"] + for i, channel in enumerate(channels): + default_value = rule_data["defaults"].get(channel, 0.0) + defaults_table.setItem(i, 0, QTableWidgetItem(channel)) + # TODO: Implement custom delegate for "Default Value" column (QDoubleSpinBox) + defaults_table.setItem(i, 1, QTableWidgetItem(str(default_value))) # Placeholder + + group_layout.addWidget(defaults_table) + self.merge_rule_details_layout.addRow(group) + self.merge_rule_widgets["defaults_table"] = defaults_table # Store table reference + + + # output_bit_depth: QComboBox (Options: "respect_inputs", "force_8bit", "force_16bit"). Label: "Output Bit Depth". + if "output_bit_depth" in rule_data: + label = QLabel("Output Bit Depth:") + widget = QComboBox() + options = ["respect_inputs", "force_8bit", "force_16bit"] + widget.addItems(options) + if rule_data["output_bit_depth"] in options: + widget.setCurrentText(rule_data["output_bit_depth"]) + self.merge_rule_details_layout.addRow(label, widget) + self.merge_rule_widgets["output_bit_depth"] = widget + + # Add stretch to push widgets to the top + self.merge_rule_details_layout.addStretch() + + + def browse_path(self, widget, key, is_dir=False): + """Opens a file or directory dialog based on the setting key and is_dir flag.""" + if is_dir: path = QFileDialog.getExistingDirectory(self, "Select Directory", widget.text()) + elif 'BLEND_PATH' in key.upper(): + path, _ = QFileDialog.getOpenFileName(self, "Select File", widget.text(), "Blender Files (*.blend)") else: path, _ = QFileDialog.getOpenFileName(self, "Select File", widget.text()) @@ -463,20 +886,16 @@ class ConfigEditorDialog(QDialog): """Reads values from widgets and saves them to the configuration file.""" new_settings = {} - # Reconstruct the settings dictionary from widgets - # This requires iterating through the widgets and mapping them back - # to the original structure. This is a simplified approach and might - # need refinement for complex nested structures or dynamic lists/tables. - # Start with a deep copy of the original settings structure to preserve # sections/keys that might not have dedicated widgets (though ideally all should) import copy new_settings = copy.deepcopy(self.settings) + # Iterate through the stored widgets and update the new_settings dictionary for key, widget in self.widgets.items(): # Handle simple widgets - if isinstance(widget, (QLineEdit, QSpinBox, QDoubleSpinBox, QCheckBox)): + if isinstance(widget, (QLineEdit, QSpinBox, QDoubleSpinBox, QCheckBox, QComboBox)): # Split the key to navigate the dictionary structure keys = key.split('.') current_dict = new_settings @@ -495,73 +914,114 @@ class ConfigEditorDialog(QDialog): current_dict[k] = widget.value() elif isinstance(widget, QCheckBox): current_dict[k] = widget.isChecked() + elif isinstance(widget, QComboBox): + # Special handling for RESOLUTION_THRESHOLD_FOR_JPG + if key == "RESOLUTION_THRESHOLD_FOR_JPG": # Use 'key' from the loop, not 'full_key' + selected_text = widget.currentText() + image_resolutions = new_settings.get('IMAGE_RESOLUTIONS', {}) + if selected_text == "Never": + # Use a very large number so the comparison target_dim_px >= threshold is always false + current_dict[k] = 999999 + elif selected_text == "Always": + current_dict[k] = 1 # Assuming 1 means always apply (any positive dimension >= 1) + elif selected_text in image_resolutions: + current_dict[k] = image_resolutions[selected_text] + else: + # Fallback or error handling if text is unexpected + print(f"Warning: Unexpected value '{selected_text}' for RESOLUTION_THRESHOLD_FOR_JPG. Saving as text.") + current_dict[k] = selected_text # Save original text as fallback + else: + # Default behavior for other combo boxes + current_dict[k] = widget.currentText() else: # Navigate to the next level + # Use full_key for error message consistency if k not in current_dict or not isinstance(current_dict[k], dict): # This should not happen if create_tabs is correct, but handle defensively print(f"Warning: Structure mismatch for key part '{k}' in '{key}'") break # Stop processing this key current_dict = current_dict[k] - # Handle TableWidgets (for definitions) - elif isinstance(widget, QTableWidget): - keys = key.split('.') - if len(keys) >= 2: - section_key = keys[0] - list_key = keys[1] - if section_key in new_settings and list_key in new_settings[section_key]: - if list_key == "ASSET_TYPE_DEFINITIONS": - new_definitions = {} - for row in range(widget.rowCount()): - asset_type_item = widget.item(row, 0) - description_item = widget.item(row, 1) - color_widget_container = widget.cellWidget(row, 2) - if asset_type_item and color_widget_container: - asset_type = asset_type_item.text() - description = description_item.text() if description_item else "" - color_widget = color_widget_container.findChild(QLineEdit) - if color_widget: - color = color_widget.text() - new_definitions[asset_type] = {"description": description, "color": color} - new_settings[section_key][list_key] = new_definitions - elif list_key == "FILE_TYPE_DEFINITIONS": - new_definitions = {} - for row in range(widget.rowCount()): - file_type_item = widget.item(row, 0) - description_item = widget.item(row, 1) - color_widget_container = widget.cellWidget(row, 2) - if file_type_item and color_widget_container: - file_type = file_type_item.text() - description = description_item.text() if description_item else "" - color_widget = color_widget_container.findChild(QLineEdit) - if color_widget: - color = color_widget.text() - new_definitions[file_type] = {"description": description, "color": color} - new_settings[section_key][list_key] = new_definitions - # Note: IMAGE_RESOLUTIONS and MAP_BIT_DEPTH_RULES tables are not saved here - # as they are currently displayed as simple text fields due to deferred UI complexity. + # Handle TableWidgets (for definitions and image resolutions) + elif key == "ASSET_TYPE_DEFINITIONS_TABLE": + table = widget + asset_defs = {} + for row in range(table.rowCount()): + try: + type_name_item = table.item(row, 0) + desc_item = table.item(row, 1) + color_item = table.item(row, 2) # Delegate stores color in EditRole + examples_item = table.item(row, 3) + + if not type_name_item or not type_name_item.text(): + print(f"Warning: Skipping row {row} in {key} due to missing type name.") + continue + + type_name = type_name_item.text() + description = desc_item.text() if desc_item else "" + # Get color from EditRole data set by the delegate + color_str = color_item.data(Qt.EditRole) if color_item else "#ffffff" + examples_str = examples_item.text() if examples_item else "" + examples_list = [ex.strip() for ex in examples_str.split(',') if ex.strip()] + + asset_defs[type_name] = { + "description": description, + "color": color_str, + "examples": examples_list + } + except Exception as e: + print(f"Error processing row {row} in {key}: {e}") + new_settings['ASSET_TYPE_DEFINITIONS'] = asset_defs # Overwrite with new data + + elif key == "FILE_TYPE_DEFINITIONS_TABLE": + table = widget + file_defs = {} + for row in range(table.rowCount()): + try: + type_id_item = table.item(row, 0) + desc_item = table.item(row, 1) + color_item = table.item(row, 2) # Delegate stores color in EditRole + examples_item = table.item(row, 3) + std_type_item = table.item(row, 4) + bit_depth_item = table.item(row, 5) + + if not type_id_item or not type_id_item.text(): + print(f"Warning: Skipping row {row} in {key} due to missing type ID.") + continue + + type_id = type_id_item.text() + description = desc_item.text() if desc_item else "" + # Get color from EditRole data set by the delegate + color_str = color_item.data(Qt.EditRole) if color_item else "#ffffff" + examples_str = examples_item.text() if examples_item else "" + examples_list = [ex.strip() for ex in examples_str.split(',') if ex.strip()] + standard_type = std_type_item.text() if std_type_item else "" + bit_depth_rule = bit_depth_item.text() if bit_depth_item else "respect" # Default? + + file_defs[type_id] = { + "description": description, + "color": color_str, + "examples": examples_list, + "standard_type": standard_type, + "bit_depth_rule": bit_depth_rule + } + except Exception as e: + print(f"Error processing row {row} in {key}: {e}") + new_settings['FILE_TYPE_DEFINITIONS'] = file_defs # Overwrite with new data + + elif key in ["IMAGE_RESOLUTIONS_TABLE", "MAP_BIT_DEPTH_RULES_TABLE"]: + # Still not implemented for these tables + print(f"Warning: Saving for table widget '{key}' is not fully implemented.") + pass # Defer saving complex table data # Handle Map Merge Rules (more complex) - # Reconstruct the list from the list widget items' data. # Note: Changes made in the details form are NOT saved with this implementation - # due to deferred complexity in updating the list item's data. - elif key == "IMAGE_PROCESSING_SETTINGS.MAP_MERGE_RULES": - new_merge_rules = [] - for i in range(self.merge_rules_list.count()): - item = self.merge_rules_list.item(i) - rule_data = item.data(Qt.UserRole) - if rule_data: - # Append the rule data stored in the list item. - # This data reflects the state when the dialog was opened, - # not changes made in the details form. - new_merge_rules.append(rule_data) - - # Update the new_settings dictionary with the reconstructed list - keys = key.split('.') - if len(keys) == 2: - section_key = keys[0] - list_key = keys[1] - if section_key in new_settings and list_key in new_settings[section_key]: - new_settings[section_key][list_key] = new_merge_rules + # due to deferred complexity in updating the list item's data and reconstructing the list. + elif key == "MAP_MERGE_RULES_DATA": + # The original data is stored, but changes in the details form are not reflected. + # A full implementation would require updating the rule data in the list item + # when the details form is edited and then reconstructing the list. + print(f"Warning: Saving for Map Merge Rules details is not fully implemented.") + pass # Defer saving changes from the details form # Save the new settings @@ -579,7 +1039,7 @@ class ConfigEditorDialog(QDialog): for key, value in self.settings.items(): # Handle simple settings directly if they have a corresponding widget - if key in self.widgets and isinstance(self.widgets[key], (QLineEdit, QSpinBox, QDoubleSpinBox, QCheckBox)): + if key in self.widgets and isinstance(self.widgets[key], (QLineEdit, QSpinBox, QDoubleSpinBox, QCheckBox, QComboBox)): widget = self.widgets[key] if isinstance(widget, QLineEdit): # Handle simple lists displayed as comma-separated strings @@ -593,75 +1053,175 @@ class ConfigEditorDialog(QDialog): widget.setValue(float(value)) elif isinstance(widget, QCheckBox) and isinstance(value, bool): widget.setChecked(value) - # Add other simple widget types if needed + elif isinstance(widget, QComboBox): + if value in [widget.itemText(i) for i in range(widget.count())]: + widget.setCurrentText(value) + + + # Handle complex structures with dedicated widgets (Tables and Lists) + elif key == "ASSET_TYPE_DEFINITIONS" and "ASSET_TYPE_DEFINITIONS_TABLE" in self.widgets: + self.populate_asset_definitions_table(self.widgets["ASSET_TYPE_DEFINITIONS_TABLE"], value) + elif key == "FILE_TYPE_DEFINITIONS" and "FILE_TYPE_DEFINITIONS_TABLE" in self.widgets: + self.populate_file_type_definitions_table(self.widgets["FILE_TYPE_DEFINITIONS_TABLE"], value) + elif key == "IMAGE_RESOLUTIONS" and "IMAGE_RESOLUTIONS_TABLE" in self.widgets: + self.populate_image_resolutions_table(self.widgets["IMAGE_RESOLUTIONS_TABLE"], value) + # Populate ComboBoxes that depend on Image Resolutions + resolution_names = [self.widgets["IMAGE_RESOLUTIONS_TABLE"].item(i, 0).text() for i in range(self.widgets["IMAGE_RESOLUTIONS_TABLE"].rowCount())] + if "CALCULATE_STATS_RESOLUTION" in self.widgets: + self.widgets["CALCULATE_STATS_RESOLUTION"].addItems(resolution_names) + if key in self.settings and self.settings["CALCULATE_STATS_RESOLUTION"] in resolution_names: + self.widgets["CALCULATE_STATS_RESOLUTION"].setCurrentText(self.settings["CALCULATE_STATS_RESOLUTION"]) + if "RESOLUTION_THRESHOLD_FOR_JPG" in self.widgets: + jpg_threshold_options = ["Never", "Always"] + resolution_names + self.widgets["RESOLUTION_THRESHOLD_FOR_JPG"].addItems(jpg_threshold_options) + if key in self.settings and self.settings["RESOLUTION_THRESHOLD_FOR_JPG"] in jpg_threshold_options: + self.widgets["RESOLUTION_THRESHOLD_FOR_JPG"].setCurrentText(self.settings["RESOLUTION_THRESHOLD_FOR_JPG"]) + + elif key == "MAP_BIT_DEPTH_RULES" and "MAP_BIT_DEPTH_RULES_TABLE" in self.widgets: + self.populate_map_bit_depth_rules_table(self.widgets["MAP_BIT_DEPTH_RULES_TABLE"], value) + + elif key == "STANDARD_MAP_TYPES" and "STANDARD_MAP_TYPES_LIST" in self.widgets and isinstance(value, list): + self.widgets["STANDARD_MAP_TYPES_LIST"].addItems(value) + # Populate DEFAULT_ASSET_CATEGORY ComboBox from Asset Types table (deferred) + # This will be done after the Asset Types table is populated. - # Handle complex structures with dedicated widgets - elif key == "ASSET_TYPE_DEFINITIONS" and "DEFINITION_SETTINGS.ASSET_TYPE_DEFINITIONS" in self.widgets: - self.populate_asset_definitions_table(self.widgets["DEFINITION_SETTINGS.ASSET_TYPE_DEFINITIONS"], value) - elif key == "FILE_TYPE_DEFINITIONS" and "DEFINITION_SETTINGS.FILE_TYPE_DEFINITIONS" in self.widgets: - self.populate_file_type_definitions_table(self.widgets["DEFINITION_SETTINGS.FILE_TYPE_DEFINITIONS"], value) elif key == "MAP_MERGE_RULES" and hasattr(self, 'merge_rules_list'): # Check if the list widget exists self.populate_merge_rules_list(value) # Select the first item to display details if the list is not empty if self.merge_rules_list.count() > 0: self.merge_rules_list.setCurrentRow(0) - # Handle complex dicts/lists displayed as strings (if they were created with create_widget_for_setting) - # These are already handled by the simple widget logic above if they were created as QLineEdit - # with the string representation. - def populate_asset_definitions_table(self, table: QTableWidget, definitions_data: dict): """Populates the asset definitions table.""" table.setRowCount(len(definitions_data)) row = 0 for asset_type, details in definitions_data.items(): - table.setItem(row, 0, QTableWidgetItem(asset_type)) - table.setItem(row, 1, QTableWidgetItem(details.get("description", ""))) + item_type_name = QTableWidgetItem(asset_type) + item_description = QTableWidgetItem(details.get("description", "")) + table.setItem(row, 0, item_type_name) + table.setItem(row, 1, item_description) - # Recreate the color widget for population - color_widget = QLineEdit(details.get("color", "")) - color_button = QPushButton("Pick Color...") - color_button.clicked.connect(lambda checked, w=color_widget: self.pick_color(w)) - h_layout = QHBoxLayout() - h_layout.addWidget(color_widget) - h_layout.addWidget(color_button) + # Color column - Set item with color string as data + color_str = details.get("color", "#ffffff") # Default to white if missing + item_color = QTableWidgetItem() # No text needed, delegate handles paint + item_color.setData(Qt.EditRole, color_str) # Store hex string for delegate/editing + # item_color.setBackground(QColor(color_str)) # Optional: Set initial background via item + table.setItem(row, 2, item_color) - cell_widget = QWidget() - cell_widget.setLayout(h_layout) - table.setCellWidget(row, 2, cell_widget) + # Examples column + examples_list = details.get("examples", []) + examples_str = ", ".join(examples_list) if isinstance(examples_list, list) else "" + item_examples = QTableWidgetItem(examples_str) + table.setItem(row, 3, item_examples) + + # Background color is now handled by the delegate's paint method based on data row += 1 + # After populating the Asset Types table, populate the DEFAULT_ASSET_CATEGORY ComboBox + if "DEFAULT_ASSET_CATEGORY" in self.widgets and isinstance(self.widgets["DEFAULT_ASSET_CATEGORY"], QComboBox): + asset_types = list(definitions_data.keys()) + self.widgets["DEFAULT_ASSET_CATEGORY"].addItems(asset_types) + # Set the current value if it exists in settings + if "DEFAULT_ASSET_CATEGORY" in self.settings and self.settings["DEFAULT_ASSET_CATEGORY"] in asset_types: + self.widgets["DEFAULT_ASSET_CATEGORY"].setCurrentText(self.settings["DEFAULT_ASSET_CATEGORY"]) + + def populate_file_type_definitions_table(self, table: QTableWidget, definitions_data: dict): """Populates the file type definitions table.""" table.setRowCount(len(definitions_data)) row = 0 for file_type, details in definitions_data.items(): - table.setItem(row, 0, QTableWidgetItem(file_type)) - table.setItem(row, 1, QTableWidgetItem(details.get("description", ""))) + item_type_id = QTableWidgetItem(file_type) + item_description = QTableWidgetItem(details.get("description", "")) + table.setItem(row, 0, item_type_id) + table.setItem(row, 1, item_description) - # Recreate the color widget for population - color_widget = QLineEdit(details.get("color", "")) - color_button = QPushButton("Pick Color...") - color_button.clicked.connect(lambda checked, w=color_widget: self.pick_color(w)) - h_layout = QHBoxLayout() - h_layout.addWidget(color_widget) - h_layout.addWidget(color_button) + # Color column - Set item with color string as data + color_str = details.get("color", "#ffffff") # Default to white if missing + item_color = QTableWidgetItem() # No text needed, delegate handles paint + item_color.setData(Qt.EditRole, color_str) # Store hex string for delegate/editing + # item_color.setBackground(QColor(color_str)) # Optional: Set initial background via item + table.setItem(row, 2, item_color) - cell_widget = QWidget() - cell_widget.setLayout(h_layout) - table.setCellWidget(row, 2, cell_widget) + # Examples column + examples_list = details.get("examples", []) + examples_str = ", ".join(examples_list) if isinstance(examples_list, list) else "" + item_examples = QTableWidgetItem(examples_str) + table.setItem(row, 3, item_examples) + + # Standard Type column (simple QTableWidgetItem for now) + standard_type_str = details.get("standard_type", "") + item_standard_type = QTableWidgetItem(standard_type_str) + table.setItem(row, 4, item_standard_type) + + # Bit Depth Rule column (simple QTableWidgetItem for now) + bit_depth_rule_str = details.get("bit_depth_rule", "") + item_bit_depth_rule = QTableWidgetItem(bit_depth_rule_str) + table.setItem(row, 5, item_bit_depth_rule) + + # Background color is now handled by the delegate's paint method based on data row += 1 + def populate_image_resolutions_table(self, table: QTableWidget, resolutions_data: list): + """Populates the image resolutions table.""" + table.setRowCount(len(resolutions_data)) + for row, resolution in enumerate(resolutions_data): + # Assuming resolution is a list/tuple like [name, resolution_string] + try: + if isinstance(resolution, (list, tuple)) and len(resolution) == 2: + name = str(resolution[0]) + # Attempt to convert resolution value to string, handle potential errors + try: + res_value = str(resolution[1]) + except Exception: + res_value = "Error: Invalid Value" + table.setItem(row, 0, QTableWidgetItem(name)) + table.setItem(row, 1, QTableWidgetItem(res_value)) + else: + # Handle unexpected format more clearly + table.setItem(row, 0, QTableWidgetItem(str(resolution))) + table.setItem(row, 1, QTableWidgetItem("Error: Invalid Format")) + except Exception as e: + # Catch any other unexpected errors during processing + print(f"Error populating resolution row {row}: {e}") + table.setItem(row, 0, QTableWidgetItem("Error")) + table.setItem(row, 1, QTableWidgetItem(f"Error: {e}")) + + + def populate_map_bit_depth_rules_table(self, table: QTableWidget, rules_data: dict): + """Populates the map bit depth rules table.""" + table.setRowCount(len(rules_data)) + row = 0 + for map_type, rule in rules_data.items(): + table.setItem(row, 0, QTableWidgetItem(map_type)) + table.setItem(row, 1, QTableWidgetItem(str(rule))) # Rule (respect/force_8bit) + 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) if __name__ == '__main__': - from PyQt5.QtWidgets import QApplication + # Use PySide6 instead of PyQt5 for consistency + from PySide6.QtWidgets import QApplication import sys app = QApplication(sys.argv) dialog = ConfigEditorDialog() - dialog.exec_() - sys.exit(app.exec_()) \ No newline at end of file + dialog.exec() # Use exec() for PySide6 QDialog + sys.exit(app.exec()) # Use exec() for PySide6 QApplication \ No newline at end of file diff --git a/gui/main_window.py b/gui/main_window.py index e8900bb..eb529e6 100644 --- a/gui/main_window.py +++ b/gui/main_window.py @@ -425,13 +425,19 @@ class MainWindow(QMainWindow): self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_ITEM_TYPE, comboBoxDelegate) # Configure View Appearance (optional, customize as needed) + self.unified_view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) # Expand horizontally and vertically self.unified_view.setAlternatingRowColors(True) self.unified_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.unified_view.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked | QAbstractItemView.EditTrigger.SelectedClicked | QAbstractItemView.EditTrigger.EditKeyPressed) - self.unified_view.header().setStretchLastSection(False) # Adjust as needed - # Set the "Name" column (index 0) to resize to contents - self.unified_view.header().setSectionResizeMode(UnifiedViewModel.COL_NAME, QHeaderView.ResizeMode.ResizeToContents) - # self.unified_view.header().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) # Example: Resize others to contents + + # Configure Header Resize Modes based on new column order + header = self.unified_view.header() + header.setStretchLastSection(False) # Don't stretch the last section by default + header.setSectionResizeMode(UnifiedViewModel.COL_NAME, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(UnifiedViewModel.COL_TARGET_ASSET, QHeaderView.ResizeMode.Stretch) # Stretch Target Asset + header.setSectionResizeMode(UnifiedViewModel.COL_SUPPLIER, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(UnifiedViewModel.COL_ASSET_TYPE, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(UnifiedViewModel.COL_ITEM_TYPE, QHeaderView.ResizeMode.ResizeToContents) # Add the Unified View to the main layout main_layout.addWidget(self.unified_view, 1) # Give it stretch factor 1 @@ -1613,80 +1619,129 @@ class MainWindow(QMainWindow): @Slot(list) def _on_rule_hierarchy_ready(self, source_rules_list: list): """Receives prediction results (a list containing one SourceRule) for a single input path, - accumulates them, and updates the model when all are ready.""" + finds the corresponding existing rule in the model, updates it while preserving overrides, + and emits dataChanged/layoutChanged signals.""" - # --- Extract input_path from the received rule --- + # --- Extract input_path and the new rule from the received list --- input_path = None - source_rule = None + new_source_rule = None if source_rules_list and isinstance(source_rules_list[0], SourceRule): - source_rule = source_rules_list[0] - input_path = source_rule.input_path - log.debug(f"--> Entered _on_rule_hierarchy_ready for '{input_path}' with {len(source_rules_list)} SourceRule(s)") + new_source_rule = source_rules_list[0] + input_path = new_source_rule.input_path + log.debug(f"--> Entered _on_rule_hierarchy_ready for '{input_path}' with 1 SourceRule") elif source_rules_list: log.error(f"Received non-SourceRule object in list: {type(source_rules_list[0])}. Cannot process.") - # Attempt to find which pending prediction this might correspond to? Difficult. - # For now, we can't reliably remove from pending without the path. return else: - # This case might happen if prediction failed critically before creating a rule. - # The prediction_finished signal (which now includes input_path) should handle removing from pending. log.warning("Received empty source_rules_list in _on_rule_hierarchy_ready. Prediction likely failed.") - return # Nothing to accumulate + # Try to deduce input_path if possible (e.g., if only one is pending) + if len(self._pending_predictions) == 1: + input_path = list(self._pending_predictions)[0] + log.warning(f"Assuming failed prediction corresponds to pending path: {input_path}") + else: + log.error("Cannot determine input_path for empty/failed prediction result when multiple predictions are pending.") + return if input_path is None: - log.error("Could not determine input_path from received source_rules_list. Aborting accumulation.") + log.error("Could not determine input_path from received source_rules_list or pending state.") return + # Log received rule details (even if it's None due to failure) + log.debug(f"_on_rule_hierarchy_ready: Processing result for '{input_path}'. Received Supplier ID='{getattr(new_source_rule, 'supplier_identifier', 'N/A')}', Received Override='{getattr(new_source_rule, 'supplier_override', 'N/A')}'") + if input_path not in self._pending_predictions: log.warning(f"Received rule hierarchy for '{input_path}', but it was not in the pending set. Ignoring stale result? Pending: {self._pending_predictions}") return # Ignore if not expected - # --- Accumulate Result --- - if source_rule: # Check if we successfully got the rule object - self._accumulated_rules[input_path] = source_rule - log.debug(f"Accumulated rule for '{input_path}'. Total accumulated: {len(self._accumulated_rules)}") - else: - # This path is already handled by the initial checks, but log just in case. - log.warning(f"No valid SourceRule found for '{input_path}' to accumulate.") + # --- Find existing rule in the model's internal list --- + # Access the model directly + existing_rule = None + existing_rule_index = -1 + model_rules = self.unified_model.get_all_source_rules() # Get current rules from model + for i, rule in enumerate(model_rules): + if rule.input_path == input_path: + existing_rule = rule + existing_rule_index = i + break - # --- Mark as Completed --- + if existing_rule: + log.debug(f"Found existing rule for '{input_path}' in model at index {existing_rule_index}. Updating it.") + if new_source_rule: # Only update if prediction was successful + # Preserve existing user overrides from the rule currently in the model + preserved_supplier_override = existing_rule.supplier_override + # Preserve other potential user overrides if they exist + preserved_asset_overrides = {asset.asset_name: asset.asset_type_override for asset in existing_rule.assets} + preserved_file_overrides = {(file.file_path, 'target'): file.target_asset_name_override for asset in existing_rule.assets for file in asset.files} + preserved_file_overrides.update({(file.file_path, 'item'): file.item_type_override for asset in existing_rule.assets for file in asset.files}) + + # --- Update existing rule with new prediction data --- + existing_rule.supplier_identifier = new_source_rule.supplier_identifier + existing_rule.preset_name = new_source_rule.preset_name + existing_rule.assets = new_source_rule.assets # Replace assets list + + # Re-apply preserved overrides + existing_rule.supplier_override = preserved_supplier_override + for asset in existing_rule.assets: + asset.asset_type_override = preserved_asset_overrides.get(asset.asset_name) + asset.parent_source = existing_rule # Set parent reference + for file in asset.files: + file.target_asset_name_override = preserved_file_overrides.get((file.file_path, 'target')) + file.item_type_override = preserved_file_overrides.get((file.file_path, 'item')) + file.parent_asset = asset # Set parent reference + # --- End Update --- + + # Emit dataChanged and layoutChanged for the updated existing rule via the model + start_index = self.unified_model.createIndex(existing_rule_index, 0, existing_rule) + end_index = self.unified_model.createIndex(existing_rule_index, self.unified_model.columnCount() - 1, existing_rule) + log.debug(f"Emitting dataChanged and layoutChanged for updated existing rule at index {existing_rule_index}") + self.unified_model.dataChanged.emit(start_index, end_index) + self.unified_model.layoutChanged.emit() # Signal layout change + else: + log.warning(f"Prediction failed for '{input_path}'. Not updating existing rule data in model.") + + else: + # If no existing rule was found (e.g., first time result arrives) + if new_source_rule: # Only add if prediction was successful + log.debug(f"No existing rule found for '{input_path}'. Adding new rule to model.") + # Ensure parent references are set + for asset_rule in new_source_rule.assets: + asset_rule.parent_source = new_source_rule + for file_rule in asset_rule.files: + file_rule.parent_asset = asset_rule + # Add to model's internal list and emit signal via model methods + self.unified_model.beginInsertRows(QModelIndex(), len(model_rules), len(model_rules)) + model_rules.append(new_source_rule) # Append to the list obtained from the model + self.unified_model.endInsertRows() + else: + log.warning(f"Prediction failed for '{input_path}' and no existing rule found. Nothing to add to model.") + + + # --- Remove from pending --- self._pending_predictions.discard(input_path) - log.debug(f"Removed '{input_path}' from pending predictions. Remaining: {self._pending_predictions}") + log.debug(f"Removed '{input_path}' from pending predictions. Remaining: {len(self._pending_predictions)} -> {self._pending_predictions}") # --- Check for Completion --- if not self._pending_predictions: - log.info("All pending predictions received. Finalizing model update.") - self._finalize_model_update() + log.info("All pending predictions processed. Model should be up-to-date.") + self.statusBar().showMessage(f"Preview complete.", 5000) # Update status + # Optional: Resize columns after all updates are done + for col in range(self.unified_model.columnCount()): + self.unified_view.resizeColumnToContents(col) + self.unified_view.expandToDepth(1) # Expand Source -> Asset level else: # Update status bar with progress - completed_count = len(self._accumulated_rules) + completed_count = len(self.unified_model.get_all_source_rules()) # Count rules in model pending_count = len(self._pending_predictions) - # total_count = completed_count + pending_count # This might be slightly off if some failed without rules - # We don't have the total count of *requested* predictions here easily, - # but we can use the initial number of items added. - total_requested = len(self.current_asset_paths) # Use the total number of items added - status_msg = f"Preview finished for {Path(input_path).name}. Waiting for {pending_count} more ({completed_count}/{total_requested} requested)..." + total_requested = completed_count + pending_count # Estimate total + status_msg = f"Preview updated for {Path(input_path).name}. Waiting for {pending_count} more ({completed_count}/{total_requested} processed)..." self.statusBar().showMessage(status_msg, 5000) log.debug(status_msg) - def _finalize_model_update(self): - """Combines accumulated rules and updates the UI model and view.""" - log.debug("Entering _finalize_model_update") - final_rules = list(self._accumulated_rules.values()) - log.info(f"Finalizing model with {len(final_rules)} accumulated SourceRule(s).") - - # Load the FINAL LIST of data into the UnifiedViewModel - self.unified_model.load_data(final_rules) - log.debug("Unified view model updated with final list of SourceRules.") - - # Resize columns to fit content after loading data - for col in range(self.unified_model.columnCount()): - self.unified_view.resizeColumnToContents(col) - log.debug("Unified view columns resized to contents.") - self.unified_view.expandToDepth(1) # Expand Source -> Asset level - - self.statusBar().showMessage(f"Preview complete for {len(final_rules)} asset(s).", 5000) + # REMOVED _finalize_model_update method as it's no longer needed + # def _finalize_model_update(self): + # """Combines accumulated rules and updates the UI model and view.""" + # ... (old code removed) ... # --- Main Execution --- diff --git a/gui/unified_view_model.py b/gui/unified_view_model.py index 3e6d400..e5c10bc 100644 --- a/gui/unified_view_model.py +++ b/gui/unified_view_model.py @@ -18,14 +18,14 @@ class UnifiedViewModel(QAbstractItemModel): of SourceRule -> AssetRule -> FileRule. """ Columns = [ - "Name", "Supplier", "Asset Type", - "Target Asset", "Item Type" + "Name", "Target Asset", "Supplier", + "Asset Type", "Item Type" ] COL_NAME = 0 - COL_SUPPLIER = 1 - COL_ASSET_TYPE = 2 - COL_TARGET_ASSET = 3 + COL_TARGET_ASSET = 1 + COL_SUPPLIER = 2 + COL_ASSET_TYPE = 3 COL_ITEM_TYPE = 4 # COL_STATUS = 5 # Removed # COL_OUTPUT_PATH = 6 # Removed @@ -242,7 +242,7 @@ class UnifiedViewModel(QAbstractItemModel): if isinstance(item, SourceRule): if role == Qt.DisplayRole or role == Qt.EditRole: # Combine Display and Edit logic if column == self.COL_NAME: - return Path(item.input_path).name # Display only basename for SourceRule + return Path(item.input_path).name elif column == self.COL_SUPPLIER: # Return override if set, otherwise the original identifier, else empty string display_value = item.supplier_override if item.supplier_override is not None else item.supplier_identifier @@ -253,21 +253,21 @@ class UnifiedViewModel(QAbstractItemModel): elif isinstance(item, AssetRule): if role == Qt.DisplayRole: if column == self.COL_NAME: return item.asset_name - if column == self.COL_ASSET_TYPE: + elif column == self.COL_ASSET_TYPE: display_value = item.asset_type_override if item.asset_type_override is not None else item.asset_type return display_value if display_value else "" # Removed Status and Output Path columns elif role == Qt.EditRole: if column == self.COL_ASSET_TYPE: - return item.asset_type_override # Return string or None + return item.asset_type_override return None # Default for AssetRule elif isinstance(item, FileRule): if role == Qt.DisplayRole: if column == self.COL_NAME: return Path(item.file_path).name # Display only filename - if column == self.COL_TARGET_ASSET: + elif column == self.COL_TARGET_ASSET: return item.target_asset_name_override if item.target_asset_name_override is not None else "" - if column == self.COL_ITEM_TYPE: + elif column == self.COL_ITEM_TYPE: # Reverted Logic: Display override if set, otherwise base type. Shows prefixed keys. override = item.item_type_override initial_type = item.item_type @@ -278,8 +278,8 @@ class UnifiedViewModel(QAbstractItemModel): return initial_type if initial_type else "" # Removed Status and Output Path columns elif role == Qt.EditRole: - if column == self.COL_TARGET_ASSET: return item.target_asset_name_override if item.target_asset_name_override is not None else "" - if column == self.COL_ITEM_TYPE: return item.item_type_override # Return string or None + if column == self.COL_TARGET_ASSET: return item.target_asset_name_override if item.target_asset_name_override is not None else "" # Return string or "" + elif column == self.COL_ITEM_TYPE: return item.item_type_override # Return string or None return None # Default for FileRule return None # Default return if role/item combination not handled @@ -299,6 +299,7 @@ class UnifiedViewModel(QAbstractItemModel): if isinstance(item, SourceRule): # If SourceRule is editable if column == self.COL_SUPPLIER: # Get the new value, strip whitespace, treat empty as None + log.debug(f"setData COL_SUPPLIER: Index=({index.row()},{column}), Value='{value}', Type={type(value)}") # <-- ADDED LOGGING (Corrected Indentation) new_value = str(value).strip() if value is not None and str(value).strip() else None # Get the original identifier (assuming it exists on SourceRule) @@ -516,12 +517,12 @@ class UnifiedViewModel(QAbstractItemModel): can_edit = False # Determine editability based on item type and column if isinstance(item, SourceRule): # If SourceRule is displayed/editable - if column == 1: can_edit = True + if column == self.COL_SUPPLIER: can_edit = True # Supplier is editable elif isinstance(item, AssetRule): - if column == 2: can_edit = True + if column == self.COL_ASSET_TYPE: can_edit = True # Asset Type is editable elif isinstance(item, FileRule): - if column == 3: can_edit = True - if column == 4: can_edit = True + if column == self.COL_TARGET_ASSET: can_edit = True # Target Asset is editable + if column == self.COL_ITEM_TYPE: can_edit = True # Item Type is editable if can_edit: return default_flags | Qt.ItemIsEditable @@ -545,4 +546,5 @@ class UnifiedViewModel(QAbstractItemModel): item = index.internalPointer() if item: # Ensure internal pointer is not None return item - return None # Return None for invalid index or None pointer \ No newline at end of file + return None # Return None for invalid index or None pointer + \ No newline at end of file diff --git a/main.py b/main.py index 7386a23..cb46662 100644 --- a/main.py +++ b/main.py @@ -410,10 +410,48 @@ class App(QObject): log.debug("DEBUG: Entering task queuing loop.") # <-- Keep this log for i, rule in enumerate(source_rules): # Added enumerate for index logging if isinstance(rule, SourceRule): + log.info(f"DEBUG Task {i+1}: Rule Input='{rule.input_path}', Supplier ID='{getattr(rule, 'supplier_identifier', 'Not Set')}', Preset='{getattr(rule, 'preset_name', 'Not Set')}'") # <-- ADDED LOGGING (Corrected Indentation) log.debug(f"DEBUG: Preparing to queue task {i+1}/{len(source_rules)} for rule: {rule.input_path}") # <-- Keep this log - # Pass the required paths to the ProcessingTask constructor + + # --- Create a new Configuration and Engine instance for this specific task --- + task_engine = None + try: + # Get preset name from the rule, fallback to app's default if missing + preset_name_for_task = getattr(rule, 'preset_name', None) + if not preset_name_for_task: + log.warning(f"Task {i+1} (Rule: {rule.input_path}): SourceRule missing preset_name. Falling back to default preset '{self.config_obj.preset_name}'.") + preset_name_for_task = self.config_obj.preset_name # Use the initially loaded default + + # Load the specific configuration for this task's preset + task_config = Configuration(preset_name=preset_name_for_task) + task_engine = ProcessingEngine(task_config) + log.debug(f"Task {i+1}: Created new ProcessingEngine instance with preset '{preset_name_for_task}'.") + + except ConfigurationError as config_err: + log.error(f"Task {i+1} (Rule: {rule.input_path}): Failed to load configuration for preset '{preset_name_for_task}': {config_err}. Skipping task.") + self._active_tasks_count -= 1 # Decrement count as this task won't run + self._task_results["failed"] += 1 + # Optionally update GUI status for this specific rule + self.main_window.update_file_status(str(rule.input_path), "failed", f"Config Error: {config_err}") + continue # Skip to the next rule + except Exception as engine_err: + log.exception(f"Task {i+1} (Rule: {rule.input_path}): Failed to initialize ProcessingEngine for preset '{preset_name_for_task}': {engine_err}. Skipping task.") + self._active_tasks_count -= 1 # Decrement count + self._task_results["failed"] += 1 + self.main_window.update_file_status(str(rule.input_path), "failed", f"Engine Init Error: {engine_err}") + continue # Skip to the next rule + + if task_engine is None: # Should not happen if exceptions are caught, but safety check + log.error(f"Task {i+1} (Rule: {rule.input_path}): Engine is None after initialization attempt. Skipping task.") + self._active_tasks_count -= 1 # Decrement count + self._task_results["failed"] += 1 + self.main_window.update_file_status(str(rule.input_path), "failed", "Engine initialization failed (unknown reason).") + continue # Skip to the next rule + # --- End Engine Instantiation --- + + # Pass the required paths and the NEW engine instance to the ProcessingTask constructor task = ProcessingTask( - engine=self.processing_engine, + engine=task_engine, # Pass the newly created engine rule=rule, workspace_path=workspace_path, output_base_path=output_base_path diff --git a/processing_engine.py b/processing_engine.py index 1d4ae22..d491a20 100644 --- a/processing_engine.py +++ b/processing_engine.py @@ -319,21 +319,32 @@ class ProcessingEngine: temp_metadata_path_asset = None # Track metadata file for this asset try: - # --- Skip Check --- - # Use static config for supplier name and metadata filename - supplier_sanitized = _sanitize_filename(self.config_obj.supplier_name) + # --- Determine Effective Supplier (Override > Identifier > Fallback) --- + effective_supplier = source_rule.supplier_override # Prioritize override + if effective_supplier is None: + effective_supplier = source_rule.supplier_identifier # Fallback to original identifier + if not effective_supplier: # Check if still None or empty + log.warning(f"Asset '{asset_name}': Supplier identifier missing from rule and override. Using fallback 'UnknownSupplier'.") + effective_supplier = "UnknownSupplier" # Final fallback + + log.debug(f"Asset '{asset_name}': Effective supplier determined as '{effective_supplier}' (Override: '{source_rule.supplier_override}', Original: '{source_rule.supplier_identifier}')") + + # --- Skip Check (using effective supplier) --- + supplier_sanitized = _sanitize_filename(effective_supplier) asset_name_sanitized = _sanitize_filename(asset_name) final_dir = output_base_path / supplier_sanitized / asset_name_sanitized - metadata_file_path = final_dir / self.config_obj.metadata_filename + metadata_file_path = final_dir / self.config_obj.metadata_filename # Metadata filename still comes from config + + log.debug(f"Checking for existing output/overwrite at: {final_dir} (using effective supplier: '{effective_supplier}')") if not overwrite and final_dir.exists(): - log.info(f"Output directory and metadata found for asset '{asset_name_sanitized}' and overwrite is False. Skipping.") + log.info(f"Output directory found for asset '{asset_name_sanitized}' (Supplier: '{effective_supplier}') and overwrite is False. Skipping.") overall_status["skipped"].append(asset_name) asset_skipped = True continue # Skip to the next asset elif overwrite and final_dir.exists(): - log.warning(f"Output directory exists for '{asset_name_sanitized}' and overwrite is True. Removing existing directory: {final_dir}") + log.warning(f"Output directory exists for '{asset_name_sanitized}' (Supplier: '{effective_supplier}') and overwrite is True. Removing existing directory: {final_dir}") try: shutil.rmtree(final_dir) except Exception as rm_err: @@ -343,6 +354,8 @@ class ProcessingEngine: # Start with common metadata from the rule, add asset name current_asset_metadata = asset_rule.common_metadata.copy() current_asset_metadata["asset_name"] = asset_name + # Use the EFFECTIVE supplier here + current_asset_metadata["supplier_name"] = effective_supplier # Add other fields that will be populated current_asset_metadata["maps_present"] = [] current_asset_metadata["merged_maps"] = [] @@ -371,8 +384,9 @@ class ProcessingEngine: ) # --- Generate Metadata --- + # Pass effective_supplier instead of the whole source_rule temp_metadata_path_asset = self._generate_metadata_file( - source_rule=source_rule, # Pass the parent SourceRule + effective_supplier=effective_supplier, # Pass the determined supplier asset_rule=asset_rule, current_asset_metadata=current_asset_metadata, # Pass the populated dict processed_maps_details_asset=processed_maps_details_asset, @@ -380,17 +394,18 @@ class ProcessingEngine: ) # --- Organize Output --- + # Pass effective_supplier instead of source_rule.supplier_identifier self._organize_output_files( asset_rule=asset_rule, workspace_path=workspace_path, # Pass the original workspace path - supplier_identifier=source_rule.supplier_identifier, # Pass supplier from SourceRule + supplier_identifier=effective_supplier, # Pass the determined supplier output_base_path=output_base_path, # Pass output path processed_maps_details_asset=processed_maps_details_asset, merged_maps_details_asset=merged_maps_details_asset, temp_metadata_path=temp_metadata_path_asset ) - log.info(f"--- Asset '{asset_name}' processed successfully. ---") + log.info(f"--- Asset '{asset_name}' processed successfully (Supplier: {effective_supplier}). ---") overall_status["processed"].append(asset_name) asset_processed = True @@ -1257,12 +1272,13 @@ class ProcessingEngine: return merged_maps_details_asset - def _generate_metadata_file(self, source_rule: SourceRule, asset_rule: AssetRule, current_asset_metadata: Dict, processed_maps_details_asset: Dict[str, Dict[str, Dict]], merged_maps_details_asset: Dict[str, Dict[str, Dict]]) -> Path: + def _generate_metadata_file(self, effective_supplier: str, asset_rule: AssetRule, current_asset_metadata: Dict, processed_maps_details_asset: Dict[str, Dict[str, Dict]], merged_maps_details_asset: Dict[str, Dict[str, Dict]]) -> Path: """ Gathers metadata for a specific asset based on the AssetRule and processing results, and writes it to a temporary JSON file in the engine's temp_dir. Args: + effective_supplier: The supplier name to use (override or original). asset_rule: The AssetRule object for this asset. current_asset_metadata: Base metadata dictionary (already contains name, category, archetype, stats, aspect ratio, map_details). processed_maps_details_asset: Details of processed maps for this asset. @@ -1277,13 +1293,13 @@ class ProcessingEngine: log.warning("Asset name missing during metadata generation, file may be incomplete or incorrectly named.") asset_name = "UnknownAsset_Metadata" # Fallback for filename - log.info(f"Generating metadata file for asset '{asset_name}'...") + log.info(f"Generating metadata file for asset '{asset_name}' (Supplier: {effective_supplier})...") # Start with the base metadata passed in (already contains name, category, archetype, stats, aspect, map_details) final_metadata = current_asset_metadata.copy() - # Use the supplier identifier determined by rules/selection (from SourceRule) - final_metadata["supplier_name"] = source_rule.supplier_identifier or self.config_obj.supplier_name # Fallback to config if empty + # Use the effective supplier passed as argument + final_metadata["supplier_name"] = effective_supplier # Already determined in process() # Populate map resolution details from processing results final_metadata["processed_map_resolutions"] = {} @@ -1326,11 +1342,11 @@ class ProcessingEngine: source_files_in_extra_set.add(str(file_rule.file_path)) final_metadata["source_files_in_extra"] = sorted(list(source_files_in_extra_set)) - # Add processing info (using static config for preset name) + # Add processing info final_metadata["_processing_info"] = { - "preset_used": getattr(source_rule, 'preset_name', self.config_obj.preset_name), # Use preset name from SourceRule (fallback to static config) + "preset_used": self.config_obj.preset_name, # Preset name comes from the engine's config "timestamp_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - "input_source": source_rule.supplier_identifier or "Unknown", # Use identifier from parent SourceRule + "input_source": effective_supplier, # Use the effective supplier } # Sort lists just before writing @@ -1381,7 +1397,7 @@ class ProcessingEngine: log.warning(f"Asset '{asset_name}': Supplier identifier missing in SourceRule. Using fallback 'UnknownSupplier'.") supplier_identifier = "UnknownSupplier" - supplier_sanitized = _sanitize_filename(supplier_identifier) # <<< FIX: Use passed identifier + supplier_sanitized = _sanitize_filename(supplier_identifier) # Use the effective supplier passed in asset_name_sanitized = _sanitize_filename(asset_name) final_dir = output_base_path / supplier_sanitized / asset_name_sanitized log.info(f"Organizing output files for asset '{asset_name_sanitized}' (Supplier: '{supplier_identifier}') into: {final_dir}")