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