{ "sourceFile": "blenderscripts/create_nodegroups.py", "activeCommit": 0, "commits": [ { "activePatchIndex": 28, "patches": [ { "date": 1745229222683, "content": "Index: \n===================================================================\n--- \n+++ \n" }, { "date": 1745229590924, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -0,0 +1,672 @@\n+# Blender Script: Create/Update Node Groups from Asset Processor Output\r\n+# Version: 1.0\r\n+# Description: Scans a library processed by the Asset Processor Tool,\r\n+# reads metadata.json files, and creates/updates corresponding\r\n+# PBR node groups in the active Blender file.\r\n+\r\n+import bpy\r\n+import os\r\n+import json\r\n+from pathlib import Path\r\n+import time\r\n+import re # For parsing aspect ratio string\r\n+\r\n+# --- USER CONFIGURATION ---\r\n+\r\n+# Path to the root output directory of the Asset Processor Tool\r\n+# Example: r\"G:\\Assets\\Processed\"\r\n+PROCESSED_ASSET_LIBRARY_ROOT = r\"G:\\02 Content\\10-19 Content\\13 Textures Power of Two\\13.00\" # <<< CHANGE THIS PATH!\r\n+\r\n+# Names of the required node group templates in the Blender file\r\n+PARENT_TEMPLATE_NAME = \"Template_PBRSET\"\r\n+CHILD_TEMPLATE_NAME = \"Template_PBRTYPE\"\r\n+\r\n+# Labels of specific nodes within the PARENT template\r\n+ASPECT_RATIO_NODE_LABEL = \"AspectRatioCorrection\" # Value node for UV X-scaling factor\r\n+STATS_NODE_PREFIX = \"Histogram-\" # Prefix for Combine XYZ nodes storing stats (e.g., \"Histogram-ROUGH\")\r\n+\r\n+# Enable/disable the manifest system to track processed assets/maps\r\n+# If enabled, requires the blend file to be saved.\r\n+ENABLE_MANIFEST = True\r\n+\r\n+# Map PBR type strings (from metadata) to Blender color spaces\r\n+# Add more mappings as needed based on your metadata types\r\n+PBR_COLOR_SPACE_MAP = {\r\n+ \"AO\": \"Non-Color\", # Usually Non-Color, but depends on workflow\r\n+ \"COL\": \"sRGB\",\r\n+ \"DISP\": \"Non-Color\",\r\n+ \"NRM\": \"Non-Color\",\r\n+ \"REFL\": \"Non-Color\", # Reflection/Specular\r\n+ \"ROUGH\": \"Non-Color\",\r\n+ \"METAL\": \"Non-Color\",\r\n+ \"OPC\": \"Non-Color\", # Opacity/Alpha\r\n+ \"TRN\": \"Non-Color\", # Transmission\r\n+ \"SSS\": \"sRGB\", # Subsurface Color\r\n+ \"EMISS\": \"sRGB\", # Emission Color\r\n+ # Add other types like GLOSS, HEIGHT, etc. if needed\r\n+}\r\n+DEFAULT_COLOR_SPACE = \"sRGB\" # Fallback if map type not in the dictionary\r\n+\r\n+# Map types for which stats should be applied (if found in metadata and node exists)\r\n+APPLY_STATS_FOR_MAP_TYPES = [\"ROUGH\", \"DISP\", \"METAL\"] # Add others if needed\r\n+\r\n+# --- END USER CONFIGURATION ---\r\n+\r\n+\r\n+# --- Helper Functions ---\r\n+\r\n+def find_nodes_by_label(node_tree, label, node_type=None):\r\n+ \"\"\"Finds ALL nodes in a node tree matching the label and optionally type.\"\"\"\r\n+ if not node_tree:\r\n+ return []\r\n+ matching_nodes = []\r\n+ for node in node_tree.nodes:\r\n+ # Use node.label for labeled nodes, node.name for non-labeled (like Group Input/Output)\r\n+ node_identifier = node.label if node.label else node.name\r\n+ if node_identifier and node_identifier == label:\r\n+ if node_type is None or node.bl_idname == node_type or node.type == node_type: # Check bl_idname and type for flexibility\r\n+ matching_nodes.append(node)\r\n+ return matching_nodes\r\n+\r\n+def add_tag_if_new(asset_data, tag_name):\r\n+ \"\"\"Adds a tag to the asset data if it's not None/empty and doesn't already exist.\"\"\"\r\n+ if not asset_data or not tag_name or not isinstance(tag_name, str):\r\n+ return False\r\n+ cleaned_tag_name = tag_name.strip()\r\n+ if not cleaned_tag_name:\r\n+ return False\r\n+\r\n+ # Check if tag already exists (case-insensitive check might be better sometimes)\r\n+ if cleaned_tag_name not in [t.name for t in asset_data.tags]:\r\n+ try:\r\n+ asset_data.tags.new(cleaned_tag_name)\r\n+ print(f\" + Added Asset Tag: '{cleaned_tag_name}'\")\r\n+ return True\r\n+ except Exception as e:\r\n+ print(f\" Error adding tag '{cleaned_tag_name}': {e}\")\r\n+ return False\r\n+ return False # Tag already existed\r\n+\r\n+def get_color_space(map_type):\r\n+ \"\"\"Returns the appropriate Blender color space name for a given map type string.\"\"\"\r\n+ return PBR_COLOR_SPACE_MAP.get(map_type.upper(), DEFAULT_COLOR_SPACE)\r\n+\r\n+def calculate_factor_from_string(aspect_string):\r\n+ \"\"\"\r\n+ Parses the aspect_ratio_change_string from metadata and returns the\r\n+ appropriate UV X-scaling factor needed to correct distortion.\r\n+ Assumes the string format documented in Asset Processor Tool readme.md:\r\n+ \"EVEN\", \"Xnnn\", \"Ynnn\", \"XnnnYnnn\".\r\n+ Returns 1.0 if the string is invalid or \"EVEN\".\r\n+ \"\"\"\r\n+ if not aspect_string or aspect_string.upper() == \"EVEN\":\r\n+ return 1.0\r\n+\r\n+ x_factor = 1.0\r\n+ y_factor = 1.0\r\n+\r\n+ # Regex to find X and Y scaling parts\r\n+ match_x = re.search(r\"X(\\d+)\", aspect_string, re.IGNORECASE)\r\n+ match_y = re.search(r\"Y(\\d+)\", aspect_string, re.IGNORECASE)\r\n+\r\n+ try:\r\n+ if match_x:\r\n+ amount_x = int(match_x.group(1))\r\n+ if amount_x > 0:\r\n+ x_factor = amount_x / 100.0\r\n+ else:\r\n+ print(f\" Warn: Invalid X amount in aspect string '{aspect_string}'. Using 1.0.\")\r\n+\r\n+ if match_y:\r\n+ amount_y = int(match_y.group(1))\r\n+ if amount_y > 0:\r\n+ y_factor = amount_y / 100.0\r\n+ else:\r\n+ print(f\" Warn: Invalid Y amount in aspect string '{aspect_string}'. Using 1.0.\")\r\n+\r\n+ # The correction factor for the U (X) coordinate is Y/X\r\n+ # If X was scaled by 1.5 (X150), U needs to be divided by 1.5 (multiplied by 1/1.5)\r\n+ # If Y was scaled by 1.5 (Y150), U needs to be multiplied by 1.5\r\n+ if x_factor == 0: # Avoid division by zero\r\n+ print(f\" Warn: X factor is zero in aspect string '{aspect_string}'. Cannot calculate correction. Returning 1.0.\")\r\n+ return 1.0\r\n+\r\n+ correction_factor = y_factor / x_factor\r\n+ return correction_factor\r\n+\r\n+ except ValueError:\r\n+ print(f\" Warn: Invalid number in aspect string '{aspect_string}'. Returning 1.0.\")\r\n+ return 1.0\r\n+ except Exception as e:\r\n+ print(f\" Error parsing aspect string '{aspect_string}': {e}. Returning 1.0.\")\r\n+ return 1.0\r\n+\r\n+\r\n+# --- Manifest Functions ---\r\n+\r\n+def get_manifest_path(context):\r\n+ \"\"\"Gets the expected path for the manifest JSON file.\"\"\"\r\n+ if not context or not context.blend_data or not context.blend_data.filepath:\r\n+ return None # Cannot determine path if blend file is not saved\r\n+ blend_path = Path(context.blend_data.filepath)\r\n+ manifest_filename = f\"{blend_path.stem}_manifest.json\"\r\n+ return blend_path.parent / manifest_filename\r\n+\r\n+def load_manifest(context):\r\n+ \"\"\"Loads the manifest data from the JSON file.\"\"\"\r\n+ if not ENABLE_MANIFEST:\r\n+ return {} # Manifest disabled\r\n+\r\n+ manifest_path = get_manifest_path(context)\r\n+ if not manifest_path:\r\n+ print(\" Manifest Info: Blend file not saved. Cannot load manifest.\")\r\n+ return {} # Cannot load without a path\r\n+\r\n+ if not manifest_path.exists():\r\n+ print(f\" Manifest Info: No manifest file found at '{manifest_path.name}'. Starting fresh.\")\r\n+ return {} # No manifest file exists yet\r\n+\r\n+ try:\r\n+ with open(manifest_path, 'r', encoding='utf-8') as f:\r\n+ data = json.load(f)\r\n+ print(f\" Manifest Loaded from: {manifest_path.name}\")\r\n+ # Basic validation (check if it's a dictionary)\r\n+ if not isinstance(data, dict):\r\n+ print(f\"!!! WARNING: Manifest file '{manifest_path.name}' has invalid format (not a dictionary). Starting fresh. !!!\")\r\n+ return {}\r\n+ return data\r\n+ except json.JSONDecodeError:\r\n+ print(f\"!!! WARNING: Manifest file '{manifest_path.name}' is corrupted. Starting fresh. !!!\")\r\n+ return {}\r\n+ except Exception as e:\r\n+ print(f\"!!! ERROR: Could not load manifest file '{manifest_path.name}': {e} !!!\")\r\n+ return {} # Treat as starting fresh on error\r\n+\r\n+def save_manifest(context, manifest_data):\r\n+ \"\"\"Saves the manifest data to the JSON file.\"\"\"\r\n+ if not ENABLE_MANIFEST or not manifest_data: # Don't save if disabled or empty\r\n+ return False\r\n+\r\n+ manifest_path = get_manifest_path(context)\r\n+ if not manifest_path:\r\n+ print(\" Manifest Error: Blend file not saved. Cannot save manifest.\")\r\n+ return False\r\n+\r\n+ try:\r\n+ with open(manifest_path, 'w', encoding='utf-8') as f:\r\n+ json.dump(manifest_data, f, indent=2, sort_keys=True) # Use indent and sort for readability\r\n+ print(f\" Manifest Saved to: {manifest_path.name}\")\r\n+ return True\r\n+ except Exception as e:\r\n+ print(f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\\n\"\r\n+ f\"!!! Manifest save FAILED to '{manifest_path.name}': {e} !!!\\n\"\r\n+ f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\")\r\n+ return False\r\n+\r\n+def is_asset_processed(manifest_data, asset_name):\r\n+ \"\"\"Checks if the entire asset (all its maps/resolutions) is marked as processed.\"\"\"\r\n+ if not ENABLE_MANIFEST: return False\r\n+ # For now, we process map-by-map. An asset is considered processed\r\n+ # if its entry exists, but we rely on map checks.\r\n+ # This could be enhanced later if needed.\r\n+ return asset_name in manifest_data\r\n+\r\n+def is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n+ \"\"\"Checks if a specific map type and resolution for an asset is processed.\"\"\"\r\n+ if not ENABLE_MANIFEST: return False\r\n+ return resolution in manifest_data.get(asset_name, {}).get(map_type, [])\r\n+\r\n+def update_manifest(manifest_data, asset_name, map_type=None, resolution=None):\r\n+ \"\"\"Updates the manifest dictionary in memory.\"\"\"\r\n+ if not ENABLE_MANIFEST: return False\r\n+\r\n+ # Ensure asset entry exists\r\n+ if asset_name not in manifest_data:\r\n+ manifest_data[asset_name] = {}\r\n+\r\n+ # If map_type and resolution are provided, update the specific map entry\r\n+ if map_type and resolution:\r\n+ if map_type not in manifest_data[asset_name]:\r\n+ manifest_data[asset_name][map_type] = []\r\n+\r\n+ if resolution not in manifest_data[asset_name][map_type]:\r\n+ manifest_data[asset_name][map_type].append(resolution)\r\n+ manifest_data[asset_name][map_type].sort() # Keep sorted\r\n+ return True # Indicate that a change was made\r\n+ return False # No change made to this specific map/res\r\n+\r\n+\r\n+# --- Core Logic ---\r\n+\r\n+def process_library(context):\r\n+ global ENABLE_MANIFEST # Declare intent to modify global if needed\r\n+ \"\"\"Scans the library, reads metadata, creates/updates node groups.\"\"\"\r\n+ start_time = time.time()\r\n+ print(f\"\\n--- Starting Node Group Processing ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---\")\r\n+\r\n+ # --- Pre-run Checks ---\r\n+ print(\"Performing pre-run checks...\")\r\n+ valid_setup = True\r\n+ # 1. Check Library Root Path\r\n+ root_path = Path(PROCESSED_ASSET_LIBRARY_ROOT)\r\n+ if not root_path.is_dir():\r\n+ print(f\"!!! ERROR: Processed asset library root directory not found or not a directory:\")\r\n+ print(f\"!!! '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n+ valid_setup = False\r\n+ else:\r\n+ print(f\" Asset Library Root: '{root_path}'\")\r\n+\r\n+ # 2. Check Templates\r\n+ template_parent = bpy.data.node_groups.get(PARENT_TEMPLATE_NAME)\r\n+ template_child = bpy.data.node_groups.get(CHILD_TEMPLATE_NAME)\r\n+ if not template_parent:\r\n+ print(f\"!!! ERROR: Parent template node group '{PARENT_TEMPLATE_NAME}' not found in this Blender file.\")\r\n+ valid_setup = False\r\n+ if not template_child:\r\n+ print(f\"!!! ERROR: Child template node group '{CHILD_TEMPLATE_NAME}' not found in this Blender file.\")\r\n+ valid_setup = False\r\n+ if template_parent and template_child:\r\n+ print(f\" Found Templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\")\r\n+\r\n+ # 3. Check Blend File Saved (if manifest enabled)\r\n+ if ENABLE_MANIFEST and not context.blend_data.filepath:\r\n+ print(f\"!!! WARNING: Manifest is enabled, but the current Blender file is not saved.\")\r\n+ print(f\"!!! Manifest cannot be loaded or saved. Processing will continue without manifest checks.\")\r\n+ global ENABLE_MANIFEST # Allow modification of global\r\n+ ENABLE_MANIFEST = False # Disable manifest for this run\r\n+\r\n+ if not valid_setup:\r\n+ print(\"\\n--- Script aborted due to configuration errors. Please fix the issues above. ---\")\r\n+ return False\r\n+ print(\"Pre-run checks passed.\")\r\n+ # --- End Pre-run Checks ---\r\n+\r\n+ manifest_data = load_manifest(context)\r\n+ manifest_needs_saving = False\r\n+\r\n+ # --- Initialize Counters ---\r\n+ metadata_files_found = 0\r\n+ assets_processed = 0\r\n+ assets_skipped_manifest = 0\r\n+ parent_groups_created = 0\r\n+ parent_groups_updated = 0\r\n+ child_groups_created = 0\r\n+ child_groups_updated = 0\r\n+ images_loaded = 0\r\n+ images_assigned = 0\r\n+ maps_processed = 0\r\n+ maps_skipped_manifest = 0\r\n+ errors_encountered = 0\r\n+ # --- End Counters ---\r\n+\r\n+ print(f\"\\nScanning for metadata files in '{root_path}'...\")\r\n+\r\n+ # --- Scan for metadata.json ---\r\n+ # Using rglob to find all metadata.json files recursively\r\n+ metadata_paths = list(root_path.rglob('metadata.json'))\r\n+ metadata_files_found = len(metadata_paths)\r\n+ print(f\"Found {metadata_files_found} metadata.json files.\")\r\n+\r\n+ if metadata_files_found == 0:\r\n+ print(\"No metadata files found. Nothing to process.\")\r\n+ print(\"--- Script Finished ---\")\r\n+ return True # No work needed is considered success\r\n+\r\n+ # --- Process Each Metadata File ---\r\n+ for metadata_path in metadata_paths:\r\n+ print(f\"\\n--- Processing Metadata: {metadata_path.relative_to(root_path)} ---\")\r\n+ try:\r\n+ with open(metadata_path, 'r', encoding='utf-8') as f:\r\n+ metadata = json.load(f)\r\n+\r\n+ # --- Extract Key Info ---\r\n+ asset_name = metadata.get(\"asset_name\")\r\n+ supplier_name = metadata.get(\"supplier_name\")\r\n+ archetype = metadata.get(\"archetype\")\r\n+ maps_data = metadata.get(\"maps\")\r\n+ aspect_string = metadata.get(\"aspect_ratio_change_string\")\r\n+\r\n+ if not asset_name or not maps_data:\r\n+ print(f\" !!! ERROR: Metadata file is missing 'asset_name' or 'maps' data. Skipping.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+\r\n+ print(f\" Asset Name: {asset_name}\")\r\n+\r\n+ # --- Manifest Check (Asset Level) ---\r\n+ # We primarily check maps, but can skip the whole asset if needed later\r\n+ if ENABLE_MANIFEST and is_asset_processed(manifest_data, asset_name):\r\n+ # Check if ALL maps/resolutions are actually done, otherwise proceed\r\n+ all_maps_done = True\r\n+ for map_type, res_data in maps_data.items():\r\n+ for resolution in res_data.keys():\r\n+ if not is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n+ all_maps_done = False\r\n+ break\r\n+ if not all_maps_done:\r\n+ break\r\n+ if all_maps_done:\r\n+ print(f\" Skipping asset '{asset_name}' (already fully processed according to manifest).\")\r\n+ assets_skipped_manifest += 1\r\n+ continue # Skip to next metadata file\r\n+\r\n+ # --- Parent Group Handling ---\r\n+ target_parent_name = f\"PBRSET_{asset_name}\"\r\n+ parent_group = bpy.data.node_groups.get(target_parent_name)\r\n+ is_new_parent = False\r\n+\r\n+ if parent_group is None:\r\n+ print(f\" Creating new parent group: '{target_parent_name}'\")\r\n+ parent_group = template_parent.copy()\r\n+ if not parent_group:\r\n+ print(f\" !!! ERROR: Failed to copy parent template '{PARENT_TEMPLATE_NAME}'. Skipping asset '{asset_name}'.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ parent_group.name = target_parent_name\r\n+ parent_groups_created += 1\r\n+ is_new_parent = True\r\n+ else:\r\n+ print(f\" Updating existing parent group: '{target_parent_name}'\")\r\n+ parent_groups_updated += 1\r\n+\r\n+ # Ensure marked as asset\r\n+ if not parent_group.asset_data:\r\n+ try:\r\n+ parent_group.asset_mark()\r\n+ print(f\" Marked '{parent_group.name}' as asset.\")\r\n+ except Exception as e_mark:\r\n+ print(f\" !!! ERROR: Failed to mark group '{parent_group.name}' as asset: {e_mark}\")\r\n+ # Continue processing other parts if possible\r\n+\r\n+ # Apply Asset Tags\r\n+ if parent_group.asset_data:\r\n+ if supplier_name:\r\n+ add_tag_if_new(parent_group.asset_data, supplier_name)\r\n+ if archetype:\r\n+ add_tag_if_new(parent_group.asset_data, archetype)\r\n+ # Add other tags if needed\r\n+ # else: print(f\" Warn: Cannot apply tags, asset_data not available for '{parent_group.name}'.\") # Optional warning\r\n+\r\n+ # Apply Aspect Ratio Correction\r\n+ aspect_nodes = find_nodes_by_label(parent_group, ASPECT_RATIO_NODE_LABEL, 'ShaderNodeValue')\r\n+ if aspect_nodes:\r\n+ aspect_node = aspect_nodes[0]\r\n+ correction_factor = calculate_factor_from_string(aspect_string)\r\n+ # Check if update is needed (avoids unnecessary console spam)\r\n+ if abs(aspect_node.outputs[0].default_value - correction_factor) > 0.0001:\r\n+ aspect_node.outputs[0].default_value = correction_factor\r\n+ print(f\" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f} (from string '{aspect_string}')\")\r\n+ # else: print(f\" Warn: Aspect ratio node '{ASPECT_RATIO_NODE_LABEL}' not found in parent group.\") # Optional\r\n+\r\n+ # Apply Stats\r\n+ for map_type_to_stat in APPLY_STATS_FOR_MAP_TYPES:\r\n+ if map_type_to_stat in maps_data:\r\n+ # Find the stats node in the parent group\r\n+ stats_node_label = f\"{STATS_NODE_PREFIX}{map_type_to_stat}\"\r\n+ stats_nodes = find_nodes_by_label(parent_group, stats_node_label, 'ShaderNodeCombineXYZ')\r\n+ if stats_nodes:\r\n+ stats_node = stats_nodes[0]\r\n+ # Find the stats data in the metadata (usually from a specific resolution)\r\n+ # Let's assume the stats are stored directly under the map_type entry\r\n+ map_metadata = maps_data[map_type_to_stat]\r\n+ stats = map_metadata.get(\"stats\") # Expecting {\"min\": float, \"max\": float, \"mean\": float}\r\n+ if stats and isinstance(stats, dict):\r\n+ min_val = stats.get(\"min\")\r\n+ max_val = stats.get(\"max\")\r\n+ mean_val = stats.get(\"mean\") # Often stored as 'mean' or 'avg'\r\n+\r\n+ updated_stat = False\r\n+ if min_val is not None and abs(stats_node.inputs[0].default_value - min_val) > 0.0001:\r\n+ stats_node.inputs[0].default_value = min_val\r\n+ updated_stat = True\r\n+ if max_val is not None and abs(stats_node.inputs[1].default_value - max_val) > 0.0001:\r\n+ stats_node.inputs[1].default_value = max_val\r\n+ updated_stat = True\r\n+ if mean_val is not None and abs(stats_node.inputs[2].default_value - mean_val) > 0.0001:\r\n+ stats_node.inputs[2].default_value = mean_val\r\n+ updated_stat = True\r\n+\r\n+ if updated_stat:\r\n+ print(f\" Set stats in '{stats_node_label}': Min={min_val:.4f}, Max={max_val:.4f}, Mean={mean_val:.4f}\")\r\n+ # else: print(f\" Info: No 'stats' dictionary found for map type '{map_type_to_stat}' in metadata.\") # Optional\r\n+ # else: print(f\" Warn: Stats node '{stats_node_label}' not found in parent group.\") # Optional\r\n+ # else: print(f\" Info: Map type '{map_type_to_stat}' not present in metadata for stats application.\") # Optional\r\n+\r\n+\r\n+ # --- Child Group Handling ---\r\n+ processed_asset_flag = True # Track if any map within the asset was actually processed in this run\r\n+ for map_type, type_data in maps_data.items():\r\n+ print(f\" Processing Map Type: {map_type}\")\r\n+\r\n+ # Find placeholder node in parent\r\n+ # Placeholders might be labeled directly with the map type (e.g., \"NRM\", \"COL\")\r\n+ holder_nodes = find_nodes_by_label(parent_group, map_type, 'ShaderNodeGroup')\r\n+ if not holder_nodes:\r\n+ print(f\" !!! WARNING: No placeholder node labeled '{map_type}' found in parent group '{parent_group.name}'. Skipping this map type.\")\r\n+ continue\r\n+ holder_node = holder_nodes[0] # Assume first is correct\r\n+\r\n+ # Determine child group name\r\n+ target_child_name = f\"PBRTYPE_{asset_name}_{map_type}\"\r\n+ child_group = bpy.data.node_groups.get(target_child_name)\r\n+ is_new_child = False\r\n+\r\n+ if child_group is None:\r\n+ # print(f\" Creating new child group: '{target_child_name}'\") # Verbose\r\n+ child_group = template_child.copy()\r\n+ if not child_group:\r\n+ print(f\" !!! ERROR: Failed to copy child template '{CHILD_TEMPLATE_NAME}'. Skipping map type '{map_type}'.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ child_group.name = target_child_name\r\n+ child_groups_created += 1\r\n+ is_new_child = True\r\n+ else:\r\n+ # print(f\" Updating existing child group: '{target_child_name}'\") # Verbose\r\n+ child_groups_updated += 1\r\n+\r\n+ # Assign child group to placeholder if needed\r\n+ if holder_node.node_tree != child_group:\r\n+ try:\r\n+ holder_node.node_tree = child_group\r\n+ print(f\" Assigned child group '{child_group.name}' to placeholder '{holder_node.label}'.\")\r\n+ except TypeError as e_assign: # Catch potential type errors if placeholder isn't a group node\r\n+ print(f\" !!! ERROR: Could not assign child group to placeholder '{holder_node.label}'. Is it a Group Node? Error: {e_assign}\")\r\n+ continue # Skip this map type if assignment fails\r\n+\r\n+ # Link placeholder output to parent output socket\r\n+ try:\r\n+ # Find parent's output node\r\n+ group_output_node = next((n for n in parent_group.nodes if n.type == 'GROUP_OUTPUT'), None)\r\n+ if group_output_node:\r\n+ # Get the specific output socket on the placeholder (usually index 0 or named 'Color'/'Value')\r\n+ source_socket = holder_node.outputs.get(\"Color\") or holder_node.outputs.get(\"Value\") or holder_node.outputs[0]\r\n+ # Get the specific input socket on the parent output node (matching map_type)\r\n+ target_socket = group_output_node.inputs.get(map_type)\r\n+\r\n+ if source_socket and target_socket:\r\n+ # Check if link already exists\r\n+ link_exists = any(link.from_socket == source_socket and link.to_socket == target_socket for link in parent_group.links)\r\n+ if not link_exists:\r\n+ parent_group.links.new(source_socket, target_socket)\r\n+ print(f\" Linked '{holder_node.label}' output to parent output socket '{map_type}'.\")\r\n+ # else: # Optional warnings\r\n+ # if not source_socket: print(f\" Warn: Could not find suitable output socket on placeholder '{holder_node.label}'.\")\r\n+ # if not target_socket: print(f\" Warn: Could not find input socket '{map_type}' on parent output node.\")\r\n+ # else: print(f\" Warn: Parent group '{parent_group.name}' has no Group Output node.\")\r\n+\r\n+ except Exception as e_link:\r\n+ print(f\" !!! ERROR linking sockets for '{map_type}': {e_link}\")\r\n+\r\n+ # Ensure parent output socket type is Color (if it exists)\r\n+ try:\r\n+ # Use the interface API for modern Blender versions\r\n+ item = parent_group.interface.items_tree.get(map_type)\r\n+ if item and item.item_type == 'SOCKET' and item.in_out == 'OUTPUT':\r\n+ # Common useful types: 'NodeSocketColor', 'NodeSocketVector', 'NodeSocketFloat'\r\n+ # Defaulting to Color seems reasonable for most PBR outputs\r\n+ if item.socket_type != 'NodeSocketColor':\r\n+ item.socket_type = 'NodeSocketColor'\r\n+ # print(f\" Set parent output socket '{map_type}' type to Color.\") # Optional info\r\n+ except Exception as e_sock_type:\r\n+ print(f\" Warn: Could not verify/set socket type for '{map_type}': {e_sock_type}\")\r\n+\r\n+\r\n+ # --- Image Node Handling (Inside Child Group) ---\r\n+ # 'type_data' should be the dictionary containing resolutions and paths for this map_type\r\n+ if not isinstance(type_data, dict):\r\n+ print(f\" !!! ERROR: Invalid format for map type '{map_type}' data in metadata. Skipping.\")\r\n+ continue\r\n+\r\n+ for resolution, map_info in type_data.items():\r\n+ # 'map_info' should be the dictionary containing 'path', 'stats', etc. for this resolution\r\n+ if not isinstance(map_info, dict) or \"path\" not in map_info:\r\n+ print(f\" !!! WARNING: Invalid or missing path data for {map_type}/{resolution}. Skipping.\")\r\n+ continue\r\n+\r\n+ image_path_str = map_info[\"path\"]\r\n+\r\n+ # --- Manifest Check (Map/Resolution Level) ---\r\n+ if ENABLE_MANIFEST and is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n+ # print(f\" Skipping {resolution} (Manifest)\") # Verbose\r\n+ maps_skipped_manifest += 1\r\n+ continue\r\n+\r\n+ print(f\" Processing Resolution: {resolution}\")\r\n+\r\n+ # Find image texture node within the CHILD group (labeled by resolution, e.g., \"4K\")\r\n+ image_nodes = find_nodes_by_label(child_group, resolution, 'ShaderNodeTexImage')\r\n+ if not image_nodes:\r\n+ print(f\" !!! WARNING: No Image Texture node labeled '{resolution}' found in child group '{child_group.name}'. Cannot assign image.\")\r\n+ continue # Skip this resolution if node not found\r\n+\r\n+ # --- Load Image ---\r\n+ img = None\r\n+ image_load_failed = False\r\n+ try:\r\n+ image_path = Path(image_path_str)\r\n+ if not image_path.is_file():\r\n+ print(f\" !!! ERROR: Image file not found: {image_path_str}\")\r\n+ image_load_failed = True\r\n+ else:\r\n+ # Use check_existing=True to reuse existing datablocks if path matches\r\n+ img = bpy.data.images.load(str(image_path), check_existing=True)\r\n+ if not img:\r\n+ print(f\" !!! ERROR: Failed loading image via bpy.data.images.load: {image_path_str}\")\r\n+ image_load_failed = True\r\n+ else:\r\n+ images_loaded += 1 # Count successful loads\r\n+ except RuntimeError as e_runtime_load:\r\n+ # Catch specific Blender runtime errors (e.g., unsupported format)\r\n+ print(f\" !!! ERROR loading image '{image_path_str}': {e_runtime_load}\")\r\n+ image_load_failed = True\r\n+ except Exception as e_gen_load:\r\n+ print(f\" !!! UNEXPECTED ERROR loading image '{image_path_str}': {e_gen_load}\")\r\n+ image_load_failed = True\r\n+ errors_encountered += 1\r\n+\r\n+ # --- Assign Image & Set Color Space ---\r\n+ if not image_load_failed and img:\r\n+ assigned_count_this_res = 0\r\n+ for image_node in image_nodes:\r\n+ if image_node.image != img:\r\n+ image_node.image = img\r\n+ assigned_count_this_res += 1\r\n+\r\n+ if assigned_count_this_res > 0:\r\n+ images_assigned += assigned_count_this_res\r\n+ print(f\" Assigned image '{img.name}' to {assigned_count_this_res} node(s).\")\r\n+\r\n+ # Set Color Space\r\n+ correct_color_space = get_color_space(map_type)\r\n+ try:\r\n+ if img.colorspace_settings.name != correct_color_space:\r\n+ img.colorspace_settings.name = correct_color_space\r\n+ print(f\" Set '{img.name}' color space -> {correct_color_space}\")\r\n+ except TypeError as e_cs: # Handle case where colorspace name is invalid\r\n+ print(f\" !!! WARNING: Could not set color space '{correct_color_space}' for image '{img.name}'. Is the color space available in Blender? Error: {e_cs}\")\r\n+ except Exception as e_cs_gen:\r\n+ print(f\" !!! ERROR setting color space for image '{img.name}': {e_cs_gen}\")\r\n+\r\n+\r\n+ # --- Update Manifest (Map/Resolution Level) ---\r\n+ if update_manifest(manifest_data, asset_name, map_type, resolution):\r\n+ manifest_needs_saving = True\r\n+ # print(f\" Marked {map_type}/{resolution} processed in manifest.\") # Verbose\r\n+ maps_processed += 1\r\n+ processed_asset_flag = False # Mark that something was processed for this asset\r\n+\r\n+ else:\r\n+ errors_encountered += 1\r\n+ processed_asset_flag = False # Still counts as an attempt for this asset\r\n+\r\n+ # --- End Resolution Loop ---\r\n+ # --- End Map Type Loop ---\r\n+\r\n+ # --- Update Manifest (Asset Level - if fully processed) ---\r\n+ # This logic might be redundant if we always check map level, but can be added\r\n+ # if needed to mark the whole asset as 'touched' or 'completed'.\r\n+ # For now, we rely on map-level updates.\r\n+ # if not processed_asset_flag: # If any map was processed (or attempted)\r\n+ # update_manifest(manifest_data, asset_name) # Mark asset as processed\r\n+ # manifest_needs_saving = True\r\n+\r\n+ assets_processed += 1\r\n+\r\n+ except FileNotFoundError:\r\n+ print(f\" !!! ERROR: Metadata file not found (should not happen if scan worked): {metadata_path}\")\r\n+ errors_encountered += 1\r\n+ except json.JSONDecodeError:\r\n+ print(f\" !!! ERROR: Invalid JSON in metadata file: {metadata_path}\")\r\n+ errors_encountered += 1\r\n+ except Exception as e_main_loop:\r\n+ print(f\" !!! UNEXPECTED ERROR processing asset from {metadata_path}: {e_main_loop}\")\r\n+ import traceback\r\n+ traceback.print_exc() # Print detailed traceback for debugging\r\n+ errors_encountered += 1\r\n+ # Continue to the next asset\r\n+\r\n+ # --- End Metadata File Loop ---\r\n+\r\n+ # --- Final Manifest Save ---\r\n+ if ENABLE_MANIFEST and manifest_needs_saving:\r\n+ print(\"\\nAttempting final manifest save...\")\r\n+ save_manifest(context, manifest_data)\r\n+ elif ENABLE_MANIFEST:\r\n+ print(\"\\nManifest is enabled, but no changes require saving.\")\r\n+ # --- End Final Manifest Save ---\r\n+\r\n+ # --- Final Summary ---\r\n+ end_time = time.time()\r\n+ duration = end_time - start_time\r\n+ print(\"\\n--- Script Run Finished ---\")\r\n+ print(f\"Duration: {duration:.2f} seconds\")\r\n+ print(f\"Metadata Files Found: {metadata_files_found}\")\r\n+ print(f\"Assets Processed/Attempted: {assets_processed}\")\r\n+ if ENABLE_MANIFEST:\r\n+ print(f\"Assets Skipped (Manifest): {assets_skipped_manifest}\")\r\n+ print(f\"Maps Skipped (Manifest): {maps_skipped_manifest}\")\r\n+ print(f\"Parent Groups Created: {parent_groups_created}\")\r\n+ print(f\"Parent Groups Updated: {parent_groups_updated}\")\r\n+ print(f\"Child Groups Created: {child_groups_created}\")\r\n+ print(f\"Child Groups Updated: {child_groups_updated}\")\r\n+ print(f\"Images Loaded: {images_loaded}\")\r\n+ print(f\"Image Nodes Assigned: {images_assigned}\")\r\n+ print(f\"Individual Maps Processed: {maps_processed}\")\r\n+ if errors_encountered > 0:\r\n+ print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n+ print(\"---------------------------\")\r\n+\r\n+ return True\r\n+\r\n+\r\n+# --- Execution Block ---\r\n+\r\n+if __name__ == \"__main__\":\r\n+ # Ensure we are running within Blender\r\n+ try:\r\n+ import bpy\r\n+ except ImportError:\r\n+ print(\"!!! ERROR: This script must be run from within Blender. !!!\")\r\n+ else:\r\n+ process_library(bpy.context)\n\\ No newline at end of file\n" }, { "date": 1745229718025, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -0,0 +1,671 @@\n+# Blender Script: Create/Update Node Groups from Asset Processor Output\r\n+# Version: 1.0\r\n+# Description: Scans a library processed by the Asset Processor Tool,\r\n+# reads metadata.json files, and creates/updates corresponding\r\n+# PBR node groups in the active Blender file.\r\n+\r\n+import bpy\r\n+import os\r\n+import json\r\n+from pathlib import Path\r\n+import time\r\n+import re # For parsing aspect ratio string\r\n+\r\n+# --- USER CONFIGURATION ---\r\n+\r\n+# Path to the root output directory of the Asset Processor Tool\r\n+# Example: r\"G:\\Assets\\Processed\"\r\n+PROCESSED_ASSET_LIBRARY_ROOT = r\"G:\\02 Content\\10-19 Content\\13 Textures Power of Two\\13.00\" # <<< CHANGE THIS PATH!\r\n+\r\n+# Names of the required node group templates in the Blender file\r\n+PARENT_TEMPLATE_NAME = \"Template_PBRSET\"\r\n+CHILD_TEMPLATE_NAME = \"Template_PBRTYPE\"\r\n+\r\n+# Labels of specific nodes within the PARENT template\r\n+ASPECT_RATIO_NODE_LABEL = \"AspectRatioCorrection\" # Value node for UV X-scaling factor\r\n+STATS_NODE_PREFIX = \"Histogram-\" # Prefix for Combine XYZ nodes storing stats (e.g., \"Histogram-ROUGH\")\r\n+\r\n+# Enable/disable the manifest system to track processed assets/maps\r\n+# If enabled, requires the blend file to be saved.\r\n+ENABLE_MANIFEST = True\r\n+\r\n+# Map PBR type strings (from metadata) to Blender color spaces\r\n+# Add more mappings as needed based on your metadata types\r\n+PBR_COLOR_SPACE_MAP = {\r\n+ \"AO\": \"Non-Color\", # Usually Non-Color, but depends on workflow\r\n+ \"COL\": \"sRGB\",\r\n+ \"DISP\": \"Non-Color\",\r\n+ \"NRM\": \"Non-Color\",\r\n+ \"REFL\": \"Non-Color\", # Reflection/Specular\r\n+ \"ROUGH\": \"Non-Color\",\r\n+ \"METAL\": \"Non-Color\",\r\n+ \"OPC\": \"Non-Color\", # Opacity/Alpha\r\n+ \"TRN\": \"Non-Color\", # Transmission\r\n+ \"SSS\": \"sRGB\", # Subsurface Color\r\n+ \"EMISS\": \"sRGB\", # Emission Color\r\n+ # Add other types like GLOSS, HEIGHT, etc. if needed\r\n+}\r\n+DEFAULT_COLOR_SPACE = \"sRGB\" # Fallback if map type not in the dictionary\r\n+\r\n+# Map types for which stats should be applied (if found in metadata and node exists)\r\n+APPLY_STATS_FOR_MAP_TYPES = [\"ROUGH\", \"DISP\", \"METAL\"] # Add others if needed\r\n+\r\n+# --- END USER CONFIGURATION ---\r\n+\r\n+\r\n+# --- Helper Functions ---\r\n+\r\n+def find_nodes_by_label(node_tree, label, node_type=None):\r\n+ \"\"\"Finds ALL nodes in a node tree matching the label and optionally type.\"\"\"\r\n+ if not node_tree:\r\n+ return []\r\n+ matching_nodes = []\r\n+ for node in node_tree.nodes:\r\n+ # Use node.label for labeled nodes, node.name for non-labeled (like Group Input/Output)\r\n+ node_identifier = node.label if node.label else node.name\r\n+ if node_identifier and node_identifier == label:\r\n+ if node_type is None or node.bl_idname == node_type or node.type == node_type: # Check bl_idname and type for flexibility\r\n+ matching_nodes.append(node)\r\n+ return matching_nodes\r\n+\r\n+def add_tag_if_new(asset_data, tag_name):\r\n+ \"\"\"Adds a tag to the asset data if it's not None/empty and doesn't already exist.\"\"\"\r\n+ if not asset_data or not tag_name or not isinstance(tag_name, str):\r\n+ return False\r\n+ cleaned_tag_name = tag_name.strip()\r\n+ if not cleaned_tag_name:\r\n+ return False\r\n+\r\n+ # Check if tag already exists (case-insensitive check might be better sometimes)\r\n+ if cleaned_tag_name not in [t.name for t in asset_data.tags]:\r\n+ try:\r\n+ asset_data.tags.new(cleaned_tag_name)\r\n+ print(f\" + Added Asset Tag: '{cleaned_tag_name}'\")\r\n+ return True\r\n+ except Exception as e:\r\n+ print(f\" Error adding tag '{cleaned_tag_name}': {e}\")\r\n+ return False\r\n+ return False # Tag already existed\r\n+\r\n+def get_color_space(map_type):\r\n+ \"\"\"Returns the appropriate Blender color space name for a given map type string.\"\"\"\r\n+ return PBR_COLOR_SPACE_MAP.get(map_type.upper(), DEFAULT_COLOR_SPACE)\r\n+\r\n+def calculate_factor_from_string(aspect_string):\r\n+ \"\"\"\r\n+ Parses the aspect_ratio_change_string from metadata and returns the\r\n+ appropriate UV X-scaling factor needed to correct distortion.\r\n+ Assumes the string format documented in Asset Processor Tool readme.md:\r\n+ \"EVEN\", \"Xnnn\", \"Ynnn\", \"XnnnYnnn\".\r\n+ Returns 1.0 if the string is invalid or \"EVEN\".\r\n+ \"\"\"\r\n+ if not aspect_string or aspect_string.upper() == \"EVEN\":\r\n+ return 1.0\r\n+\r\n+ x_factor = 1.0\r\n+ y_factor = 1.0\r\n+\r\n+ # Regex to find X and Y scaling parts\r\n+ match_x = re.search(r\"X(\\d+)\", aspect_string, re.IGNORECASE)\r\n+ match_y = re.search(r\"Y(\\d+)\", aspect_string, re.IGNORECASE)\r\n+\r\n+ try:\r\n+ if match_x:\r\n+ amount_x = int(match_x.group(1))\r\n+ if amount_x > 0:\r\n+ x_factor = amount_x / 100.0\r\n+ else:\r\n+ print(f\" Warn: Invalid X amount in aspect string '{aspect_string}'. Using 1.0.\")\r\n+\r\n+ if match_y:\r\n+ amount_y = int(match_y.group(1))\r\n+ if amount_y > 0:\r\n+ y_factor = amount_y / 100.0\r\n+ else:\r\n+ print(f\" Warn: Invalid Y amount in aspect string '{aspect_string}'. Using 1.0.\")\r\n+\r\n+ # The correction factor for the U (X) coordinate is Y/X\r\n+ # If X was scaled by 1.5 (X150), U needs to be divided by 1.5 (multiplied by 1/1.5)\r\n+ # If Y was scaled by 1.5 (Y150), U needs to be multiplied by 1.5\r\n+ if x_factor == 0: # Avoid division by zero\r\n+ print(f\" Warn: X factor is zero in aspect string '{aspect_string}'. Cannot calculate correction. Returning 1.0.\")\r\n+ return 1.0\r\n+\r\n+ correction_factor = y_factor / x_factor\r\n+ return correction_factor\r\n+\r\n+ except ValueError:\r\n+ print(f\" Warn: Invalid number in aspect string '{aspect_string}'. Returning 1.0.\")\r\n+ return 1.0\r\n+ except Exception as e:\r\n+ print(f\" Error parsing aspect string '{aspect_string}': {e}. Returning 1.0.\")\r\n+ return 1.0\r\n+\r\n+\r\n+# --- Manifest Functions ---\r\n+\r\n+def get_manifest_path(context):\r\n+ \"\"\"Gets the expected path for the manifest JSON file.\"\"\"\r\n+ if not context or not context.blend_data or not context.blend_data.filepath:\r\n+ return None # Cannot determine path if blend file is not saved\r\n+ blend_path = Path(context.blend_data.filepath)\r\n+ manifest_filename = f\"{blend_path.stem}_manifest.json\"\r\n+ return blend_path.parent / manifest_filename\r\n+\r\n+def load_manifest(context):\r\n+ \"\"\"Loads the manifest data from the JSON file.\"\"\"\r\n+ if not ENABLE_MANIFEST:\r\n+ return {} # Manifest disabled\r\n+\r\n+ manifest_path = get_manifest_path(context)\r\n+ if not manifest_path:\r\n+ print(\" Manifest Info: Blend file not saved. Cannot load manifest.\")\r\n+ return {} # Cannot load without a path\r\n+\r\n+ if not manifest_path.exists():\r\n+ print(f\" Manifest Info: No manifest file found at '{manifest_path.name}'. Starting fresh.\")\r\n+ return {} # No manifest file exists yet\r\n+\r\n+ try:\r\n+ with open(manifest_path, 'r', encoding='utf-8') as f:\r\n+ data = json.load(f)\r\n+ print(f\" Manifest Loaded from: {manifest_path.name}\")\r\n+ # Basic validation (check if it's a dictionary)\r\n+ if not isinstance(data, dict):\r\n+ print(f\"!!! WARNING: Manifest file '{manifest_path.name}' has invalid format (not a dictionary). Starting fresh. !!!\")\r\n+ return {}\r\n+ return data\r\n+ except json.JSONDecodeError:\r\n+ print(f\"!!! WARNING: Manifest file '{manifest_path.name}' is corrupted. Starting fresh. !!!\")\r\n+ return {}\r\n+ except Exception as e:\r\n+ print(f\"!!! ERROR: Could not load manifest file '{manifest_path.name}': {e} !!!\")\r\n+ return {} # Treat as starting fresh on error\r\n+\r\n+def save_manifest(context, manifest_data):\r\n+ \"\"\"Saves the manifest data to the JSON file.\"\"\"\r\n+ if not ENABLE_MANIFEST or not manifest_data: # Don't save if disabled or empty\r\n+ return False\r\n+\r\n+ manifest_path = get_manifest_path(context)\r\n+ if not manifest_path:\r\n+ print(\" Manifest Error: Blend file not saved. Cannot save manifest.\")\r\n+ return False\r\n+\r\n+ try:\r\n+ with open(manifest_path, 'w', encoding='utf-8') as f:\r\n+ json.dump(manifest_data, f, indent=2, sort_keys=True) # Use indent and sort for readability\r\n+ print(f\" Manifest Saved to: {manifest_path.name}\")\r\n+ return True\r\n+ except Exception as e:\r\n+ print(f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\\n\"\r\n+ f\"!!! Manifest save FAILED to '{manifest_path.name}': {e} !!!\\n\"\r\n+ f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\")\r\n+ return False\r\n+\r\n+def is_asset_processed(manifest_data, asset_name):\r\n+ \"\"\"Checks if the entire asset (all its maps/resolutions) is marked as processed.\"\"\"\r\n+ if not ENABLE_MANIFEST: return False\r\n+ # For now, we process map-by-map. An asset is considered processed\r\n+ # if its entry exists, but we rely on map checks.\r\n+ # This could be enhanced later if needed.\r\n+ return asset_name in manifest_data\r\n+\r\n+def is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n+ \"\"\"Checks if a specific map type and resolution for an asset is processed.\"\"\"\r\n+ if not ENABLE_MANIFEST: return False\r\n+ return resolution in manifest_data.get(asset_name, {}).get(map_type, [])\r\n+\r\n+def update_manifest(manifest_data, asset_name, map_type=None, resolution=None):\r\n+ \"\"\"Updates the manifest dictionary in memory.\"\"\"\r\n+ if not ENABLE_MANIFEST: return False\r\n+\r\n+ # Ensure asset entry exists\r\n+ if asset_name not in manifest_data:\r\n+ manifest_data[asset_name] = {}\r\n+\r\n+ # If map_type and resolution are provided, update the specific map entry\r\n+ if map_type and resolution:\r\n+ if map_type not in manifest_data[asset_name]:\r\n+ manifest_data[asset_name][map_type] = []\r\n+\r\n+ if resolution not in manifest_data[asset_name][map_type]:\r\n+ manifest_data[asset_name][map_type].append(resolution)\r\n+ manifest_data[asset_name][map_type].sort() # Keep sorted\r\n+ return True # Indicate that a change was made\r\n+ return False # No change made to this specific map/res\r\n+\r\n+\r\n+# --- Core Logic ---\r\n+\r\n+def process_library(context):\r\n+ global ENABLE_MANIFEST # Declare intent to modify global if needed\r\n+ \"\"\"Scans the library, reads metadata, creates/updates node groups.\"\"\"\r\n+ start_time = time.time()\r\n+ print(f\"\\n--- Starting Node Group Processing ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---\")\r\n+\r\n+ # --- Pre-run Checks ---\r\n+ print(\"Performing pre-run checks...\")\r\n+ valid_setup = True\r\n+ # 1. Check Library Root Path\r\n+ root_path = Path(PROCESSED_ASSET_LIBRARY_ROOT)\r\n+ if not root_path.is_dir():\r\n+ print(f\"!!! ERROR: Processed asset library root directory not found or not a directory:\")\r\n+ print(f\"!!! '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n+ valid_setup = False\r\n+ else:\r\n+ print(f\" Asset Library Root: '{root_path}'\")\r\n+\r\n+ # 2. Check Templates\r\n+ template_parent = bpy.data.node_groups.get(PARENT_TEMPLATE_NAME)\r\n+ template_child = bpy.data.node_groups.get(CHILD_TEMPLATE_NAME)\r\n+ if not template_parent:\r\n+ print(f\"!!! ERROR: Parent template node group '{PARENT_TEMPLATE_NAME}' not found in this Blender file.\")\r\n+ valid_setup = False\r\n+ if not template_child:\r\n+ print(f\"!!! ERROR: Child template node group '{CHILD_TEMPLATE_NAME}' not found in this Blender file.\")\r\n+ valid_setup = False\r\n+ if template_parent and template_child:\r\n+ print(f\" Found Templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\")\r\n+\r\n+ # 3. Check Blend File Saved (if manifest enabled)\r\n+ if ENABLE_MANIFEST and not context.blend_data.filepath:\r\n+ print(f\"!!! WARNING: Manifest is enabled, but the current Blender file is not saved.\")\r\n+ print(f\"!!! Manifest cannot be loaded or saved. Processing will continue without manifest checks.\")\r\n+ ENABLE_MANIFEST = False # Disable manifest for this run\r\n+\r\n+ if not valid_setup:\r\n+ print(\"\\n--- Script aborted due to configuration errors. Please fix the issues above. ---\")\r\n+ return False\r\n+ print(\"Pre-run checks passed.\")\r\n+ # --- End Pre-run Checks ---\r\n+\r\n+ manifest_data = load_manifest(context)\r\n+ manifest_needs_saving = False\r\n+\r\n+ # --- Initialize Counters ---\r\n+ metadata_files_found = 0\r\n+ assets_processed = 0\r\n+ assets_skipped_manifest = 0\r\n+ parent_groups_created = 0\r\n+ parent_groups_updated = 0\r\n+ child_groups_created = 0\r\n+ child_groups_updated = 0\r\n+ images_loaded = 0\r\n+ images_assigned = 0\r\n+ maps_processed = 0\r\n+ maps_skipped_manifest = 0\r\n+ errors_encountered = 0\r\n+ # --- End Counters ---\r\n+\r\n+ print(f\"\\nScanning for metadata files in '{root_path}'...\")\r\n+\r\n+ # --- Scan for metadata.json ---\r\n+ # Using rglob to find all metadata.json files recursively\r\n+ metadata_paths = list(root_path.rglob('metadata.json'))\r\n+ metadata_files_found = len(metadata_paths)\r\n+ print(f\"Found {metadata_files_found} metadata.json files.\")\r\n+\r\n+ if metadata_files_found == 0:\r\n+ print(\"No metadata files found. Nothing to process.\")\r\n+ print(\"--- Script Finished ---\")\r\n+ return True # No work needed is considered success\r\n+\r\n+ # --- Process Each Metadata File ---\r\n+ for metadata_path in metadata_paths:\r\n+ print(f\"\\n--- Processing Metadata: {metadata_path.relative_to(root_path)} ---\")\r\n+ try:\r\n+ with open(metadata_path, 'r', encoding='utf-8') as f:\r\n+ metadata = json.load(f)\r\n+\r\n+ # --- Extract Key Info ---\r\n+ asset_name = metadata.get(\"asset_name\")\r\n+ supplier_name = metadata.get(\"supplier_name\")\r\n+ archetype = metadata.get(\"archetype\")\r\n+ maps_data = metadata.get(\"maps\")\r\n+ aspect_string = metadata.get(\"aspect_ratio_change_string\")\r\n+\r\n+ if not asset_name or not maps_data:\r\n+ print(f\" !!! ERROR: Metadata file is missing 'asset_name' or 'maps' data. Skipping.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+\r\n+ print(f\" Asset Name: {asset_name}\")\r\n+\r\n+ # --- Manifest Check (Asset Level) ---\r\n+ # We primarily check maps, but can skip the whole asset if needed later\r\n+ if ENABLE_MANIFEST and is_asset_processed(manifest_data, asset_name):\r\n+ # Check if ALL maps/resolutions are actually done, otherwise proceed\r\n+ all_maps_done = True\r\n+ for map_type, res_data in maps_data.items():\r\n+ for resolution in res_data.keys():\r\n+ if not is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n+ all_maps_done = False\r\n+ break\r\n+ if not all_maps_done:\r\n+ break\r\n+ if all_maps_done:\r\n+ print(f\" Skipping asset '{asset_name}' (already fully processed according to manifest).\")\r\n+ assets_skipped_manifest += 1\r\n+ continue # Skip to next metadata file\r\n+\r\n+ # --- Parent Group Handling ---\r\n+ target_parent_name = f\"PBRSET_{asset_name}\"\r\n+ parent_group = bpy.data.node_groups.get(target_parent_name)\r\n+ is_new_parent = False\r\n+\r\n+ if parent_group is None:\r\n+ print(f\" Creating new parent group: '{target_parent_name}'\")\r\n+ parent_group = template_parent.copy()\r\n+ if not parent_group:\r\n+ print(f\" !!! ERROR: Failed to copy parent template '{PARENT_TEMPLATE_NAME}'. Skipping asset '{asset_name}'.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ parent_group.name = target_parent_name\r\n+ parent_groups_created += 1\r\n+ is_new_parent = True\r\n+ else:\r\n+ print(f\" Updating existing parent group: '{target_parent_name}'\")\r\n+ parent_groups_updated += 1\r\n+\r\n+ # Ensure marked as asset\r\n+ if not parent_group.asset_data:\r\n+ try:\r\n+ parent_group.asset_mark()\r\n+ print(f\" Marked '{parent_group.name}' as asset.\")\r\n+ except Exception as e_mark:\r\n+ print(f\" !!! ERROR: Failed to mark group '{parent_group.name}' as asset: {e_mark}\")\r\n+ # Continue processing other parts if possible\r\n+\r\n+ # Apply Asset Tags\r\n+ if parent_group.asset_data:\r\n+ if supplier_name:\r\n+ add_tag_if_new(parent_group.asset_data, supplier_name)\r\n+ if archetype:\r\n+ add_tag_if_new(parent_group.asset_data, archetype)\r\n+ # Add other tags if needed\r\n+ # else: print(f\" Warn: Cannot apply tags, asset_data not available for '{parent_group.name}'.\") # Optional warning\r\n+\r\n+ # Apply Aspect Ratio Correction\r\n+ aspect_nodes = find_nodes_by_label(parent_group, ASPECT_RATIO_NODE_LABEL, 'ShaderNodeValue')\r\n+ if aspect_nodes:\r\n+ aspect_node = aspect_nodes[0]\r\n+ correction_factor = calculate_factor_from_string(aspect_string)\r\n+ # Check if update is needed (avoids unnecessary console spam)\r\n+ if abs(aspect_node.outputs[0].default_value - correction_factor) > 0.0001:\r\n+ aspect_node.outputs[0].default_value = correction_factor\r\n+ print(f\" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f} (from string '{aspect_string}')\")\r\n+ # else: print(f\" Warn: Aspect ratio node '{ASPECT_RATIO_NODE_LABEL}' not found in parent group.\") # Optional\r\n+\r\n+ # Apply Stats\r\n+ for map_type_to_stat in APPLY_STATS_FOR_MAP_TYPES:\r\n+ if map_type_to_stat in maps_data:\r\n+ # Find the stats node in the parent group\r\n+ stats_node_label = f\"{STATS_NODE_PREFIX}{map_type_to_stat}\"\r\n+ stats_nodes = find_nodes_by_label(parent_group, stats_node_label, 'ShaderNodeCombineXYZ')\r\n+ if stats_nodes:\r\n+ stats_node = stats_nodes[0]\r\n+ # Find the stats data in the metadata (usually from a specific resolution)\r\n+ # Let's assume the stats are stored directly under the map_type entry\r\n+ map_metadata = maps_data[map_type_to_stat]\r\n+ stats = map_metadata.get(\"stats\") # Expecting {\"min\": float, \"max\": float, \"mean\": float}\r\n+ if stats and isinstance(stats, dict):\r\n+ min_val = stats.get(\"min\")\r\n+ max_val = stats.get(\"max\")\r\n+ mean_val = stats.get(\"mean\") # Often stored as 'mean' or 'avg'\r\n+\r\n+ updated_stat = False\r\n+ if min_val is not None and abs(stats_node.inputs[0].default_value - min_val) > 0.0001:\r\n+ stats_node.inputs[0].default_value = min_val\r\n+ updated_stat = True\r\n+ if max_val is not None and abs(stats_node.inputs[1].default_value - max_val) > 0.0001:\r\n+ stats_node.inputs[1].default_value = max_val\r\n+ updated_stat = True\r\n+ if mean_val is not None and abs(stats_node.inputs[2].default_value - mean_val) > 0.0001:\r\n+ stats_node.inputs[2].default_value = mean_val\r\n+ updated_stat = True\r\n+\r\n+ if updated_stat:\r\n+ print(f\" Set stats in '{stats_node_label}': Min={min_val:.4f}, Max={max_val:.4f}, Mean={mean_val:.4f}\")\r\n+ # else: print(f\" Info: No 'stats' dictionary found for map type '{map_type_to_stat}' in metadata.\") # Optional\r\n+ # else: print(f\" Warn: Stats node '{stats_node_label}' not found in parent group.\") # Optional\r\n+ # else: print(f\" Info: Map type '{map_type_to_stat}' not present in metadata for stats application.\") # Optional\r\n+\r\n+\r\n+ # --- Child Group Handling ---\r\n+ processed_asset_flag = True # Track if any map within the asset was actually processed in this run\r\n+ for map_type, type_data in maps_data.items():\r\n+ print(f\" Processing Map Type: {map_type}\")\r\n+\r\n+ # Find placeholder node in parent\r\n+ # Placeholders might be labeled directly with the map type (e.g., \"NRM\", \"COL\")\r\n+ holder_nodes = find_nodes_by_label(parent_group, map_type, 'ShaderNodeGroup')\r\n+ if not holder_nodes:\r\n+ print(f\" !!! WARNING: No placeholder node labeled '{map_type}' found in parent group '{parent_group.name}'. Skipping this map type.\")\r\n+ continue\r\n+ holder_node = holder_nodes[0] # Assume first is correct\r\n+\r\n+ # Determine child group name\r\n+ target_child_name = f\"PBRTYPE_{asset_name}_{map_type}\"\r\n+ child_group = bpy.data.node_groups.get(target_child_name)\r\n+ is_new_child = False\r\n+\r\n+ if child_group is None:\r\n+ # print(f\" Creating new child group: '{target_child_name}'\") # Verbose\r\n+ child_group = template_child.copy()\r\n+ if not child_group:\r\n+ print(f\" !!! ERROR: Failed to copy child template '{CHILD_TEMPLATE_NAME}'. Skipping map type '{map_type}'.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ child_group.name = target_child_name\r\n+ child_groups_created += 1\r\n+ is_new_child = True\r\n+ else:\r\n+ # print(f\" Updating existing child group: '{target_child_name}'\") # Verbose\r\n+ child_groups_updated += 1\r\n+\r\n+ # Assign child group to placeholder if needed\r\n+ if holder_node.node_tree != child_group:\r\n+ try:\r\n+ holder_node.node_tree = child_group\r\n+ print(f\" Assigned child group '{child_group.name}' to placeholder '{holder_node.label}'.\")\r\n+ except TypeError as e_assign: # Catch potential type errors if placeholder isn't a group node\r\n+ print(f\" !!! ERROR: Could not assign child group to placeholder '{holder_node.label}'. Is it a Group Node? Error: {e_assign}\")\r\n+ continue # Skip this map type if assignment fails\r\n+\r\n+ # Link placeholder output to parent output socket\r\n+ try:\r\n+ # Find parent's output node\r\n+ group_output_node = next((n for n in parent_group.nodes if n.type == 'GROUP_OUTPUT'), None)\r\n+ if group_output_node:\r\n+ # Get the specific output socket on the placeholder (usually index 0 or named 'Color'/'Value')\r\n+ source_socket = holder_node.outputs.get(\"Color\") or holder_node.outputs.get(\"Value\") or holder_node.outputs[0]\r\n+ # Get the specific input socket on the parent output node (matching map_type)\r\n+ target_socket = group_output_node.inputs.get(map_type)\r\n+\r\n+ if source_socket and target_socket:\r\n+ # Check if link already exists\r\n+ link_exists = any(link.from_socket == source_socket and link.to_socket == target_socket for link in parent_group.links)\r\n+ if not link_exists:\r\n+ parent_group.links.new(source_socket, target_socket)\r\n+ print(f\" Linked '{holder_node.label}' output to parent output socket '{map_type}'.\")\r\n+ # else: # Optional warnings\r\n+ # if not source_socket: print(f\" Warn: Could not find suitable output socket on placeholder '{holder_node.label}'.\")\r\n+ # if not target_socket: print(f\" Warn: Could not find input socket '{map_type}' on parent output node.\")\r\n+ # else: print(f\" Warn: Parent group '{parent_group.name}' has no Group Output node.\")\r\n+\r\n+ except Exception as e_link:\r\n+ print(f\" !!! ERROR linking sockets for '{map_type}': {e_link}\")\r\n+\r\n+ # Ensure parent output socket type is Color (if it exists)\r\n+ try:\r\n+ # Use the interface API for modern Blender versions\r\n+ item = parent_group.interface.items_tree.get(map_type)\r\n+ if item and item.item_type == 'SOCKET' and item.in_out == 'OUTPUT':\r\n+ # Common useful types: 'NodeSocketColor', 'NodeSocketVector', 'NodeSocketFloat'\r\n+ # Defaulting to Color seems reasonable for most PBR outputs\r\n+ if item.socket_type != 'NodeSocketColor':\r\n+ item.socket_type = 'NodeSocketColor'\r\n+ # print(f\" Set parent output socket '{map_type}' type to Color.\") # Optional info\r\n+ except Exception as e_sock_type:\r\n+ print(f\" Warn: Could not verify/set socket type for '{map_type}': {e_sock_type}\")\r\n+\r\n+\r\n+ # --- Image Node Handling (Inside Child Group) ---\r\n+ # 'type_data' should be the dictionary containing resolutions and paths for this map_type\r\n+ if not isinstance(type_data, dict):\r\n+ print(f\" !!! ERROR: Invalid format for map type '{map_type}' data in metadata. Skipping.\")\r\n+ continue\r\n+\r\n+ for resolution, map_info in type_data.items():\r\n+ # 'map_info' should be the dictionary containing 'path', 'stats', etc. for this resolution\r\n+ if not isinstance(map_info, dict) or \"path\" not in map_info:\r\n+ print(f\" !!! WARNING: Invalid or missing path data for {map_type}/{resolution}. Skipping.\")\r\n+ continue\r\n+\r\n+ image_path_str = map_info[\"path\"]\r\n+\r\n+ # --- Manifest Check (Map/Resolution Level) ---\r\n+ if ENABLE_MANIFEST and is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n+ # print(f\" Skipping {resolution} (Manifest)\") # Verbose\r\n+ maps_skipped_manifest += 1\r\n+ continue\r\n+\r\n+ print(f\" Processing Resolution: {resolution}\")\r\n+\r\n+ # Find image texture node within the CHILD group (labeled by resolution, e.g., \"4K\")\r\n+ image_nodes = find_nodes_by_label(child_group, resolution, 'ShaderNodeTexImage')\r\n+ if not image_nodes:\r\n+ print(f\" !!! WARNING: No Image Texture node labeled '{resolution}' found in child group '{child_group.name}'. Cannot assign image.\")\r\n+ continue # Skip this resolution if node not found\r\n+\r\n+ # --- Load Image ---\r\n+ img = None\r\n+ image_load_failed = False\r\n+ try:\r\n+ image_path = Path(image_path_str)\r\n+ if not image_path.is_file():\r\n+ print(f\" !!! ERROR: Image file not found: {image_path_str}\")\r\n+ image_load_failed = True\r\n+ else:\r\n+ # Use check_existing=True to reuse existing datablocks if path matches\r\n+ img = bpy.data.images.load(str(image_path), check_existing=True)\r\n+ if not img:\r\n+ print(f\" !!! ERROR: Failed loading image via bpy.data.images.load: {image_path_str}\")\r\n+ image_load_failed = True\r\n+ else:\r\n+ images_loaded += 1 # Count successful loads\r\n+ except RuntimeError as e_runtime_load:\r\n+ # Catch specific Blender runtime errors (e.g., unsupported format)\r\n+ print(f\" !!! ERROR loading image '{image_path_str}': {e_runtime_load}\")\r\n+ image_load_failed = True\r\n+ except Exception as e_gen_load:\r\n+ print(f\" !!! UNEXPECTED ERROR loading image '{image_path_str}': {e_gen_load}\")\r\n+ image_load_failed = True\r\n+ errors_encountered += 1\r\n+\r\n+ # --- Assign Image & Set Color Space ---\r\n+ if not image_load_failed and img:\r\n+ assigned_count_this_res = 0\r\n+ for image_node in image_nodes:\r\n+ if image_node.image != img:\r\n+ image_node.image = img\r\n+ assigned_count_this_res += 1\r\n+\r\n+ if assigned_count_this_res > 0:\r\n+ images_assigned += assigned_count_this_res\r\n+ print(f\" Assigned image '{img.name}' to {assigned_count_this_res} node(s).\")\r\n+\r\n+ # Set Color Space\r\n+ correct_color_space = get_color_space(map_type)\r\n+ try:\r\n+ if img.colorspace_settings.name != correct_color_space:\r\n+ img.colorspace_settings.name = correct_color_space\r\n+ print(f\" Set '{img.name}' color space -> {correct_color_space}\")\r\n+ except TypeError as e_cs: # Handle case where colorspace name is invalid\r\n+ print(f\" !!! WARNING: Could not set color space '{correct_color_space}' for image '{img.name}'. Is the color space available in Blender? Error: {e_cs}\")\r\n+ except Exception as e_cs_gen:\r\n+ print(f\" !!! ERROR setting color space for image '{img.name}': {e_cs_gen}\")\r\n+\r\n+\r\n+ # --- Update Manifest (Map/Resolution Level) ---\r\n+ if update_manifest(manifest_data, asset_name, map_type, resolution):\r\n+ manifest_needs_saving = True\r\n+ # print(f\" Marked {map_type}/{resolution} processed in manifest.\") # Verbose\r\n+ maps_processed += 1\r\n+ processed_asset_flag = False # Mark that something was processed for this asset\r\n+\r\n+ else:\r\n+ errors_encountered += 1\r\n+ processed_asset_flag = False # Still counts as an attempt for this asset\r\n+\r\n+ # --- End Resolution Loop ---\r\n+ # --- End Map Type Loop ---\r\n+\r\n+ # --- Update Manifest (Asset Level - if fully processed) ---\r\n+ # This logic might be redundant if we always check map level, but can be added\r\n+ # if needed to mark the whole asset as 'touched' or 'completed'.\r\n+ # For now, we rely on map-level updates.\r\n+ # if not processed_asset_flag: # If any map was processed (or attempted)\r\n+ # update_manifest(manifest_data, asset_name) # Mark asset as processed\r\n+ # manifest_needs_saving = True\r\n+\r\n+ assets_processed += 1\r\n+\r\n+ except FileNotFoundError:\r\n+ print(f\" !!! ERROR: Metadata file not found (should not happen if scan worked): {metadata_path}\")\r\n+ errors_encountered += 1\r\n+ except json.JSONDecodeError:\r\n+ print(f\" !!! ERROR: Invalid JSON in metadata file: {metadata_path}\")\r\n+ errors_encountered += 1\r\n+ except Exception as e_main_loop:\r\n+ print(f\" !!! UNEXPECTED ERROR processing asset from {metadata_path}: {e_main_loop}\")\r\n+ import traceback\r\n+ traceback.print_exc() # Print detailed traceback for debugging\r\n+ errors_encountered += 1\r\n+ # Continue to the next asset\r\n+\r\n+ # --- End Metadata File Loop ---\r\n+\r\n+ # --- Final Manifest Save ---\r\n+ if ENABLE_MANIFEST and manifest_needs_saving:\r\n+ print(\"\\nAttempting final manifest save...\")\r\n+ save_manifest(context, manifest_data)\r\n+ elif ENABLE_MANIFEST:\r\n+ print(\"\\nManifest is enabled, but no changes require saving.\")\r\n+ # --- End Final Manifest Save ---\r\n+\r\n+ # --- Final Summary ---\r\n+ end_time = time.time()\r\n+ duration = end_time - start_time\r\n+ print(\"\\n--- Script Run Finished ---\")\r\n+ print(f\"Duration: {duration:.2f} seconds\")\r\n+ print(f\"Metadata Files Found: {metadata_files_found}\")\r\n+ print(f\"Assets Processed/Attempted: {assets_processed}\")\r\n+ if ENABLE_MANIFEST:\r\n+ print(f\"Assets Skipped (Manifest): {assets_skipped_manifest}\")\r\n+ print(f\"Maps Skipped (Manifest): {maps_skipped_manifest}\")\r\n+ print(f\"Parent Groups Created: {parent_groups_created}\")\r\n+ print(f\"Parent Groups Updated: {parent_groups_updated}\")\r\n+ print(f\"Child Groups Created: {child_groups_created}\")\r\n+ print(f\"Child Groups Updated: {child_groups_updated}\")\r\n+ print(f\"Images Loaded: {images_loaded}\")\r\n+ print(f\"Image Nodes Assigned: {images_assigned}\")\r\n+ print(f\"Individual Maps Processed: {maps_processed}\")\r\n+ if errors_encountered > 0:\r\n+ print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n+ print(\"---------------------------\")\r\n+\r\n+ return True\r\n+\r\n+\r\n+# --- Execution Block ---\r\n+\r\n+if __name__ == \"__main__\":\r\n+ # Ensure we are running within Blender\r\n+ try:\r\n+ import bpy\r\n+ except ImportError:\r\n+ print(\"!!! ERROR: This script must be run from within Blender. !!!\")\r\n+ else:\r\n+ process_library(bpy.context)\n\\ No newline at end of file\n" }, { "date": 1745229767336, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -667,1348 +667,5 @@\n import bpy\r\n except ImportError:\r\n print(\"!!! ERROR: This script must be run from within Blender. !!!\")\r\n else:\r\n- process_library(bpy.context)\n-# Blender Script: Create/Update Node Groups from Asset Processor Output\r\n-# Version: 1.0\r\n-# Description: Scans a library processed by the Asset Processor Tool,\r\n-# reads metadata.json files, and creates/updates corresponding\r\n-# PBR node groups in the active Blender file.\r\n-\r\n-import bpy\r\n-import os\r\n-import json\r\n-from pathlib import Path\r\n-import time\r\n-import re # For parsing aspect ratio string\r\n-\r\n-# --- USER CONFIGURATION ---\r\n-\r\n-# Path to the root output directory of the Asset Processor Tool\r\n-# Example: r\"G:\\Assets\\Processed\"\r\n-PROCESSED_ASSET_LIBRARY_ROOT = r\"G:\\02 Content\\10-19 Content\\13 Textures Power of Two\\13.00\" # <<< CHANGE THIS PATH!\r\n-\r\n-# Names of the required node group templates in the Blender file\r\n-PARENT_TEMPLATE_NAME = \"Template_PBRSET\"\r\n-CHILD_TEMPLATE_NAME = \"Template_PBRTYPE\"\r\n-\r\n-# Labels of specific nodes within the PARENT template\r\n-ASPECT_RATIO_NODE_LABEL = \"AspectRatioCorrection\" # Value node for UV X-scaling factor\r\n-STATS_NODE_PREFIX = \"Histogram-\" # Prefix for Combine XYZ nodes storing stats (e.g., \"Histogram-ROUGH\")\r\n-\r\n-# Enable/disable the manifest system to track processed assets/maps\r\n-# If enabled, requires the blend file to be saved.\r\n-ENABLE_MANIFEST = True\r\n-\r\n-# Map PBR type strings (from metadata) to Blender color spaces\r\n-# Add more mappings as needed based on your metadata types\r\n-PBR_COLOR_SPACE_MAP = {\r\n- \"AO\": \"Non-Color\", # Usually Non-Color, but depends on workflow\r\n- \"COL\": \"sRGB\",\r\n- \"DISP\": \"Non-Color\",\r\n- \"NRM\": \"Non-Color\",\r\n- \"REFL\": \"Non-Color\", # Reflection/Specular\r\n- \"ROUGH\": \"Non-Color\",\r\n- \"METAL\": \"Non-Color\",\r\n- \"OPC\": \"Non-Color\", # Opacity/Alpha\r\n- \"TRN\": \"Non-Color\", # Transmission\r\n- \"SSS\": \"sRGB\", # Subsurface Color\r\n- \"EMISS\": \"sRGB\", # Emission Color\r\n- # Add other types like GLOSS, HEIGHT, etc. if needed\r\n-}\r\n-DEFAULT_COLOR_SPACE = \"sRGB\" # Fallback if map type not in the dictionary\r\n-\r\n-# Map types for which stats should be applied (if found in metadata and node exists)\r\n-APPLY_STATS_FOR_MAP_TYPES = [\"ROUGH\", \"DISP\", \"METAL\"] # Add others if needed\r\n-\r\n-# --- END USER CONFIGURATION ---\r\n-\r\n-\r\n-# --- Helper Functions ---\r\n-\r\n-def find_nodes_by_label(node_tree, label, node_type=None):\r\n- \"\"\"Finds ALL nodes in a node tree matching the label and optionally type.\"\"\"\r\n- if not node_tree:\r\n- return []\r\n- matching_nodes = []\r\n- for node in node_tree.nodes:\r\n- # Use node.label for labeled nodes, node.name for non-labeled (like Group Input/Output)\r\n- node_identifier = node.label if node.label else node.name\r\n- if node_identifier and node_identifier == label:\r\n- if node_type is None or node.bl_idname == node_type or node.type == node_type: # Check bl_idname and type for flexibility\r\n- matching_nodes.append(node)\r\n- return matching_nodes\r\n-\r\n-def add_tag_if_new(asset_data, tag_name):\r\n- \"\"\"Adds a tag to the asset data if it's not None/empty and doesn't already exist.\"\"\"\r\n- if not asset_data or not tag_name or not isinstance(tag_name, str):\r\n- return False\r\n- cleaned_tag_name = tag_name.strip()\r\n- if not cleaned_tag_name:\r\n- return False\r\n-\r\n- # Check if tag already exists (case-insensitive check might be better sometimes)\r\n- if cleaned_tag_name not in [t.name for t in asset_data.tags]:\r\n- try:\r\n- asset_data.tags.new(cleaned_tag_name)\r\n- print(f\" + Added Asset Tag: '{cleaned_tag_name}'\")\r\n- return True\r\n- except Exception as e:\r\n- print(f\" Error adding tag '{cleaned_tag_name}': {e}\")\r\n- return False\r\n- return False # Tag already existed\r\n-\r\n-def get_color_space(map_type):\r\n- \"\"\"Returns the appropriate Blender color space name for a given map type string.\"\"\"\r\n- return PBR_COLOR_SPACE_MAP.get(map_type.upper(), DEFAULT_COLOR_SPACE)\r\n-\r\n-def calculate_factor_from_string(aspect_string):\r\n- \"\"\"\r\n- Parses the aspect_ratio_change_string from metadata and returns the\r\n- appropriate UV X-scaling factor needed to correct distortion.\r\n- Assumes the string format documented in Asset Processor Tool readme.md:\r\n- \"EVEN\", \"Xnnn\", \"Ynnn\", \"XnnnYnnn\".\r\n- Returns 1.0 if the string is invalid or \"EVEN\".\r\n- \"\"\"\r\n- if not aspect_string or aspect_string.upper() == \"EVEN\":\r\n- return 1.0\r\n-\r\n- x_factor = 1.0\r\n- y_factor = 1.0\r\n-\r\n- # Regex to find X and Y scaling parts\r\n- match_x = re.search(r\"X(\\d+)\", aspect_string, re.IGNORECASE)\r\n- match_y = re.search(r\"Y(\\d+)\", aspect_string, re.IGNORECASE)\r\n-\r\n- try:\r\n- if match_x:\r\n- amount_x = int(match_x.group(1))\r\n- if amount_x > 0:\r\n- x_factor = amount_x / 100.0\r\n- else:\r\n- print(f\" Warn: Invalid X amount in aspect string '{aspect_string}'. Using 1.0.\")\r\n-\r\n- if match_y:\r\n- amount_y = int(match_y.group(1))\r\n- if amount_y > 0:\r\n- y_factor = amount_y / 100.0\r\n- else:\r\n- print(f\" Warn: Invalid Y amount in aspect string '{aspect_string}'. Using 1.0.\")\r\n-\r\n- # The correction factor for the U (X) coordinate is Y/X\r\n- # If X was scaled by 1.5 (X150), U needs to be divided by 1.5 (multiplied by 1/1.5)\r\n- # If Y was scaled by 1.5 (Y150), U needs to be multiplied by 1.5\r\n- if x_factor == 0: # Avoid division by zero\r\n- print(f\" Warn: X factor is zero in aspect string '{aspect_string}'. Cannot calculate correction. Returning 1.0.\")\r\n- return 1.0\r\n-\r\n- correction_factor = y_factor / x_factor\r\n- return correction_factor\r\n-\r\n- except ValueError:\r\n- print(f\" Warn: Invalid number in aspect string '{aspect_string}'. Returning 1.0.\")\r\n- return 1.0\r\n- except Exception as e:\r\n- print(f\" Error parsing aspect string '{aspect_string}': {e}. Returning 1.0.\")\r\n- return 1.0\r\n-\r\n-\r\n-# --- Manifest Functions ---\r\n-\r\n-def get_manifest_path(context):\r\n- \"\"\"Gets the expected path for the manifest JSON file.\"\"\"\r\n- if not context or not context.blend_data or not context.blend_data.filepath:\r\n- return None # Cannot determine path if blend file is not saved\r\n- blend_path = Path(context.blend_data.filepath)\r\n- manifest_filename = f\"{blend_path.stem}_manifest.json\"\r\n- return blend_path.parent / manifest_filename\r\n-\r\n-def load_manifest(context):\r\n- \"\"\"Loads the manifest data from the JSON file.\"\"\"\r\n- if not ENABLE_MANIFEST:\r\n- return {} # Manifest disabled\r\n-\r\n- manifest_path = get_manifest_path(context)\r\n- if not manifest_path:\r\n- print(\" Manifest Info: Blend file not saved. Cannot load manifest.\")\r\n- return {} # Cannot load without a path\r\n-\r\n- if not manifest_path.exists():\r\n- print(f\" Manifest Info: No manifest file found at '{manifest_path.name}'. Starting fresh.\")\r\n- return {} # No manifest file exists yet\r\n-\r\n- try:\r\n- with open(manifest_path, 'r', encoding='utf-8') as f:\r\n- data = json.load(f)\r\n- print(f\" Manifest Loaded from: {manifest_path.name}\")\r\n- # Basic validation (check if it's a dictionary)\r\n- if not isinstance(data, dict):\r\n- print(f\"!!! WARNING: Manifest file '{manifest_path.name}' has invalid format (not a dictionary). Starting fresh. !!!\")\r\n- return {}\r\n- return data\r\n- except json.JSONDecodeError:\r\n- print(f\"!!! WARNING: Manifest file '{manifest_path.name}' is corrupted. Starting fresh. !!!\")\r\n- return {}\r\n- except Exception as e:\r\n- print(f\"!!! ERROR: Could not load manifest file '{manifest_path.name}': {e} !!!\")\r\n- return {} # Treat as starting fresh on error\r\n-\r\n-def save_manifest(context, manifest_data):\r\n- \"\"\"Saves the manifest data to the JSON file.\"\"\"\r\n- if not ENABLE_MANIFEST or not manifest_data: # Don't save if disabled or empty\r\n- return False\r\n-\r\n- manifest_path = get_manifest_path(context)\r\n- if not manifest_path:\r\n- print(\" Manifest Error: Blend file not saved. Cannot save manifest.\")\r\n- return False\r\n-\r\n- try:\r\n- with open(manifest_path, 'w', encoding='utf-8') as f:\r\n- json.dump(manifest_data, f, indent=2, sort_keys=True) # Use indent and sort for readability\r\n- print(f\" Manifest Saved to: {manifest_path.name}\")\r\n- return True\r\n- except Exception as e:\r\n- print(f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\\n\"\r\n- f\"!!! Manifest save FAILED to '{manifest_path.name}': {e} !!!\\n\"\r\n- f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\")\r\n- return False\r\n-\r\n-def is_asset_processed(manifest_data, asset_name):\r\n- \"\"\"Checks if the entire asset (all its maps/resolutions) is marked as processed.\"\"\"\r\n- if not ENABLE_MANIFEST: return False\r\n- # For now, we process map-by-map. An asset is considered processed\r\n- # if its entry exists, but we rely on map checks.\r\n- # This could be enhanced later if needed.\r\n- return asset_name in manifest_data\r\n-\r\n-def is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- \"\"\"Checks if a specific map type and resolution for an asset is processed.\"\"\"\r\n- if not ENABLE_MANIFEST: return False\r\n- return resolution in manifest_data.get(asset_name, {}).get(map_type, [])\r\n-\r\n-def update_manifest(manifest_data, asset_name, map_type=None, resolution=None):\r\n- \"\"\"Updates the manifest dictionary in memory.\"\"\"\r\n- if not ENABLE_MANIFEST: return False\r\n-\r\n- # Ensure asset entry exists\r\n- if asset_name not in manifest_data:\r\n- manifest_data[asset_name] = {}\r\n-\r\n- # If map_type and resolution are provided, update the specific map entry\r\n- if map_type and resolution:\r\n- if map_type not in manifest_data[asset_name]:\r\n- manifest_data[asset_name][map_type] = []\r\n-\r\n- if resolution not in manifest_data[asset_name][map_type]:\r\n- manifest_data[asset_name][map_type].append(resolution)\r\n- manifest_data[asset_name][map_type].sort() # Keep sorted\r\n- return True # Indicate that a change was made\r\n- return False # No change made to this specific map/res\r\n-\r\n-\r\n-# --- Core Logic ---\r\n-\r\n-def process_library(context):\r\n- global ENABLE_MANIFEST # Declare intent to modify global if needed\r\n- \"\"\"Scans the library, reads metadata, creates/updates node groups.\"\"\"\r\n- start_time = time.time()\r\n- print(f\"\\n--- Starting Node Group Processing ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---\")\r\n-\r\n- # --- Pre-run Checks ---\r\n- print(\"Performing pre-run checks...\")\r\n- valid_setup = True\r\n- # 1. Check Library Root Path\r\n- root_path = Path(PROCESSED_ASSET_LIBRARY_ROOT)\r\n- if not root_path.is_dir():\r\n- print(f\"!!! ERROR: Processed asset library root directory not found or not a directory:\")\r\n- print(f\"!!! '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n- valid_setup = False\r\n- else:\r\n- print(f\" Asset Library Root: '{root_path}'\")\r\n-\r\n- # 2. Check Templates\r\n- template_parent = bpy.data.node_groups.get(PARENT_TEMPLATE_NAME)\r\n- template_child = bpy.data.node_groups.get(CHILD_TEMPLATE_NAME)\r\n- if not template_parent:\r\n- print(f\"!!! ERROR: Parent template node group '{PARENT_TEMPLATE_NAME}' not found in this Blender file.\")\r\n- valid_setup = False\r\n- if not template_child:\r\n- print(f\"!!! ERROR: Child template node group '{CHILD_TEMPLATE_NAME}' not found in this Blender file.\")\r\n- valid_setup = False\r\n- if template_parent and template_child:\r\n- print(f\" Found Templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\")\r\n-\r\n- # 3. Check Blend File Saved (if manifest enabled)\r\n- if ENABLE_MANIFEST and not context.blend_data.filepath:\r\n- print(f\"!!! WARNING: Manifest is enabled, but the current Blender file is not saved.\")\r\n- print(f\"!!! Manifest cannot be loaded or saved. Processing will continue without manifest checks.\")\r\n- global ENABLE_MANIFEST # Allow modification of global\r\n- ENABLE_MANIFEST = False # Disable manifest for this run\r\n-\r\n- if not valid_setup:\r\n- print(\"\\n--- Script aborted due to configuration errors. Please fix the issues above. ---\")\r\n- return False\r\n- print(\"Pre-run checks passed.\")\r\n- # --- End Pre-run Checks ---\r\n-\r\n- manifest_data = load_manifest(context)\r\n- manifest_needs_saving = False\r\n-\r\n- # --- Initialize Counters ---\r\n- metadata_files_found = 0\r\n- assets_processed = 0\r\n- assets_skipped_manifest = 0\r\n- parent_groups_created = 0\r\n- parent_groups_updated = 0\r\n- child_groups_created = 0\r\n- child_groups_updated = 0\r\n- images_loaded = 0\r\n- images_assigned = 0\r\n- maps_processed = 0\r\n- maps_skipped_manifest = 0\r\n- errors_encountered = 0\r\n- # --- End Counters ---\r\n-\r\n- print(f\"\\nScanning for metadata files in '{root_path}'...\")\r\n-\r\n- # --- Scan for metadata.json ---\r\n- # Using rglob to find all metadata.json files recursively\r\n- metadata_paths = list(root_path.rglob('metadata.json'))\r\n- metadata_files_found = len(metadata_paths)\r\n- print(f\"Found {metadata_files_found} metadata.json files.\")\r\n-\r\n- if metadata_files_found == 0:\r\n- print(\"No metadata files found. Nothing to process.\")\r\n- print(\"--- Script Finished ---\")\r\n- return True # No work needed is considered success\r\n-\r\n- # --- Process Each Metadata File ---\r\n- for metadata_path in metadata_paths:\r\n- print(f\"\\n--- Processing Metadata: {metadata_path.relative_to(root_path)} ---\")\r\n- try:\r\n- with open(metadata_path, 'r', encoding='utf-8') as f:\r\n- metadata = json.load(f)\r\n-\r\n- # --- Extract Key Info ---\r\n- asset_name = metadata.get(\"asset_name\")\r\n- supplier_name = metadata.get(\"supplier_name\")\r\n- archetype = metadata.get(\"archetype\")\r\n- maps_data = metadata.get(\"maps\")\r\n- aspect_string = metadata.get(\"aspect_ratio_change_string\")\r\n-\r\n- if not asset_name or not maps_data:\r\n- print(f\" !!! ERROR: Metadata file is missing 'asset_name' or 'maps' data. Skipping.\")\r\n- errors_encountered += 1\r\n- continue\r\n-\r\n- print(f\" Asset Name: {asset_name}\")\r\n-\r\n- # --- Manifest Check (Asset Level) ---\r\n- # We primarily check maps, but can skip the whole asset if needed later\r\n- if ENABLE_MANIFEST and is_asset_processed(manifest_data, asset_name):\r\n- # Check if ALL maps/resolutions are actually done, otherwise proceed\r\n- all_maps_done = True\r\n- for map_type, res_data in maps_data.items():\r\n- for resolution in res_data.keys():\r\n- if not is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- all_maps_done = False\r\n- break\r\n- if not all_maps_done:\r\n- break\r\n- if all_maps_done:\r\n- print(f\" Skipping asset '{asset_name}' (already fully processed according to manifest).\")\r\n- assets_skipped_manifest += 1\r\n- continue # Skip to next metadata file\r\n-\r\n- # --- Parent Group Handling ---\r\n- target_parent_name = f\"PBRSET_{asset_name}\"\r\n- parent_group = bpy.data.node_groups.get(target_parent_name)\r\n- is_new_parent = False\r\n-\r\n- if parent_group is None:\r\n- print(f\" Creating new parent group: '{target_parent_name}'\")\r\n- parent_group = template_parent.copy()\r\n- if not parent_group:\r\n- print(f\" !!! ERROR: Failed to copy parent template '{PARENT_TEMPLATE_NAME}'. Skipping asset '{asset_name}'.\")\r\n- errors_encountered += 1\r\n- continue\r\n- parent_group.name = target_parent_name\r\n- parent_groups_created += 1\r\n- is_new_parent = True\r\n- else:\r\n- print(f\" Updating existing parent group: '{target_parent_name}'\")\r\n- parent_groups_updated += 1\r\n-\r\n- # Ensure marked as asset\r\n- if not parent_group.asset_data:\r\n- try:\r\n- parent_group.asset_mark()\r\n- print(f\" Marked '{parent_group.name}' as asset.\")\r\n- except Exception as e_mark:\r\n- print(f\" !!! ERROR: Failed to mark group '{parent_group.name}' as asset: {e_mark}\")\r\n- # Continue processing other parts if possible\r\n-\r\n- # Apply Asset Tags\r\n- if parent_group.asset_data:\r\n- if supplier_name:\r\n- add_tag_if_new(parent_group.asset_data, supplier_name)\r\n- if archetype:\r\n- add_tag_if_new(parent_group.asset_data, archetype)\r\n- # Add other tags if needed\r\n- # else: print(f\" Warn: Cannot apply tags, asset_data not available for '{parent_group.name}'.\") # Optional warning\r\n-\r\n- # Apply Aspect Ratio Correction\r\n- aspect_nodes = find_nodes_by_label(parent_group, ASPECT_RATIO_NODE_LABEL, 'ShaderNodeValue')\r\n- if aspect_nodes:\r\n- aspect_node = aspect_nodes[0]\r\n- correction_factor = calculate_factor_from_string(aspect_string)\r\n- # Check if update is needed (avoids unnecessary console spam)\r\n- if abs(aspect_node.outputs[0].default_value - correction_factor) > 0.0001:\r\n- aspect_node.outputs[0].default_value = correction_factor\r\n- print(f\" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f} (from string '{aspect_string}')\")\r\n- # else: print(f\" Warn: Aspect ratio node '{ASPECT_RATIO_NODE_LABEL}' not found in parent group.\") # Optional\r\n-\r\n- # Apply Stats\r\n- for map_type_to_stat in APPLY_STATS_FOR_MAP_TYPES:\r\n- if map_type_to_stat in maps_data:\r\n- # Find the stats node in the parent group\r\n- stats_node_label = f\"{STATS_NODE_PREFIX}{map_type_to_stat}\"\r\n- stats_nodes = find_nodes_by_label(parent_group, stats_node_label, 'ShaderNodeCombineXYZ')\r\n- if stats_nodes:\r\n- stats_node = stats_nodes[0]\r\n- # Find the stats data in the metadata (usually from a specific resolution)\r\n- # Let's assume the stats are stored directly under the map_type entry\r\n- map_metadata = maps_data[map_type_to_stat]\r\n- stats = map_metadata.get(\"stats\") # Expecting {\"min\": float, \"max\": float, \"mean\": float}\r\n- if stats and isinstance(stats, dict):\r\n- min_val = stats.get(\"min\")\r\n- max_val = stats.get(\"max\")\r\n- mean_val = stats.get(\"mean\") # Often stored as 'mean' or 'avg'\r\n-\r\n- updated_stat = False\r\n- if min_val is not None and abs(stats_node.inputs[0].default_value - min_val) > 0.0001:\r\n- stats_node.inputs[0].default_value = min_val\r\n- updated_stat = True\r\n- if max_val is not None and abs(stats_node.inputs[1].default_value - max_val) > 0.0001:\r\n- stats_node.inputs[1].default_value = max_val\r\n- updated_stat = True\r\n- if mean_val is not None and abs(stats_node.inputs[2].default_value - mean_val) > 0.0001:\r\n- stats_node.inputs[2].default_value = mean_val\r\n- updated_stat = True\r\n-\r\n- if updated_stat:\r\n- print(f\" Set stats in '{stats_node_label}': Min={min_val:.4f}, Max={max_val:.4f}, Mean={mean_val:.4f}\")\r\n- # else: print(f\" Info: No 'stats' dictionary found for map type '{map_type_to_stat}' in metadata.\") # Optional\r\n- # else: print(f\" Warn: Stats node '{stats_node_label}' not found in parent group.\") # Optional\r\n- # else: print(f\" Info: Map type '{map_type_to_stat}' not present in metadata for stats application.\") # Optional\r\n-\r\n-\r\n- # --- Child Group Handling ---\r\n- processed_asset_flag = True # Track if any map within the asset was actually processed in this run\r\n- for map_type, type_data in maps_data.items():\r\n- print(f\" Processing Map Type: {map_type}\")\r\n-\r\n- # Find placeholder node in parent\r\n- # Placeholders might be labeled directly with the map type (e.g., \"NRM\", \"COL\")\r\n- holder_nodes = find_nodes_by_label(parent_group, map_type, 'ShaderNodeGroup')\r\n- if not holder_nodes:\r\n- print(f\" !!! WARNING: No placeholder node labeled '{map_type}' found in parent group '{parent_group.name}'. Skipping this map type.\")\r\n- continue\r\n- holder_node = holder_nodes[0] # Assume first is correct\r\n-\r\n- # Determine child group name\r\n- target_child_name = f\"PBRTYPE_{asset_name}_{map_type}\"\r\n- child_group = bpy.data.node_groups.get(target_child_name)\r\n- is_new_child = False\r\n-\r\n- if child_group is None:\r\n- # print(f\" Creating new child group: '{target_child_name}'\") # Verbose\r\n- child_group = template_child.copy()\r\n- if not child_group:\r\n- print(f\" !!! ERROR: Failed to copy child template '{CHILD_TEMPLATE_NAME}'. Skipping map type '{map_type}'.\")\r\n- errors_encountered += 1\r\n- continue\r\n- child_group.name = target_child_name\r\n- child_groups_created += 1\r\n- is_new_child = True\r\n- else:\r\n- # print(f\" Updating existing child group: '{target_child_name}'\") # Verbose\r\n- child_groups_updated += 1\r\n-\r\n- # Assign child group to placeholder if needed\r\n- if holder_node.node_tree != child_group:\r\n- try:\r\n- holder_node.node_tree = child_group\r\n- print(f\" Assigned child group '{child_group.name}' to placeholder '{holder_node.label}'.\")\r\n- except TypeError as e_assign: # Catch potential type errors if placeholder isn't a group node\r\n- print(f\" !!! ERROR: Could not assign child group to placeholder '{holder_node.label}'. Is it a Group Node? Error: {e_assign}\")\r\n- continue # Skip this map type if assignment fails\r\n-\r\n- # Link placeholder output to parent output socket\r\n- try:\r\n- # Find parent's output node\r\n- group_output_node = next((n for n in parent_group.nodes if n.type == 'GROUP_OUTPUT'), None)\r\n- if group_output_node:\r\n- # Get the specific output socket on the placeholder (usually index 0 or named 'Color'/'Value')\r\n- source_socket = holder_node.outputs.get(\"Color\") or holder_node.outputs.get(\"Value\") or holder_node.outputs[0]\r\n- # Get the specific input socket on the parent output node (matching map_type)\r\n- target_socket = group_output_node.inputs.get(map_type)\r\n-\r\n- if source_socket and target_socket:\r\n- # Check if link already exists\r\n- link_exists = any(link.from_socket == source_socket and link.to_socket == target_socket for link in parent_group.links)\r\n- if not link_exists:\r\n- parent_group.links.new(source_socket, target_socket)\r\n- print(f\" Linked '{holder_node.label}' output to parent output socket '{map_type}'.\")\r\n- # else: # Optional warnings\r\n- # if not source_socket: print(f\" Warn: Could not find suitable output socket on placeholder '{holder_node.label}'.\")\r\n- # if not target_socket: print(f\" Warn: Could not find input socket '{map_type}' on parent output node.\")\r\n- # else: print(f\" Warn: Parent group '{parent_group.name}' has no Group Output node.\")\r\n-\r\n- except Exception as e_link:\r\n- print(f\" !!! ERROR linking sockets for '{map_type}': {e_link}\")\r\n-\r\n- # Ensure parent output socket type is Color (if it exists)\r\n- try:\r\n- # Use the interface API for modern Blender versions\r\n- item = parent_group.interface.items_tree.get(map_type)\r\n- if item and item.item_type == 'SOCKET' and item.in_out == 'OUTPUT':\r\n- # Common useful types: 'NodeSocketColor', 'NodeSocketVector', 'NodeSocketFloat'\r\n- # Defaulting to Color seems reasonable for most PBR outputs\r\n- if item.socket_type != 'NodeSocketColor':\r\n- item.socket_type = 'NodeSocketColor'\r\n- # print(f\" Set parent output socket '{map_type}' type to Color.\") # Optional info\r\n- except Exception as e_sock_type:\r\n- print(f\" Warn: Could not verify/set socket type for '{map_type}': {e_sock_type}\")\r\n-\r\n-\r\n- # --- Image Node Handling (Inside Child Group) ---\r\n- # 'type_data' should be the dictionary containing resolutions and paths for this map_type\r\n- if not isinstance(type_data, dict):\r\n- print(f\" !!! ERROR: Invalid format for map type '{map_type}' data in metadata. Skipping.\")\r\n- continue\r\n-\r\n- for resolution, map_info in type_data.items():\r\n- # 'map_info' should be the dictionary containing 'path', 'stats', etc. for this resolution\r\n- if not isinstance(map_info, dict) or \"path\" not in map_info:\r\n- print(f\" !!! WARNING: Invalid or missing path data for {map_type}/{resolution}. Skipping.\")\r\n- continue\r\n-\r\n- image_path_str = map_info[\"path\"]\r\n-\r\n- # --- Manifest Check (Map/Resolution Level) ---\r\n- if ENABLE_MANIFEST and is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- # print(f\" Skipping {resolution} (Manifest)\") # Verbose\r\n- maps_skipped_manifest += 1\r\n- continue\r\n-\r\n- print(f\" Processing Resolution: {resolution}\")\r\n-\r\n- # Find image texture node within the CHILD group (labeled by resolution, e.g., \"4K\")\r\n- image_nodes = find_nodes_by_label(child_group, resolution, 'ShaderNodeTexImage')\r\n- if not image_nodes:\r\n- print(f\" !!! WARNING: No Image Texture node labeled '{resolution}' found in child group '{child_group.name}'. Cannot assign image.\")\r\n- continue # Skip this resolution if node not found\r\n-\r\n- # --- Load Image ---\r\n- img = None\r\n- image_load_failed = False\r\n- try:\r\n- image_path = Path(image_path_str)\r\n- if not image_path.is_file():\r\n- print(f\" !!! ERROR: Image file not found: {image_path_str}\")\r\n- image_load_failed = True\r\n- else:\r\n- # Use check_existing=True to reuse existing datablocks if path matches\r\n- img = bpy.data.images.load(str(image_path), check_existing=True)\r\n- if not img:\r\n- print(f\" !!! ERROR: Failed loading image via bpy.data.images.load: {image_path_str}\")\r\n- image_load_failed = True\r\n- else:\r\n- images_loaded += 1 # Count successful loads\r\n- except RuntimeError as e_runtime_load:\r\n- # Catch specific Blender runtime errors (e.g., unsupported format)\r\n- print(f\" !!! ERROR loading image '{image_path_str}': {e_runtime_load}\")\r\n- image_load_failed = True\r\n- except Exception as e_gen_load:\r\n- print(f\" !!! UNEXPECTED ERROR loading image '{image_path_str}': {e_gen_load}\")\r\n- image_load_failed = True\r\n- errors_encountered += 1\r\n-\r\n- # --- Assign Image & Set Color Space ---\r\n- if not image_load_failed and img:\r\n- assigned_count_this_res = 0\r\n- for image_node in image_nodes:\r\n- if image_node.image != img:\r\n- image_node.image = img\r\n- assigned_count_this_res += 1\r\n-\r\n- if assigned_count_this_res > 0:\r\n- images_assigned += assigned_count_this_res\r\n- print(f\" Assigned image '{img.name}' to {assigned_count_this_res} node(s).\")\r\n-\r\n- # Set Color Space\r\n- correct_color_space = get_color_space(map_type)\r\n- try:\r\n- if img.colorspace_settings.name != correct_color_space:\r\n- img.colorspace_settings.name = correct_color_space\r\n- print(f\" Set '{img.name}' color space -> {correct_color_space}\")\r\n- except TypeError as e_cs: # Handle case where colorspace name is invalid\r\n- print(f\" !!! WARNING: Could not set color space '{correct_color_space}' for image '{img.name}'. Is the color space available in Blender? Error: {e_cs}\")\r\n- except Exception as e_cs_gen:\r\n- print(f\" !!! ERROR setting color space for image '{img.name}': {e_cs_gen}\")\r\n-\r\n-\r\n- # --- Update Manifest (Map/Resolution Level) ---\r\n- if update_manifest(manifest_data, asset_name, map_type, resolution):\r\n- manifest_needs_saving = True\r\n- # print(f\" Marked {map_type}/{resolution} processed in manifest.\") # Verbose\r\n- maps_processed += 1\r\n- processed_asset_flag = False # Mark that something was processed for this asset\r\n-\r\n- else:\r\n- errors_encountered += 1\r\n- processed_asset_flag = False # Still counts as an attempt for this asset\r\n-\r\n- # --- End Resolution Loop ---\r\n- # --- End Map Type Loop ---\r\n-\r\n- # --- Update Manifest (Asset Level - if fully processed) ---\r\n- # This logic might be redundant if we always check map level, but can be added\r\n- # if needed to mark the whole asset as 'touched' or 'completed'.\r\n- # For now, we rely on map-level updates.\r\n- # if not processed_asset_flag: # If any map was processed (or attempted)\r\n- # update_manifest(manifest_data, asset_name) # Mark asset as processed\r\n- # manifest_needs_saving = True\r\n-\r\n- assets_processed += 1\r\n-\r\n- except FileNotFoundError:\r\n- print(f\" !!! ERROR: Metadata file not found (should not happen if scan worked): {metadata_path}\")\r\n- errors_encountered += 1\r\n- except json.JSONDecodeError:\r\n- print(f\" !!! ERROR: Invalid JSON in metadata file: {metadata_path}\")\r\n- errors_encountered += 1\r\n- except Exception as e_main_loop:\r\n- print(f\" !!! UNEXPECTED ERROR processing asset from {metadata_path}: {e_main_loop}\")\r\n- import traceback\r\n- traceback.print_exc() # Print detailed traceback for debugging\r\n- errors_encountered += 1\r\n- # Continue to the next asset\r\n-\r\n- # --- End Metadata File Loop ---\r\n-\r\n- # --- Final Manifest Save ---\r\n- if ENABLE_MANIFEST and manifest_needs_saving:\r\n- print(\"\\nAttempting final manifest save...\")\r\n- save_manifest(context, manifest_data)\r\n- elif ENABLE_MANIFEST:\r\n- print(\"\\nManifest is enabled, but no changes require saving.\")\r\n- # --- End Final Manifest Save ---\r\n-\r\n- # --- Final Summary ---\r\n- end_time = time.time()\r\n- duration = end_time - start_time\r\n- print(\"\\n--- Script Run Finished ---\")\r\n- print(f\"Duration: {duration:.2f} seconds\")\r\n- print(f\"Metadata Files Found: {metadata_files_found}\")\r\n- print(f\"Assets Processed/Attempted: {assets_processed}\")\r\n- if ENABLE_MANIFEST:\r\n- print(f\"Assets Skipped (Manifest): {assets_skipped_manifest}\")\r\n- print(f\"Maps Skipped (Manifest): {maps_skipped_manifest}\")\r\n- print(f\"Parent Groups Created: {parent_groups_created}\")\r\n- print(f\"Parent Groups Updated: {parent_groups_updated}\")\r\n- print(f\"Child Groups Created: {child_groups_created}\")\r\n- print(f\"Child Groups Updated: {child_groups_updated}\")\r\n- print(f\"Images Loaded: {images_loaded}\")\r\n- print(f\"Image Nodes Assigned: {images_assigned}\")\r\n- print(f\"Individual Maps Processed: {maps_processed}\")\r\n- if errors_encountered > 0:\r\n- print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n- print(\"---------------------------\")\r\n-\r\n- return True\r\n-\r\n-\r\n-# --- Execution Block ---\r\n-\r\n-if __name__ == \"__main__\":\r\n- # Ensure we are running within Blender\r\n- try:\r\n- import bpy\r\n- except ImportError:\r\n- print(\"!!! ERROR: This script must be run from within Blender. !!!\")\r\n- else:\r\n- process_library(bpy.context)\n-# Blender Script: Create/Update Node Groups from Asset Processor Output\r\n-# Version: 1.0\r\n-# Description: Scans a library processed by the Asset Processor Tool,\r\n-# reads metadata.json files, and creates/updates corresponding\r\n-# PBR node groups in the active Blender file.\r\n-\r\n-import bpy\r\n-import os\r\n-import json\r\n-from pathlib import Path\r\n-import time\r\n-import re # For parsing aspect ratio string\r\n-\r\n-# --- USER CONFIGURATION ---\r\n-\r\n-# Path to the root output directory of the Asset Processor Tool\r\n-# Example: r\"G:\\Assets\\Processed\"\r\n-PROCESSED_ASSET_LIBRARY_ROOT = r\"G:\\02 Content\\10-19 Content\\13 Textures Power of Two\\13.00\" # <<< CHANGE THIS PATH!\r\n-\r\n-# Names of the required node group templates in the Blender file\r\n-PARENT_TEMPLATE_NAME = \"Template_PBRSET\"\r\n-CHILD_TEMPLATE_NAME = \"Template_PBRTYPE\"\r\n-\r\n-# Labels of specific nodes within the PARENT template\r\n-ASPECT_RATIO_NODE_LABEL = \"AspectRatioCorrection\" # Value node for UV X-scaling factor\r\n-STATS_NODE_PREFIX = \"Histogram-\" # Prefix for Combine XYZ nodes storing stats (e.g., \"Histogram-ROUGH\")\r\n-\r\n-# Enable/disable the manifest system to track processed assets/maps\r\n-# If enabled, requires the blend file to be saved.\r\n-ENABLE_MANIFEST = True\r\n-\r\n-# Map PBR type strings (from metadata) to Blender color spaces\r\n-# Add more mappings as needed based on your metadata types\r\n-PBR_COLOR_SPACE_MAP = {\r\n- \"AO\": \"Non-Color\", # Usually Non-Color, but depends on workflow\r\n- \"COL\": \"sRGB\",\r\n- \"DISP\": \"Non-Color\",\r\n- \"NRM\": \"Non-Color\",\r\n- \"REFL\": \"Non-Color\", # Reflection/Specular\r\n- \"ROUGH\": \"Non-Color\",\r\n- \"METAL\": \"Non-Color\",\r\n- \"OPC\": \"Non-Color\", # Opacity/Alpha\r\n- \"TRN\": \"Non-Color\", # Transmission\r\n- \"SSS\": \"sRGB\", # Subsurface Color\r\n- \"EMISS\": \"sRGB\", # Emission Color\r\n- # Add other types like GLOSS, HEIGHT, etc. if needed\r\n-}\r\n-DEFAULT_COLOR_SPACE = \"sRGB\" # Fallback if map type not in the dictionary\r\n-\r\n-# Map types for which stats should be applied (if found in metadata and node exists)\r\n-APPLY_STATS_FOR_MAP_TYPES = [\"ROUGH\", \"DISP\", \"METAL\"] # Add others if needed\r\n-\r\n-# --- END USER CONFIGURATION ---\r\n-\r\n-\r\n-# --- Helper Functions ---\r\n-\r\n-def find_nodes_by_label(node_tree, label, node_type=None):\r\n- \"\"\"Finds ALL nodes in a node tree matching the label and optionally type.\"\"\"\r\n- if not node_tree:\r\n- return []\r\n- matching_nodes = []\r\n- for node in node_tree.nodes:\r\n- # Use node.label for labeled nodes, node.name for non-labeled (like Group Input/Output)\r\n- node_identifier = node.label if node.label else node.name\r\n- if node_identifier and node_identifier == label:\r\n- if node_type is None or node.bl_idname == node_type or node.type == node_type: # Check bl_idname and type for flexibility\r\n- matching_nodes.append(node)\r\n- return matching_nodes\r\n-\r\n-def add_tag_if_new(asset_data, tag_name):\r\n- \"\"\"Adds a tag to the asset data if it's not None/empty and doesn't already exist.\"\"\"\r\n- if not asset_data or not tag_name or not isinstance(tag_name, str):\r\n- return False\r\n- cleaned_tag_name = tag_name.strip()\r\n- if not cleaned_tag_name:\r\n- return False\r\n-\r\n- # Check if tag already exists (case-insensitive check might be better sometimes)\r\n- if cleaned_tag_name not in [t.name for t in asset_data.tags]:\r\n- try:\r\n- asset_data.tags.new(cleaned_tag_name)\r\n- print(f\" + Added Asset Tag: '{cleaned_tag_name}'\")\r\n- return True\r\n- except Exception as e:\r\n- print(f\" Error adding tag '{cleaned_tag_name}': {e}\")\r\n- return False\r\n- return False # Tag already existed\r\n-\r\n-def get_color_space(map_type):\r\n- \"\"\"Returns the appropriate Blender color space name for a given map type string.\"\"\"\r\n- return PBR_COLOR_SPACE_MAP.get(map_type.upper(), DEFAULT_COLOR_SPACE)\r\n-\r\n-def calculate_factor_from_string(aspect_string):\r\n- \"\"\"\r\n- Parses the aspect_ratio_change_string from metadata and returns the\r\n- appropriate UV X-scaling factor needed to correct distortion.\r\n- Assumes the string format documented in Asset Processor Tool readme.md:\r\n- \"EVEN\", \"Xnnn\", \"Ynnn\", \"XnnnYnnn\".\r\n- Returns 1.0 if the string is invalid or \"EVEN\".\r\n- \"\"\"\r\n- if not aspect_string or aspect_string.upper() == \"EVEN\":\r\n- return 1.0\r\n-\r\n- x_factor = 1.0\r\n- y_factor = 1.0\r\n-\r\n- # Regex to find X and Y scaling parts\r\n- match_x = re.search(r\"X(\\d+)\", aspect_string, re.IGNORECASE)\r\n- match_y = re.search(r\"Y(\\d+)\", aspect_string, re.IGNORECASE)\r\n-\r\n- try:\r\n- if match_x:\r\n- amount_x = int(match_x.group(1))\r\n- if amount_x > 0:\r\n- x_factor = amount_x / 100.0\r\n- else:\r\n- print(f\" Warn: Invalid X amount in aspect string '{aspect_string}'. Using 1.0.\")\r\n-\r\n- if match_y:\r\n- amount_y = int(match_y.group(1))\r\n- if amount_y > 0:\r\n- y_factor = amount_y / 100.0\r\n- else:\r\n- print(f\" Warn: Invalid Y amount in aspect string '{aspect_string}'. Using 1.0.\")\r\n-\r\n- # The correction factor for the U (X) coordinate is Y/X\r\n- # If X was scaled by 1.5 (X150), U needs to be divided by 1.5 (multiplied by 1/1.5)\r\n- # If Y was scaled by 1.5 (Y150), U needs to be multiplied by 1.5\r\n- if x_factor == 0: # Avoid division by zero\r\n- print(f\" Warn: X factor is zero in aspect string '{aspect_string}'. Cannot calculate correction. Returning 1.0.\")\r\n- return 1.0\r\n-\r\n- correction_factor = y_factor / x_factor\r\n- return correction_factor\r\n-\r\n- except ValueError:\r\n- print(f\" Warn: Invalid number in aspect string '{aspect_string}'. Returning 1.0.\")\r\n- return 1.0\r\n- except Exception as e:\r\n- print(f\" Error parsing aspect string '{aspect_string}': {e}. Returning 1.0.\")\r\n- return 1.0\r\n-\r\n-\r\n-# --- Manifest Functions ---\r\n-\r\n-def get_manifest_path(context):\r\n- \"\"\"Gets the expected path for the manifest JSON file.\"\"\"\r\n- if not context or not context.blend_data or not context.blend_data.filepath:\r\n- return None # Cannot determine path if blend file is not saved\r\n- blend_path = Path(context.blend_data.filepath)\r\n- manifest_filename = f\"{blend_path.stem}_manifest.json\"\r\n- return blend_path.parent / manifest_filename\r\n-\r\n-def load_manifest(context):\r\n- \"\"\"Loads the manifest data from the JSON file.\"\"\"\r\n- if not ENABLE_MANIFEST:\r\n- return {} # Manifest disabled\r\n-\r\n- manifest_path = get_manifest_path(context)\r\n- if not manifest_path:\r\n- print(\" Manifest Info: Blend file not saved. Cannot load manifest.\")\r\n- return {} # Cannot load without a path\r\n-\r\n- if not manifest_path.exists():\r\n- print(f\" Manifest Info: No manifest file found at '{manifest_path.name}'. Starting fresh.\")\r\n- return {} # No manifest file exists yet\r\n-\r\n- try:\r\n- with open(manifest_path, 'r', encoding='utf-8') as f:\r\n- data = json.load(f)\r\n- print(f\" Manifest Loaded from: {manifest_path.name}\")\r\n- # Basic validation (check if it's a dictionary)\r\n- if not isinstance(data, dict):\r\n- print(f\"!!! WARNING: Manifest file '{manifest_path.name}' has invalid format (not a dictionary). Starting fresh. !!!\")\r\n- return {}\r\n- return data\r\n- except json.JSONDecodeError:\r\n- print(f\"!!! WARNING: Manifest file '{manifest_path.name}' is corrupted. Starting fresh. !!!\")\r\n- return {}\r\n- except Exception as e:\r\n- print(f\"!!! ERROR: Could not load manifest file '{manifest_path.name}': {e} !!!\")\r\n- return {} # Treat as starting fresh on error\r\n-\r\n-def save_manifest(context, manifest_data):\r\n- \"\"\"Saves the manifest data to the JSON file.\"\"\"\r\n- if not ENABLE_MANIFEST or not manifest_data: # Don't save if disabled or empty\r\n- return False\r\n-\r\n- manifest_path = get_manifest_path(context)\r\n- if not manifest_path:\r\n- print(\" Manifest Error: Blend file not saved. Cannot save manifest.\")\r\n- return False\r\n-\r\n- try:\r\n- with open(manifest_path, 'w', encoding='utf-8') as f:\r\n- json.dump(manifest_data, f, indent=2, sort_keys=True) # Use indent and sort for readability\r\n- print(f\" Manifest Saved to: {manifest_path.name}\")\r\n- return True\r\n- except Exception as e:\r\n- print(f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\\n\"\r\n- f\"!!! Manifest save FAILED to '{manifest_path.name}': {e} !!!\\n\"\r\n- f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\")\r\n- return False\r\n-\r\n-def is_asset_processed(manifest_data, asset_name):\r\n- \"\"\"Checks if the entire asset (all its maps/resolutions) is marked as processed.\"\"\"\r\n- if not ENABLE_MANIFEST: return False\r\n- # For now, we process map-by-map. An asset is considered processed\r\n- # if its entry exists, but we rely on map checks.\r\n- # This could be enhanced later if needed.\r\n- return asset_name in manifest_data\r\n-\r\n-def is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- \"\"\"Checks if a specific map type and resolution for an asset is processed.\"\"\"\r\n- if not ENABLE_MANIFEST: return False\r\n- return resolution in manifest_data.get(asset_name, {}).get(map_type, [])\r\n-\r\n-def update_manifest(manifest_data, asset_name, map_type=None, resolution=None):\r\n- \"\"\"Updates the manifest dictionary in memory.\"\"\"\r\n- if not ENABLE_MANIFEST: return False\r\n-\r\n- # Ensure asset entry exists\r\n- if asset_name not in manifest_data:\r\n- manifest_data[asset_name] = {}\r\n-\r\n- # If map_type and resolution are provided, update the specific map entry\r\n- if map_type and resolution:\r\n- if map_type not in manifest_data[asset_name]:\r\n- manifest_data[asset_name][map_type] = []\r\n-\r\n- if resolution not in manifest_data[asset_name][map_type]:\r\n- manifest_data[asset_name][map_type].append(resolution)\r\n- manifest_data[asset_name][map_type].sort() # Keep sorted\r\n- return True # Indicate that a change was made\r\n- return False # No change made to this specific map/res\r\n-\r\n-\r\n-# --- Core Logic ---\r\n-\r\n-def process_library(context):\r\n- \"\"\"Scans the library, reads metadata, creates/updates node groups.\"\"\"\r\n- start_time = time.time()\r\n- print(f\"\\n--- Starting Node Group Processing ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---\")\r\n-\r\n- # --- Pre-run Checks ---\r\n- print(\"Performing pre-run checks...\")\r\n- valid_setup = True\r\n- # 1. Check Library Root Path\r\n- root_path = Path(PROCESSED_ASSET_LIBRARY_ROOT)\r\n- if not root_path.is_dir():\r\n- print(f\"!!! ERROR: Processed asset library root directory not found or not a directory:\")\r\n- print(f\"!!! '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n- valid_setup = False\r\n- else:\r\n- print(f\" Asset Library Root: '{root_path}'\")\r\n-\r\n- # 2. Check Templates\r\n- template_parent = bpy.data.node_groups.get(PARENT_TEMPLATE_NAME)\r\n- template_child = bpy.data.node_groups.get(CHILD_TEMPLATE_NAME)\r\n- if not template_parent:\r\n- print(f\"!!! ERROR: Parent template node group '{PARENT_TEMPLATE_NAME}' not found in this Blender file.\")\r\n- valid_setup = False\r\n- if not template_child:\r\n- print(f\"!!! ERROR: Child template node group '{CHILD_TEMPLATE_NAME}' not found in this Blender file.\")\r\n- valid_setup = False\r\n- if template_parent and template_child:\r\n- print(f\" Found Templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\")\r\n-\r\n- # 3. Check Blend File Saved (if manifest enabled)\r\n- if ENABLE_MANIFEST and not context.blend_data.filepath:\r\n- print(f\"!!! WARNING: Manifest is enabled, but the current Blender file is not saved.\")\r\n- print(f\"!!! Manifest cannot be loaded or saved. Processing will continue without manifest checks.\")\r\n- global ENABLE_MANIFEST # Allow modification of global\r\n- ENABLE_MANIFEST = False # Disable manifest for this run\r\n-\r\n- if not valid_setup:\r\n- print(\"\\n--- Script aborted due to configuration errors. Please fix the issues above. ---\")\r\n- return False\r\n- print(\"Pre-run checks passed.\")\r\n- # --- End Pre-run Checks ---\r\n-\r\n- manifest_data = load_manifest(context)\r\n- manifest_needs_saving = False\r\n-\r\n- # --- Initialize Counters ---\r\n- metadata_files_found = 0\r\n- assets_processed = 0\r\n- assets_skipped_manifest = 0\r\n- parent_groups_created = 0\r\n- parent_groups_updated = 0\r\n- child_groups_created = 0\r\n- child_groups_updated = 0\r\n- images_loaded = 0\r\n- images_assigned = 0\r\n- maps_processed = 0\r\n- maps_skipped_manifest = 0\r\n- errors_encountered = 0\r\n- # --- End Counters ---\r\n-\r\n- print(f\"\\nScanning for metadata files in '{root_path}'...\")\r\n-\r\n- # --- Scan for metadata.json ---\r\n- # Using rglob to find all metadata.json files recursively\r\n- metadata_paths = list(root_path.rglob('metadata.json'))\r\n- metadata_files_found = len(metadata_paths)\r\n- print(f\"Found {metadata_files_found} metadata.json files.\")\r\n-\r\n- if metadata_files_found == 0:\r\n- print(\"No metadata files found. Nothing to process.\")\r\n- print(\"--- Script Finished ---\")\r\n- return True # No work needed is considered success\r\n-\r\n- # --- Process Each Metadata File ---\r\n- for metadata_path in metadata_paths:\r\n- print(f\"\\n--- Processing Metadata: {metadata_path.relative_to(root_path)} ---\")\r\n- try:\r\n- with open(metadata_path, 'r', encoding='utf-8') as f:\r\n- metadata = json.load(f)\r\n-\r\n- # --- Extract Key Info ---\r\n- asset_name = metadata.get(\"asset_name\")\r\n- supplier_name = metadata.get(\"supplier_name\")\r\n- archetype = metadata.get(\"archetype\")\r\n- maps_data = metadata.get(\"maps\")\r\n- aspect_string = metadata.get(\"aspect_ratio_change_string\")\r\n-\r\n- if not asset_name or not maps_data:\r\n- print(f\" !!! ERROR: Metadata file is missing 'asset_name' or 'maps' data. Skipping.\")\r\n- errors_encountered += 1\r\n- continue\r\n-\r\n- print(f\" Asset Name: {asset_name}\")\r\n-\r\n- # --- Manifest Check (Asset Level) ---\r\n- # We primarily check maps, but can skip the whole asset if needed later\r\n- if ENABLE_MANIFEST and is_asset_processed(manifest_data, asset_name):\r\n- # Check if ALL maps/resolutions are actually done, otherwise proceed\r\n- all_maps_done = True\r\n- for map_type, res_data in maps_data.items():\r\n- for resolution in res_data.keys():\r\n- if not is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- all_maps_done = False\r\n- break\r\n- if not all_maps_done:\r\n- break\r\n- if all_maps_done:\r\n- print(f\" Skipping asset '{asset_name}' (already fully processed according to manifest).\")\r\n- assets_skipped_manifest += 1\r\n- continue # Skip to next metadata file\r\n-\r\n- # --- Parent Group Handling ---\r\n- target_parent_name = f\"PBRSET_{asset_name}\"\r\n- parent_group = bpy.data.node_groups.get(target_parent_name)\r\n- is_new_parent = False\r\n-\r\n- if parent_group is None:\r\n- print(f\" Creating new parent group: '{target_parent_name}'\")\r\n- parent_group = template_parent.copy()\r\n- if not parent_group:\r\n- print(f\" !!! ERROR: Failed to copy parent template '{PARENT_TEMPLATE_NAME}'. Skipping asset '{asset_name}'.\")\r\n- errors_encountered += 1\r\n- continue\r\n- parent_group.name = target_parent_name\r\n- parent_groups_created += 1\r\n- is_new_parent = True\r\n- else:\r\n- print(f\" Updating existing parent group: '{target_parent_name}'\")\r\n- parent_groups_updated += 1\r\n-\r\n- # Ensure marked as asset\r\n- if not parent_group.asset_data:\r\n- try:\r\n- parent_group.asset_mark()\r\n- print(f\" Marked '{parent_group.name}' as asset.\")\r\n- except Exception as e_mark:\r\n- print(f\" !!! ERROR: Failed to mark group '{parent_group.name}' as asset: {e_mark}\")\r\n- # Continue processing other parts if possible\r\n-\r\n- # Apply Asset Tags\r\n- if parent_group.asset_data:\r\n- if supplier_name:\r\n- add_tag_if_new(parent_group.asset_data, supplier_name)\r\n- if archetype:\r\n- add_tag_if_new(parent_group.asset_data, archetype)\r\n- # Add other tags if needed\r\n- # else: print(f\" Warn: Cannot apply tags, asset_data not available for '{parent_group.name}'.\") # Optional warning\r\n-\r\n- # Apply Aspect Ratio Correction\r\n- aspect_nodes = find_nodes_by_label(parent_group, ASPECT_RATIO_NODE_LABEL, 'ShaderNodeValue')\r\n- if aspect_nodes:\r\n- aspect_node = aspect_nodes[0]\r\n- correction_factor = calculate_factor_from_string(aspect_string)\r\n- # Check if update is needed (avoids unnecessary console spam)\r\n- if abs(aspect_node.outputs[0].default_value - correction_factor) > 0.0001:\r\n- aspect_node.outputs[0].default_value = correction_factor\r\n- print(f\" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f} (from string '{aspect_string}')\")\r\n- # else: print(f\" Warn: Aspect ratio node '{ASPECT_RATIO_NODE_LABEL}' not found in parent group.\") # Optional\r\n-\r\n- # Apply Stats\r\n- for map_type_to_stat in APPLY_STATS_FOR_MAP_TYPES:\r\n- if map_type_to_stat in maps_data:\r\n- # Find the stats node in the parent group\r\n- stats_node_label = f\"{STATS_NODE_PREFIX}{map_type_to_stat}\"\r\n- stats_nodes = find_nodes_by_label(parent_group, stats_node_label, 'ShaderNodeCombineXYZ')\r\n- if stats_nodes:\r\n- stats_node = stats_nodes[0]\r\n- # Find the stats data in the metadata (usually from a specific resolution)\r\n- # Let's assume the stats are stored directly under the map_type entry\r\n- map_metadata = maps_data[map_type_to_stat]\r\n- stats = map_metadata.get(\"stats\") # Expecting {\"min\": float, \"max\": float, \"mean\": float}\r\n- if stats and isinstance(stats, dict):\r\n- min_val = stats.get(\"min\")\r\n- max_val = stats.get(\"max\")\r\n- mean_val = stats.get(\"mean\") # Often stored as 'mean' or 'avg'\r\n-\r\n- updated_stat = False\r\n- if min_val is not None and abs(stats_node.inputs[0].default_value - min_val) > 0.0001:\r\n- stats_node.inputs[0].default_value = min_val\r\n- updated_stat = True\r\n- if max_val is not None and abs(stats_node.inputs[1].default_value - max_val) > 0.0001:\r\n- stats_node.inputs[1].default_value = max_val\r\n- updated_stat = True\r\n- if mean_val is not None and abs(stats_node.inputs[2].default_value - mean_val) > 0.0001:\r\n- stats_node.inputs[2].default_value = mean_val\r\n- updated_stat = True\r\n-\r\n- if updated_stat:\r\n- print(f\" Set stats in '{stats_node_label}': Min={min_val:.4f}, Max={max_val:.4f}, Mean={mean_val:.4f}\")\r\n- # else: print(f\" Info: No 'stats' dictionary found for map type '{map_type_to_stat}' in metadata.\") # Optional\r\n- # else: print(f\" Warn: Stats node '{stats_node_label}' not found in parent group.\") # Optional\r\n- # else: print(f\" Info: Map type '{map_type_to_stat}' not present in metadata for stats application.\") # Optional\r\n-\r\n-\r\n- # --- Child Group Handling ---\r\n- processed_asset_flag = True # Track if any map within the asset was actually processed in this run\r\n- for map_type, type_data in maps_data.items():\r\n- print(f\" Processing Map Type: {map_type}\")\r\n-\r\n- # Find placeholder node in parent\r\n- # Placeholders might be labeled directly with the map type (e.g., \"NRM\", \"COL\")\r\n- holder_nodes = find_nodes_by_label(parent_group, map_type, 'ShaderNodeGroup')\r\n- if not holder_nodes:\r\n- print(f\" !!! WARNING: No placeholder node labeled '{map_type}' found in parent group '{parent_group.name}'. Skipping this map type.\")\r\n- continue\r\n- holder_node = holder_nodes[0] # Assume first is correct\r\n-\r\n- # Determine child group name\r\n- target_child_name = f\"PBRTYPE_{asset_name}_{map_type}\"\r\n- child_group = bpy.data.node_groups.get(target_child_name)\r\n- is_new_child = False\r\n-\r\n- if child_group is None:\r\n- # print(f\" Creating new child group: '{target_child_name}'\") # Verbose\r\n- child_group = template_child.copy()\r\n- if not child_group:\r\n- print(f\" !!! ERROR: Failed to copy child template '{CHILD_TEMPLATE_NAME}'. Skipping map type '{map_type}'.\")\r\n- errors_encountered += 1\r\n- continue\r\n- child_group.name = target_child_name\r\n- child_groups_created += 1\r\n- is_new_child = True\r\n- else:\r\n- # print(f\" Updating existing child group: '{target_child_name}'\") # Verbose\r\n- child_groups_updated += 1\r\n-\r\n- # Assign child group to placeholder if needed\r\n- if holder_node.node_tree != child_group:\r\n- try:\r\n- holder_node.node_tree = child_group\r\n- print(f\" Assigned child group '{child_group.name}' to placeholder '{holder_node.label}'.\")\r\n- except TypeError as e_assign: # Catch potential type errors if placeholder isn't a group node\r\n- print(f\" !!! ERROR: Could not assign child group to placeholder '{holder_node.label}'. Is it a Group Node? Error: {e_assign}\")\r\n- continue # Skip this map type if assignment fails\r\n-\r\n- # Link placeholder output to parent output socket\r\n- try:\r\n- # Find parent's output node\r\n- group_output_node = next((n for n in parent_group.nodes if n.type == 'GROUP_OUTPUT'), None)\r\n- if group_output_node:\r\n- # Get the specific output socket on the placeholder (usually index 0 or named 'Color'/'Value')\r\n- source_socket = holder_node.outputs.get(\"Color\") or holder_node.outputs.get(\"Value\") or holder_node.outputs[0]\r\n- # Get the specific input socket on the parent output node (matching map_type)\r\n- target_socket = group_output_node.inputs.get(map_type)\r\n-\r\n- if source_socket and target_socket:\r\n- # Check if link already exists\r\n- link_exists = any(link.from_socket == source_socket and link.to_socket == target_socket for link in parent_group.links)\r\n- if not link_exists:\r\n- parent_group.links.new(source_socket, target_socket)\r\n- print(f\" Linked '{holder_node.label}' output to parent output socket '{map_type}'.\")\r\n- # else: # Optional warnings\r\n- # if not source_socket: print(f\" Warn: Could not find suitable output socket on placeholder '{holder_node.label}'.\")\r\n- # if not target_socket: print(f\" Warn: Could not find input socket '{map_type}' on parent output node.\")\r\n- # else: print(f\" Warn: Parent group '{parent_group.name}' has no Group Output node.\")\r\n-\r\n- except Exception as e_link:\r\n- print(f\" !!! ERROR linking sockets for '{map_type}': {e_link}\")\r\n-\r\n- # Ensure parent output socket type is Color (if it exists)\r\n- try:\r\n- # Use the interface API for modern Blender versions\r\n- item = parent_group.interface.items_tree.get(map_type)\r\n- if item and item.item_type == 'SOCKET' and item.in_out == 'OUTPUT':\r\n- # Common useful types: 'NodeSocketColor', 'NodeSocketVector', 'NodeSocketFloat'\r\n- # Defaulting to Color seems reasonable for most PBR outputs\r\n- if item.socket_type != 'NodeSocketColor':\r\n- item.socket_type = 'NodeSocketColor'\r\n- # print(f\" Set parent output socket '{map_type}' type to Color.\") # Optional info\r\n- except Exception as e_sock_type:\r\n- print(f\" Warn: Could not verify/set socket type for '{map_type}': {e_sock_type}\")\r\n-\r\n-\r\n- # --- Image Node Handling (Inside Child Group) ---\r\n- # 'type_data' should be the dictionary containing resolutions and paths for this map_type\r\n- if not isinstance(type_data, dict):\r\n- print(f\" !!! ERROR: Invalid format for map type '{map_type}' data in metadata. Skipping.\")\r\n- continue\r\n-\r\n- for resolution, map_info in type_data.items():\r\n- # 'map_info' should be the dictionary containing 'path', 'stats', etc. for this resolution\r\n- if not isinstance(map_info, dict) or \"path\" not in map_info:\r\n- print(f\" !!! WARNING: Invalid or missing path data for {map_type}/{resolution}. Skipping.\")\r\n- continue\r\n-\r\n- image_path_str = map_info[\"path\"]\r\n-\r\n- # --- Manifest Check (Map/Resolution Level) ---\r\n- if ENABLE_MANIFEST and is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- # print(f\" Skipping {resolution} (Manifest)\") # Verbose\r\n- maps_skipped_manifest += 1\r\n- continue\r\n-\r\n- print(f\" Processing Resolution: {resolution}\")\r\n-\r\n- # Find image texture node within the CHILD group (labeled by resolution, e.g., \"4K\")\r\n- image_nodes = find_nodes_by_label(child_group, resolution, 'ShaderNodeTexImage')\r\n- if not image_nodes:\r\n- print(f\" !!! WARNING: No Image Texture node labeled '{resolution}' found in child group '{child_group.name}'. Cannot assign image.\")\r\n- continue # Skip this resolution if node not found\r\n-\r\n- # --- Load Image ---\r\n- img = None\r\n- image_load_failed = False\r\n- try:\r\n- image_path = Path(image_path_str)\r\n- if not image_path.is_file():\r\n- print(f\" !!! ERROR: Image file not found: {image_path_str}\")\r\n- image_load_failed = True\r\n- else:\r\n- # Use check_existing=True to reuse existing datablocks if path matches\r\n- img = bpy.data.images.load(str(image_path), check_existing=True)\r\n- if not img:\r\n- print(f\" !!! ERROR: Failed loading image via bpy.data.images.load: {image_path_str}\")\r\n- image_load_failed = True\r\n- else:\r\n- images_loaded += 1 # Count successful loads\r\n- except RuntimeError as e_runtime_load:\r\n- # Catch specific Blender runtime errors (e.g., unsupported format)\r\n- print(f\" !!! ERROR loading image '{image_path_str}': {e_runtime_load}\")\r\n- image_load_failed = True\r\n- except Exception as e_gen_load:\r\n- print(f\" !!! UNEXPECTED ERROR loading image '{image_path_str}': {e_gen_load}\")\r\n- image_load_failed = True\r\n- errors_encountered += 1\r\n-\r\n- # --- Assign Image & Set Color Space ---\r\n- if not image_load_failed and img:\r\n- assigned_count_this_res = 0\r\n- for image_node in image_nodes:\r\n- if image_node.image != img:\r\n- image_node.image = img\r\n- assigned_count_this_res += 1\r\n-\r\n- if assigned_count_this_res > 0:\r\n- images_assigned += assigned_count_this_res\r\n- print(f\" Assigned image '{img.name}' to {assigned_count_this_res} node(s).\")\r\n-\r\n- # Set Color Space\r\n- correct_color_space = get_color_space(map_type)\r\n- try:\r\n- if img.colorspace_settings.name != correct_color_space:\r\n- img.colorspace_settings.name = correct_color_space\r\n- print(f\" Set '{img.name}' color space -> {correct_color_space}\")\r\n- except TypeError as e_cs: # Handle case where colorspace name is invalid\r\n- print(f\" !!! WARNING: Could not set color space '{correct_color_space}' for image '{img.name}'. Is the color space available in Blender? Error: {e_cs}\")\r\n- except Exception as e_cs_gen:\r\n- print(f\" !!! ERROR setting color space for image '{img.name}': {e_cs_gen}\")\r\n-\r\n-\r\n- # --- Update Manifest (Map/Resolution Level) ---\r\n- if update_manifest(manifest_data, asset_name, map_type, resolution):\r\n- manifest_needs_saving = True\r\n- # print(f\" Marked {map_type}/{resolution} processed in manifest.\") # Verbose\r\n- maps_processed += 1\r\n- processed_asset_flag = False # Mark that something was processed for this asset\r\n-\r\n- else:\r\n- errors_encountered += 1\r\n- processed_asset_flag = False # Still counts as an attempt for this asset\r\n-\r\n- # --- End Resolution Loop ---\r\n- # --- End Map Type Loop ---\r\n-\r\n- # --- Update Manifest (Asset Level - if fully processed) ---\r\n- # This logic might be redundant if we always check map level, but can be added\r\n- # if needed to mark the whole asset as 'touched' or 'completed'.\r\n- # For now, we rely on map-level updates.\r\n- # if not processed_asset_flag: # If any map was processed (or attempted)\r\n- # update_manifest(manifest_data, asset_name) # Mark asset as processed\r\n- # manifest_needs_saving = True\r\n-\r\n- assets_processed += 1\r\n-\r\n- except FileNotFoundError:\r\n- print(f\" !!! ERROR: Metadata file not found (should not happen if scan worked): {metadata_path}\")\r\n- errors_encountered += 1\r\n- except json.JSONDecodeError:\r\n- print(f\" !!! ERROR: Invalid JSON in metadata file: {metadata_path}\")\r\n- errors_encountered += 1\r\n- except Exception as e_main_loop:\r\n- print(f\" !!! UNEXPECTED ERROR processing asset from {metadata_path}: {e_main_loop}\")\r\n- import traceback\r\n- traceback.print_exc() # Print detailed traceback for debugging\r\n- errors_encountered += 1\r\n- # Continue to the next asset\r\n-\r\n- # --- End Metadata File Loop ---\r\n-\r\n- # --- Final Manifest Save ---\r\n- if ENABLE_MANIFEST and manifest_needs_saving:\r\n- print(\"\\nAttempting final manifest save...\")\r\n- save_manifest(context, manifest_data)\r\n- elif ENABLE_MANIFEST:\r\n- print(\"\\nManifest is enabled, but no changes require saving.\")\r\n- # --- End Final Manifest Save ---\r\n-\r\n- # --- Final Summary ---\r\n- end_time = time.time()\r\n- duration = end_time - start_time\r\n- print(\"\\n--- Script Run Finished ---\")\r\n- print(f\"Duration: {duration:.2f} seconds\")\r\n- print(f\"Metadata Files Found: {metadata_files_found}\")\r\n- print(f\"Assets Processed/Attempted: {assets_processed}\")\r\n- if ENABLE_MANIFEST:\r\n- print(f\"Assets Skipped (Manifest): {assets_skipped_manifest}\")\r\n- print(f\"Maps Skipped (Manifest): {maps_skipped_manifest}\")\r\n- print(f\"Parent Groups Created: {parent_groups_created}\")\r\n- print(f\"Parent Groups Updated: {parent_groups_updated}\")\r\n- print(f\"Child Groups Created: {child_groups_created}\")\r\n- print(f\"Child Groups Updated: {child_groups_updated}\")\r\n- print(f\"Images Loaded: {images_loaded}\")\r\n- print(f\"Image Nodes Assigned: {images_assigned}\")\r\n- print(f\"Individual Maps Processed: {maps_processed}\")\r\n- if errors_encountered > 0:\r\n- print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n- print(\"---------------------------\")\r\n-\r\n- return True\r\n-\r\n-\r\n-# --- Execution Block ---\r\n-\r\n-if __name__ == \"__main__\":\r\n- # Ensure we are running within Blender\r\n- try:\r\n- import bpy\r\n- except ImportError:\r\n- print(\"!!! ERROR: This script must be run from within Blender. !!!\")\r\n- else:\r\n process_library(bpy.context)\n\\ No newline at end of file\n" }, { "date": 1745229942459, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -0,0 +1,685 @@\n+# Blender Script: Create/Update Node Groups from Asset Processor Output\r\n+# Version: 1.0\r\n+# Description: Scans a library processed by the Asset Processor Tool,\r\n+# reads metadata.json files, and creates/updates corresponding\r\n+# PBR node groups in the active Blender file.\r\n+\r\n+import bpy\r\n+import os\r\n+import json\r\n+from pathlib import Path\r\n+import time\r\n+import re # For parsing aspect ratio string\r\n+\r\n+# --- USER CONFIGURATION ---\r\n+\r\n+# Path to the root output directory of the Asset Processor Tool\r\n+# Example: r\"G:\\Assets\\Processed\"\r\n+PROCESSED_ASSET_LIBRARY_ROOT = r\"G:\\02 Content\\10-19 Content\\13 Textures Power of Two\\13.00\" # <<< CHANGE THIS PATH!\r\n+\r\n+# Names of the required node group templates in the Blender file\r\n+PARENT_TEMPLATE_NAME = \"Template_PBRSET\"\r\n+CHILD_TEMPLATE_NAME = \"Template_PBRTYPE\"\r\n+\r\n+# Labels of specific nodes within the PARENT template\r\n+ASPECT_RATIO_NODE_LABEL = \"AspectRatioCorrection\" # Value node for UV X-scaling factor\r\n+STATS_NODE_PREFIX = \"Histogram-\" # Prefix for Combine XYZ nodes storing stats (e.g., \"Histogram-ROUGH\")\r\n+\r\n+# Enable/disable the manifest system to track processed assets/maps\r\n+# If enabled, requires the blend file to be saved.\r\n+ENABLE_MANIFEST = True\r\n+\r\n+# Map PBR type strings (from metadata) to Blender color spaces\r\n+# Add more mappings as needed based on your metadata types\r\n+PBR_COLOR_SPACE_MAP = {\r\n+ \"AO\": \"Non-Color\", # Usually Non-Color, but depends on workflow\r\n+ \"COL\": \"sRGB\",\r\n+ \"DISP\": \"Non-Color\",\r\n+ \"NRM\": \"Non-Color\",\r\n+ \"REFL\": \"Non-Color\", # Reflection/Specular\r\n+ \"ROUGH\": \"Non-Color\",\r\n+ \"METAL\": \"Non-Color\",\r\n+ \"OPC\": \"Non-Color\", # Opacity/Alpha\r\n+ \"TRN\": \"Non-Color\", # Transmission\r\n+ \"SSS\": \"sRGB\", # Subsurface Color\r\n+ \"EMISS\": \"sRGB\", # Emission Color\r\n+ # Add other types like GLOSS, HEIGHT, etc. if needed\r\n+}\r\n+DEFAULT_COLOR_SPACE = \"sRGB\" # Fallback if map type not in the dictionary\r\n+\r\n+# Map types for which stats should be applied (if found in metadata and node exists)\r\n+APPLY_STATS_FOR_MAP_TYPES = [\"ROUGH\", \"DISP\", \"METAL\"] # Add others if needed\r\n+\r\n+# --- END USER CONFIGURATION ---\r\n+\r\n+\r\n+# --- Helper Functions ---\r\n+\r\n+def find_nodes_by_label(node_tree, label, node_type=None):\r\n+ \"\"\"Finds ALL nodes in a node tree matching the label and optionally type.\"\"\"\r\n+ if not node_tree:\r\n+ return []\r\n+ matching_nodes = []\r\n+ for node in node_tree.nodes:\r\n+ # Use node.label for labeled nodes, node.name for non-labeled (like Group Input/Output)\r\n+ node_identifier = node.label if node.label else node.name\r\n+ if node_identifier and node_identifier == label:\r\n+ if node_type is None or node.bl_idname == node_type or node.type == node_type: # Check bl_idname and type for flexibility\r\n+ matching_nodes.append(node)\r\n+ return matching_nodes\r\n+\r\n+def add_tag_if_new(asset_data, tag_name):\r\n+ \"\"\"Adds a tag to the asset data if it's not None/empty and doesn't already exist.\"\"\"\r\n+ if not asset_data or not tag_name or not isinstance(tag_name, str):\r\n+ return False\r\n+ cleaned_tag_name = tag_name.strip()\r\n+ if not cleaned_tag_name:\r\n+ return False\r\n+\r\n+ # Check if tag already exists (case-insensitive check might be better sometimes)\r\n+ if cleaned_tag_name not in [t.name for t in asset_data.tags]:\r\n+ try:\r\n+ asset_data.tags.new(cleaned_tag_name)\r\n+ print(f\" + Added Asset Tag: '{cleaned_tag_name}'\")\r\n+ return True\r\n+ except Exception as e:\r\n+ print(f\" Error adding tag '{cleaned_tag_name}': {e}\")\r\n+ return False\r\n+ return False # Tag already existed\r\n+\r\n+def get_color_space(map_type):\r\n+ \"\"\"Returns the appropriate Blender color space name for a given map type string.\"\"\"\r\n+ return PBR_COLOR_SPACE_MAP.get(map_type.upper(), DEFAULT_COLOR_SPACE)\r\n+\r\n+def calculate_factor_from_string(aspect_string):\r\n+ \"\"\"\r\n+ Parses the aspect_ratio_change_string from metadata and returns the\r\n+ appropriate UV X-scaling factor needed to correct distortion.\r\n+ Assumes the string format documented in Asset Processor Tool readme.md:\r\n+ \"EVEN\", \"Xnnn\", \"Ynnn\", \"XnnnYnnn\".\r\n+ Returns 1.0 if the string is invalid or \"EVEN\".\r\n+ \"\"\"\r\n+ if not aspect_string or aspect_string.upper() == \"EVEN\":\r\n+ return 1.0\r\n+\r\n+ x_factor = 1.0\r\n+ y_factor = 1.0\r\n+\r\n+ # Regex to find X and Y scaling parts\r\n+ match_x = re.search(r\"X(\\d+)\", aspect_string, re.IGNORECASE)\r\n+ match_y = re.search(r\"Y(\\d+)\", aspect_string, re.IGNORECASE)\r\n+\r\n+ try:\r\n+ if match_x:\r\n+ amount_x = int(match_x.group(1))\r\n+ if amount_x > 0:\r\n+ x_factor = amount_x / 100.0\r\n+ else:\r\n+ print(f\" Warn: Invalid X amount in aspect string '{aspect_string}'. Using 1.0.\")\r\n+\r\n+ if match_y:\r\n+ amount_y = int(match_y.group(1))\r\n+ if amount_y > 0:\r\n+ y_factor = amount_y / 100.0\r\n+ else:\r\n+ print(f\" Warn: Invalid Y amount in aspect string '{aspect_string}'. Using 1.0.\")\r\n+\r\n+ # The correction factor for the U (X) coordinate is Y/X\r\n+ # If X was scaled by 1.5 (X150), U needs to be divided by 1.5 (multiplied by 1/1.5)\r\n+ # If Y was scaled by 1.5 (Y150), U needs to be multiplied by 1.5\r\n+ if x_factor == 0: # Avoid division by zero\r\n+ print(f\" Warn: X factor is zero in aspect string '{aspect_string}'. Cannot calculate correction. Returning 1.0.\")\r\n+ return 1.0\r\n+\r\n+ correction_factor = y_factor / x_factor\r\n+ return correction_factor\r\n+\r\n+ except ValueError:\r\n+ print(f\" Warn: Invalid number in aspect string '{aspect_string}'. Returning 1.0.\")\r\n+ return 1.0\r\n+ except Exception as e:\r\n+ print(f\" Error parsing aspect string '{aspect_string}': {e}. Returning 1.0.\")\r\n+ return 1.0\r\n+\r\n+\r\n+# --- Manifest Functions ---\r\n+\r\n+def get_manifest_path(context):\r\n+ \"\"\"Gets the expected path for the manifest JSON file.\"\"\"\r\n+ if not context or not context.blend_data or not context.blend_data.filepath:\r\n+ return None # Cannot determine path if blend file is not saved\r\n+ blend_path = Path(context.blend_data.filepath)\r\n+ manifest_filename = f\"{blend_path.stem}_manifest.json\"\r\n+ return blend_path.parent / manifest_filename\r\n+\r\n+def load_manifest(context):\r\n+ \"\"\"Loads the manifest data from the JSON file.\"\"\"\r\n+ if not ENABLE_MANIFEST:\r\n+ return {} # Manifest disabled\r\n+\r\n+ manifest_path = get_manifest_path(context)\r\n+ if not manifest_path:\r\n+ print(\" Manifest Info: Blend file not saved. Cannot load manifest.\")\r\n+ return {} # Cannot load without a path\r\n+\r\n+ if not manifest_path.exists():\r\n+ print(f\" Manifest Info: No manifest file found at '{manifest_path.name}'. Starting fresh.\")\r\n+ return {} # No manifest file exists yet\r\n+\r\n+ try:\r\n+ with open(manifest_path, 'r', encoding='utf-8') as f:\r\n+ data = json.load(f)\r\n+ print(f\" Manifest Loaded from: {manifest_path.name}\")\r\n+ # Basic validation (check if it's a dictionary)\r\n+ if not isinstance(data, dict):\r\n+ print(f\"!!! WARNING: Manifest file '{manifest_path.name}' has invalid format (not a dictionary). Starting fresh. !!!\")\r\n+ return {}\r\n+ return data\r\n+ except json.JSONDecodeError:\r\n+ print(f\"!!! WARNING: Manifest file '{manifest_path.name}' is corrupted. Starting fresh. !!!\")\r\n+ return {}\r\n+ except Exception as e:\r\n+ print(f\"!!! ERROR: Could not load manifest file '{manifest_path.name}': {e} !!!\")\r\n+ return {} # Treat as starting fresh on error\r\n+\r\n+def save_manifest(context, manifest_data):\r\n+ \"\"\"Saves the manifest data to the JSON file.\"\"\"\r\n+ if not ENABLE_MANIFEST or not manifest_data: # Don't save if disabled or empty\r\n+ return False\r\n+\r\n+ manifest_path = get_manifest_path(context)\r\n+ if not manifest_path:\r\n+ print(\" Manifest Error: Blend file not saved. Cannot save manifest.\")\r\n+ return False\r\n+\r\n+ try:\r\n+ with open(manifest_path, 'w', encoding='utf-8') as f:\r\n+ json.dump(manifest_data, f, indent=2, sort_keys=True) # Use indent and sort for readability\r\n+ print(f\" Manifest Saved to: {manifest_path.name}\")\r\n+ return True\r\n+ except Exception as e:\r\n+ print(f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\\n\"\r\n+ f\"!!! Manifest save FAILED to '{manifest_path.name}': {e} !!!\\n\"\r\n+ f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\")\r\n+ return False\r\n+\r\n+def is_asset_processed(manifest_data, asset_name):\r\n+ \"\"\"Checks if the entire asset (all its maps/resolutions) is marked as processed.\"\"\"\r\n+ if not ENABLE_MANIFEST: return False\r\n+ # For now, we process map-by-map. An asset is considered processed\r\n+ # if its entry exists, but we rely on map checks.\r\n+ # This could be enhanced later if needed.\r\n+ return asset_name in manifest_data\r\n+\r\n+def is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n+ \"\"\"Checks if a specific map type and resolution for an asset is processed.\"\"\"\r\n+ if not ENABLE_MANIFEST: return False\r\n+ return resolution in manifest_data.get(asset_name, {}).get(map_type, [])\r\n+\r\n+def update_manifest(manifest_data, asset_name, map_type=None, resolution=None):\r\n+ \"\"\"Updates the manifest dictionary in memory.\"\"\"\r\n+ if not ENABLE_MANIFEST: return False\r\n+\r\n+ # Ensure asset entry exists\r\n+ if asset_name not in manifest_data:\r\n+ manifest_data[asset_name] = {}\r\n+\r\n+ # If map_type and resolution are provided, update the specific map entry\r\n+ if map_type and resolution:\r\n+ if map_type not in manifest_data[asset_name]:\r\n+ manifest_data[asset_name][map_type] = []\r\n+\r\n+ if resolution not in manifest_data[asset_name][map_type]:\r\n+ manifest_data[asset_name][map_type].append(resolution)\r\n+ manifest_data[asset_name][map_type].sort() # Keep sorted\r\n+ return True # Indicate that a change was made\r\n+ return False # No change made to this specific map/res\r\n+\r\n+\r\n+# --- Core Logic ---\r\n+\r\n+def process_library(context):\r\n+ global ENABLE_MANIFEST # Declare intent to modify global if needed\r\n+ \"\"\"Scans the library, reads metadata, creates/updates node groups.\"\"\"\r\n+ start_time = time.time()\r\n+ print(f\"\\n--- Starting Node Group Processing ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---\")\r\n+\r\n+ # --- Pre-run Checks ---\r\n+ print(\"Performing pre-run checks...\")\r\n+ valid_setup = True\r\n+ # 1. Check Library Root Path\r\n+ root_path = Path(PROCESSED_ASSET_LIBRARY_ROOT)\r\n+ if not root_path.is_dir():\r\n+ print(f\"!!! ERROR: Processed asset library root directory not found or not a directory:\")\r\n+ print(f\"!!! '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n+ valid_setup = False\r\n+ else:\r\n+ print(f\" Asset Library Root: '{root_path}'\")\r\n+\r\n+ # 2. Check Templates\r\n+ template_parent = bpy.data.node_groups.get(PARENT_TEMPLATE_NAME)\r\n+ template_child = bpy.data.node_groups.get(CHILD_TEMPLATE_NAME)\r\n+ if not template_parent:\r\n+ print(f\"!!! ERROR: Parent template node group '{PARENT_TEMPLATE_NAME}' not found in this Blender file.\")\r\n+ valid_setup = False\r\n+ if not template_child:\r\n+ print(f\"!!! ERROR: Child template node group '{CHILD_TEMPLATE_NAME}' not found in this Blender file.\")\r\n+ valid_setup = False\r\n+ if template_parent and template_child:\r\n+ print(f\" Found Templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\")\r\n+\r\n+ # 3. Check Blend File Saved (if manifest enabled)\r\n+ if ENABLE_MANIFEST and not context.blend_data.filepath:\r\n+ print(f\"!!! WARNING: Manifest is enabled, but the current Blender file is not saved.\")\r\n+ print(f\"!!! Manifest cannot be loaded or saved. Processing will continue without manifest checks.\")\r\n+ ENABLE_MANIFEST = False # Disable manifest for this run\r\n+\r\n+ if not valid_setup:\r\n+ print(\"\\n--- Script aborted due to configuration errors. Please fix the issues above. ---\")\r\n+ return False\r\n+ print(\"Pre-run checks passed.\")\r\n+ # --- End Pre-run Checks ---\r\n+\r\n+ manifest_data = load_manifest(context)\r\n+ manifest_needs_saving = False\r\n+\r\n+ # --- Initialize Counters ---\r\n+ metadata_files_found = 0\r\n+ assets_processed = 0\r\n+ assets_skipped_manifest = 0\r\n+ parent_groups_created = 0\r\n+ parent_groups_updated = 0\r\n+ child_groups_created = 0\r\n+ child_groups_updated = 0\r\n+ images_loaded = 0\r\n+ images_assigned = 0\r\n+ maps_processed = 0\r\n+ maps_skipped_manifest = 0\r\n+ errors_encountered = 0\r\n+ # --- End Counters ---\r\n+\r\n+ print(f\"\\nScanning for metadata files in '{root_path}'...\")\r\n+\r\n+ # --- Scan for metadata.json ---\r\n+ # Using rglob to find all metadata.json files recursively\r\n+ metadata_paths = list(root_path.rglob('metadata.json'))\r\n+ metadata_files_found = len(metadata_paths)\r\n+ print(f\"Found {metadata_files_found} metadata.json files.\")\r\n+\r\n+ if metadata_files_found == 0:\r\n+ print(\"No metadata files found. Nothing to process.\")\r\n+ print(\"--- Script Finished ---\")\r\n+ return True # No work needed is considered success\r\n+\r\n+ # --- Process Each Metadata File ---\r\n+ for metadata_path in metadata_paths:\r\n+ print(f\"\\n--- Processing Metadata: {metadata_path.relative_to(root_path)} ---\")\r\n+ try:\r\n+ with open(metadata_path, 'r', encoding='utf-8') as f:\r\n+ metadata = json.load(f)\r\n+\r\n+ # --- Extract Key Info ---\r\n+ asset_name = metadata.get(\"asset_name\")\r\n+ supplier_name = metadata.get(\"supplier_name\")\r\n+ archetype = metadata.get(\"archetype\")\r\n+:start_line:325\r\n+-------\r\n+ # --- Extract Key Info ---\r\n+ asset_name = metadata.get(\"asset_name\")\r\n+ supplier_name = metadata.get(\"supplier_name\")\r\n+ archetype = metadata.get(\"archetype\")\r\n+ # maps_data = metadata.get(\"maps\") # Old structure\r\n+ aspect_string = metadata.get(\"aspect_ratio_change_string\")\r\n+ processed_map_resolutions = metadata.get(\"processed_map_resolutions\", {})\r\n+ map_details = metadata.get(\"map_details\", {})\r\n+ image_stats_1k = metadata.get(\"image_stats_1k\", {}) # Get 1k stats here\r\n+\r\n+ # Check for essential data\r\n+ if not asset_name or not processed_map_resolutions:\r\n+ print(f\" !!! ERROR: Metadata file is missing 'asset_name' or 'processed_map_resolutions' data. Skipping.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+\r\n+ print(f\" Asset Name: {asset_name}\")\r\n+\r\n+ # --- Manifest Check (Asset Level) ---\r\n+ # We primarily check maps, but can skip the whole asset if needed later\r\n+ if ENABLE_MANIFEST and is_asset_processed(manifest_data, asset_name):\r\n+ # Check if ALL maps/resolutions are actually done, otherwise proceed\r\n+ all_maps_done = True\r\n+ for map_type, resolutions_list in processed_map_resolutions.items():\r\n+ if not isinstance(resolutions_list, list): continue # Skip invalid entries\r\n+ for resolution in resolutions_list:\r\n+ if not is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n+ all_maps_done = False\r\n+ break\r\n+ if not all_maps_done:\r\n+ break\r\n+ if all_maps_done:\r\n+ print(f\" Skipping asset '{asset_name}' (already fully processed according to manifest).\")\r\n+ assets_skipped_manifest += 1\r\n+ continue # Skip to next metadata file\r\n+\r\n+ # --- Parent Group Handling ---\r\n+ target_parent_name = f\"PBRSET_{asset_name}\"\r\n+ parent_group = bpy.data.node_groups.get(target_parent_name)\r\n+ is_new_parent = False\r\n+\r\n+ if parent_group is None:\r\n+ print(f\" Creating new parent group: '{target_parent_name}'\")\r\n+ parent_group = template_parent.copy()\r\n+ if not parent_group:\r\n+ print(f\" !!! ERROR: Failed to copy parent template '{PARENT_TEMPLATE_NAME}'. Skipping asset '{asset_name}'.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ parent_group.name = target_parent_name\r\n+ parent_groups_created += 1\r\n+ is_new_parent = True\r\n+ else:\r\n+ print(f\" Updating existing parent group: '{target_parent_name}'\")\r\n+ parent_groups_updated += 1\r\n+\r\n+ # Ensure marked as asset\r\n+ if not parent_group.asset_data:\r\n+ try:\r\n+ parent_group.asset_mark()\r\n+ print(f\" Marked '{parent_group.name}' as asset.\")\r\n+ except Exception as e_mark:\r\n+ print(f\" !!! ERROR: Failed to mark group '{parent_group.name}' as asset: {e_mark}\")\r\n+ # Continue processing other parts if possible\r\n+\r\n+ # Apply Asset Tags\r\n+ if parent_group.asset_data:\r\n+ if supplier_name:\r\n+ add_tag_if_new(parent_group.asset_data, supplier_name)\r\n+ if archetype:\r\n+ add_tag_if_new(parent_group.asset_data, archetype)\r\n+ # Add other tags if needed\r\n+ # else: print(f\" Warn: Cannot apply tags, asset_data not available for '{parent_group.name}'.\") # Optional warning\r\n+\r\n+ # Apply Aspect Ratio Correction\r\n+ aspect_nodes = find_nodes_by_label(parent_group, ASPECT_RATIO_NODE_LABEL, 'ShaderNodeValue')\r\n+ if aspect_nodes:\r\n+ aspect_node = aspect_nodes[0]\r\n+ correction_factor = calculate_factor_from_string(aspect_string)\r\n+ # Check if update is needed (avoids unnecessary console spam)\r\n+ if abs(aspect_node.outputs[0].default_value - correction_factor) > 0.0001:\r\n+ aspect_node.outputs[0].default_value = correction_factor\r\n+ print(f\" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f} (from string '{aspect_string}')\")\r\n+ # else: print(f\" Warn: Aspect ratio node '{ASPECT_RATIO_NODE_LABEL}' not found in parent group.\") # Optional\r\n+\r\n+\r\n+ # --- Child Group Handling ---\r\n+ processed_asset_flag = True # Track if any map within the asset was actually processed in this run\r\n+ # Iterate through map types and their resolutions from processed_map_resolutions\r\n+ for map_type, resolutions_list in processed_map_resolutions.items():\r\n+ print(f\" Processing Map Type: {map_type}\")\r\n+\r\n+ # Apply Stats (moved here to be inside the map_type loop)\r\n+ if map_type in APPLY_STATS_FOR_MAP_TYPES:\r\n+ # Find the stats node in the parent group\r\n+ stats_node_label = f\"{STATS_NODE_PREFIX}{map_type}\"\r\n+ stats_nodes = find_nodes_by_label(parent_group, stats_node_label, 'ShaderNodeCombineXYZ')\r\n+ if stats_nodes:\r\n+ stats_node = stats_nodes[0]\r\n+ # Find the stats data in the metadata (from image_stats_1k)\r\n+ stats = image_stats_1k.get(map_type) # Expecting {\"min\": float, \"max\": float, \"mean\": float}\r\n+ if stats and isinstance(stats, dict):\r\n+ min_val = stats.get(\"min\")\r\n+ max_val = stats.get(\"max\")\r\n+ mean_val = stats.get(\"mean\") # Often stored as 'mean' or 'avg'\r\n+\r\n+ updated_stat = False\r\n+ if min_val is not None and abs(stats_node.inputs[0].default_value - min_val) > 0.0001:\r\n+ stats_node.inputs[0].default_value = min_val\r\n+ updated_stat = True\r\n+ if max_val is not None and abs(stats_node.inputs[1].default_value - max_val) > 0.0001:\r\n+ stats_node.inputs[1].default_value = max_val\r\n+ updated_stat = True\r\n+ if mean_val is not None and abs(stats_node.inputs[2].default_value - mean_val) > 0.0001:\r\n+ stats_node.inputs[2].default_value = mean_val\r\n+ updated_stat = True\r\n+\r\n+ if updated_stat:\r\n+ print(f\" Set stats in '{stats_node_label}': Min={min_val:.4f}, Max={max_val:.4f}, Mean={mean_val:.4f}\")\r\n+ # else: print(f\" Info: No 'stats' dictionary found for map type '{map_type}' in image_stats_1k.\") # Optional\r\n+ # else: print(f\" Warn: Stats node '{stats_node_label}' not found in parent group.\") # Optional\r\n+ # else: print(f\" Info: Map type '{map_type}' not in APPLY_STATS_FOR_MAP_TYPES.\") # Optional\r\n+\r\n+\r\n+ # Find placeholder node in parent\r\n+ # Placeholders might be labeled directly with the map type (e.g., \"NRM\", \"COL\")\r\n+ holder_nodes = find_nodes_by_label(parent_group, map_type, 'ShaderNodeGroup')\r\n+ if not holder_nodes:\r\n+ print(f\" !!! WARNING: No placeholder node labeled '{map_type}' found in parent group '{parent_group.name}'. Skipping this map type.\")\r\n+ continue\r\n+ holder_node = holder_nodes[0] # Assume first is correct\r\n+\r\n+ # Determine child group name\r\n+ target_child_name = f\"PBRTYPE_{asset_name}_{map_type}\"\r\n+ child_group = bpy.data.node_groups.get(target_child_name)\r\n+ is_new_child = False\r\n+\r\n+ if child_group is None:\r\n+ # print(f\" Creating new child group: '{target_child_name}'\") # Verbose\r\n+ child_group = template_child.copy()\r\n+ if not child_group:\r\n+ print(f\" !!! ERROR: Failed to copy child template '{CHILD_TEMPLATE_NAME}'. Skipping map type '{map_type}'.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ child_group.name = target_child_name\r\n+ child_groups_created += 1\r\n+ is_new_child = True\r\n+ else:\r\n+ # print(f\" Updating existing child group: '{target_child_name}'\") # Verbose\r\n+ child_groups_updated += 1\r\n+\r\n+ # Assign child group to placeholder if needed\r\n+ if holder_node.node_tree != child_group:\r\n+ try:\r\n+ holder_node.node_tree = child_group\r\n+ print(f\" Assigned child group '{child_group.name}' to placeholder '{holder_node.label}'.\")\r\n+ except TypeError as e_assign: # Catch potential type errors if placeholder isn't a group node\r\n+ print(f\" !!! ERROR: Could not assign child group to placeholder '{holder_node.label}'. Is it a Group Node? Error: {e_assign}\")\r\n+ continue # Skip this map type if assignment fails\r\n+\r\n+ # Link placeholder output to parent output socket\r\n+ try:\r\n+ # Find parent's output node\r\n+ group_output_node = next((n for n in parent_group.nodes if n.type == 'GROUP_OUTPUT'), None)\r\n+ if group_output_node:\r\n+ # Get the specific output socket on the placeholder (usually index 0 or named 'Color'/'Value')\r\n+ source_socket = holder_node.outputs.get(\"Color\") or holder_node.outputs.get(\"Value\") or holder_node.outputs[0]\r\n+ # Get the specific input socket on the parent output node (matching map_type)\r\n+ target_socket = group_output_node.inputs.get(map_type)\r\n+\r\n+ if source_socket and target_socket:\r\n+ # Check if link already exists\r\n+ link_exists = any(link.from_socket == source_socket and link.to_socket == target_socket for link in parent_group.links)\r\n+ if not link_exists:\r\n+ parent_group.links.new(source_socket, target_socket)\r\n+ print(f\" Linked '{holder_node.label}' output to parent output socket '{map_type}'.\")\r\n+ # else: # Optional warnings\r\n+ # if not source_socket: print(f\" Warn: Could not find suitable output socket on placeholder '{holder_node.label}'.\")\r\n+ # if not target_socket: print(f\" Warn: Could not find input socket '{map_type}' on parent output node.\")\r\n+ # else: print(f\" Warn: Parent group '{parent_group.name}' has no Group Output node.\")\r\n+\r\n+ except Exception as e_link:\r\n+ print(f\" !!! ERROR linking sockets for '{map_type}': {e_link}\")\r\n+\r\n+ # Ensure parent output socket type is Color (if it exists)\r\n+ try:\r\n+ # Use the interface API for modern Blender versions\r\n+ item = parent_group.interface.items_tree.get(map_type)\r\n+ if item and item.item_type == 'SOCKET' and item.in_out == 'OUTPUT':\r\n+ # Common useful types: 'NodeSocketColor', 'NodeSocketVector', 'NodeSocketFloat'\r\n+ # Defaulting to Color seems reasonable for most PBR outputs\r\n+ if item.socket_type != 'NodeSocketColor':\r\n+ item.socket_type = 'NodeSocketColor'\r\n+ # print(f\" Set parent output socket '{map_type}' type to Color.\") # Optional info\r\n+ except Exception as e_sock_type:\r\n+ print(f\" Warn: Could not verify/set socket type for '{map_type}': {e_sock_type}\")\r\n+\r\n+\r\n+ # --- Image Node Handling (Inside Child Group) ---\r\n+ # Iterate through resolutions listed for this map type\r\n+ if not isinstance(resolutions_list, list):\r\n+ print(f\" !!! ERROR: Invalid format for map type '{map_type}' resolutions list in metadata. Skipping.\")\r\n+ continue\r\n+\r\n+ # Get the output format for this map type from map_details\r\n+ map_output_format = map_details.get(map_type, {}).get(\"output_format\", \"png\") # Default to png if not found\r\n+\r\n+ for resolution in resolutions_list:\r\n+ # --- Manifest Check (Map/Resolution Level) ---\r\n+ if ENABLE_MANIFEST and is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n+ # print(f\" Skipping {resolution} (Manifest)\") # Verbose\r\n+ maps_skipped_manifest += 1\r\n+ continue\r\n+\r\n+ print(f\" Processing Resolution: {resolution}\")\r\n+\r\n+ # Find image texture node within the CHILD group (labeled by resolution, e.g., \"4K\")\r\n+ image_nodes = find_nodes_by_label(child_group, resolution, 'ShaderNodeTexImage')\r\n+ if not image_nodes:\r\n+ print(f\" !!! WARNING: No Image Texture node labeled '{resolution}' found in child group '{child_group.name}'. Cannot assign image.\")\r\n+ continue # Skip this resolution if node not found\r\n+\r\n+ # --- Reconstruct Image Path ---\r\n+ # Expected path: ///__.\r\n+ # Need to handle potential case sensitivity or variations in naming\r\n+ # Let's assume the standard naming convention from the readme for now.\r\n+ image_filename = f\"{asset_name}_{map_type}_{resolution}.{map_output_format}\"\r\n+ image_path_str = str(root_path / supplier_name / asset_name / image_filename)\r\n+ print(f\" Reconstructed Image Path: {image_path_str}\") # Add log to verify path\r\n+\r\n+ # --- Load Image ---\r\n+ img = None\r\n+ image_load_failed = False\r\n+ try:\r\n+ image_path = Path(image_path_str)\r\n+ if not image_path.is_file():\r\n+ print(f\" !!! ERROR: Image file not found at reconstructed path: {image_path_str}\")\r\n+ image_load_failed = True\r\n+ else:\r\n+ # Use check_existing=True to reuse existing datablocks if path matches\r\n+ img = bpy.data.images.load(str(image_path), check_existing=True)\r\n+ if not img:\r\n+ print(f\" !!! ERROR: Failed loading image via bpy.data.images.load: {image_path_str}\")\r\n+ image_load_failed = True\r\n+ else:\r\n+ images_loaded += 1 # Count successful loads\r\n+ except RuntimeError as e_runtime_load:\r\n+ # Catch specific Blender runtime errors (e.g., unsupported format)\r\n+ print(f\" !!! ERROR loading image '{image_path_str}': {e_runtime_load}\")\r\n+ image_load_failed = True\r\n+ except Exception as e_gen_load:\r\n+ print(f\" !!! UNEXPECTED ERROR loading image '{image_path_str}': {e_gen_load}\")\r\n+ image_load_failed = True\r\n+ errors_encountered += 1\r\n+\r\n+ # --- Assign Image & Set Color Space ---\r\n+ if not image_load_failed and img:\r\n+ assigned_count_this_res = 0\r\n+ for image_node in image_nodes:\r\n+ if image_node.image != img:\r\n+ image_node.image = img\r\n+ assigned_count_this_res += 1\r\n+\r\n+ if assigned_count_this_res > 0:\r\n+ images_assigned += assigned_count_this_res\r\n+ print(f\" Assigned image '{img.name}' to {assigned_count_this_res} node(s).\")\r\n+\r\n+ # Set Color Space\r\n+ correct_color_space = get_color_space(map_type)\r\n+ try:\r\n+ if img.colorspace_settings.name != correct_color_space:\r\n+ img.colorspace_settings.name = correct_color_space\r\n+ print(f\" Set '{img.name}' color space -> {correct_color_space}\")\r\n+ except TypeError as e_cs: # Handle case where colorspace name is invalid\r\n+ print(f\" !!! WARNING: Could not set color space '{correct_color_space}' for image '{img.name}'. Is the color space available in Blender? Error: {e_cs}\")\r\n+ except Exception as e_cs_gen:\r\n+ print(f\" !!! ERROR setting color space for image '{img.name}': {e_cs_gen}\")\r\n+\r\n+\r\n+ # --- Update Manifest (Map/Resolution Level) ---\r\n+ if update_manifest(manifest_data, asset_name, map_type, resolution):\r\n+ manifest_needs_saving = True\r\n+ # print(f\" Marked {map_type}/{resolution} processed in manifest.\") # Verbose\r\n+ maps_processed += 1\r\n+ processed_asset_flag = False # Mark that something was processed for this asset\r\n+\r\n+ else:\r\n+ errors_encountered += 1\r\n+ processed_asset_flag = False # Still counts as an attempt for this asset\r\n+\r\n+ # --- End Resolution Loop ---\r\n+ # --- End Map Type Loop ---\r\n+\r\n+ # --- Update Manifest (Asset Level - if fully processed) ---\r\n+ # This logic might be redundant if we always check map level, but can be added\r\n+ # if needed to mark the whole asset as 'touched' or 'completed'.\r\n+ # For now, we rely on map-level updates.\r\n+ # if not processed_asset_flag: # If any map was processed (or attempted)\r\n+ # update_manifest(manifest_data, asset_name) # Mark asset as processed\r\n+ # manifest_needs_saving = True\r\n+\r\n+ assets_processed += 1\r\n+\r\n+ except FileNotFoundError:\r\n+ print(f\" !!! ERROR: Metadata file not found (should not happen if scan worked): {metadata_path}\")\r\n+ errors_encountered += 1\r\n+ except json.JSONDecodeError:\r\n+ print(f\" !!! ERROR: Invalid JSON in metadata file: {metadata_path}\")\r\n+ errors_encountered += 1\r\n+ except Exception as e_main_loop:\r\n+ print(f\" !!! UNEXPECTED ERROR processing asset from {metadata_path}: {e_main_loop}\")\r\n+ import traceback\r\n+ traceback.print_exc() # Print detailed traceback for debugging\r\n+ errors_encountered += 1\r\n+ # Continue to the next asset\r\n+\r\n+ # --- End Metadata File Loop ---\r\n+\r\n+ # --- Final Manifest Save ---\r\n+ if ENABLE_MANIFEST and manifest_needs_saving:\r\n+ print(\"\\nAttempting final manifest save...\")\r\n+ save_manifest(context, manifest_data)\r\n+ elif ENABLE_MANIFEST:\r\n+ print(\"\\nManifest is enabled, but no changes require saving.\")\r\n+ # --- End Final Manifest Save ---\r\n+\r\n+ # --- Final Summary ---\r\n+ end_time = time.time()\r\n+ duration = end_time - start_time\r\n+ print(\"\\n--- Script Run Finished ---\")\r\n+ print(f\"Duration: {duration:.2f} seconds\")\r\n+ print(f\"Metadata Files Found: {metadata_files_found}\")\r\n+ print(f\"Assets Processed/Attempted: {assets_processed}\")\r\n+ if ENABLE_MANIFEST:\r\n+ print(f\"Assets Skipped (Manifest): {assets_skipped_manifest}\")\r\n+ print(f\"Maps Skipped (Manifest): {maps_skipped_manifest}\")\r\n+ print(f\"Parent Groups Created: {parent_groups_created}\")\r\n+ print(f\"Parent Groups Updated: {parent_groups_updated}\")\r\n+ print(f\"Child Groups Created: {child_groups_created}\")\r\n+ print(f\"Child Groups Updated: {child_groups_updated}\")\r\n+ print(f\"Images Loaded: {images_loaded}\")\r\n+ print(f\"Image Nodes Assigned: {images_assigned}\")\r\n+ print(f\"Individual Maps Processed: {maps_processed}\")\r\n+ if errors_encountered > 0:\r\n+ print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n+ print(\"---------------------------\")\r\n+\r\n+ return True\r\n+\r\n+\r\n+# --- Execution Block ---\r\n+\r\n+if __name__ == \"__main__\":\r\n+ # Ensure we are running within Blender\r\n+ try:\r\n+ import bpy\r\n+ except ImportError:\r\n+ print(\"!!! ERROR: This script must be run from within Blender. !!!\")\r\n+ else:\r\n+ process_library(bpy.context)\n\\ No newline at end of file\n" }, { "date": 1745230160934, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -321,693 +321,8 @@\n # --- Extract Key Info ---\r\n asset_name = metadata.get(\"asset_name\")\r\n supplier_name = metadata.get(\"supplier_name\")\r\n archetype = metadata.get(\"archetype\")\r\n-:start_line:325\r\n--------\r\n- # --- Extract Key Info ---\r\n- asset_name = metadata.get(\"asset_name\")\r\n- supplier_name = metadata.get(\"supplier_name\")\r\n- archetype = metadata.get(\"archetype\")\r\n- # maps_data = metadata.get(\"maps\") # Old structure\r\n- aspect_string = metadata.get(\"aspect_ratio_change_string\")\r\n- processed_map_resolutions = metadata.get(\"processed_map_resolutions\", {})\r\n- map_details = metadata.get(\"map_details\", {})\r\n- image_stats_1k = metadata.get(\"image_stats_1k\", {}) # Get 1k stats here\r\n-\r\n- # Check for essential data\r\n- if not asset_name or not processed_map_resolutions:\r\n- print(f\" !!! ERROR: Metadata file is missing 'asset_name' or 'processed_map_resolutions' data. Skipping.\")\r\n- errors_encountered += 1\r\n- continue\r\n-\r\n- print(f\" Asset Name: {asset_name}\")\r\n-\r\n- # --- Manifest Check (Asset Level) ---\r\n- # We primarily check maps, but can skip the whole asset if needed later\r\n- if ENABLE_MANIFEST and is_asset_processed(manifest_data, asset_name):\r\n- # Check if ALL maps/resolutions are actually done, otherwise proceed\r\n- all_maps_done = True\r\n- for map_type, resolutions_list in processed_map_resolutions.items():\r\n- if not isinstance(resolutions_list, list): continue # Skip invalid entries\r\n- for resolution in resolutions_list:\r\n- if not is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- all_maps_done = False\r\n- break\r\n- if not all_maps_done:\r\n- break\r\n- if all_maps_done:\r\n- print(f\" Skipping asset '{asset_name}' (already fully processed according to manifest).\")\r\n- assets_skipped_manifest += 1\r\n- continue # Skip to next metadata file\r\n-\r\n- # --- Parent Group Handling ---\r\n- target_parent_name = f\"PBRSET_{asset_name}\"\r\n- parent_group = bpy.data.node_groups.get(target_parent_name)\r\n- is_new_parent = False\r\n-\r\n- if parent_group is None:\r\n- print(f\" Creating new parent group: '{target_parent_name}'\")\r\n- parent_group = template_parent.copy()\r\n- if not parent_group:\r\n- print(f\" !!! ERROR: Failed to copy parent template '{PARENT_TEMPLATE_NAME}'. Skipping asset '{asset_name}'.\")\r\n- errors_encountered += 1\r\n- continue\r\n- parent_group.name = target_parent_name\r\n- parent_groups_created += 1\r\n- is_new_parent = True\r\n- else:\r\n- print(f\" Updating existing parent group: '{target_parent_name}'\")\r\n- parent_groups_updated += 1\r\n-\r\n- # Ensure marked as asset\r\n- if not parent_group.asset_data:\r\n- try:\r\n- parent_group.asset_mark()\r\n- print(f\" Marked '{parent_group.name}' as asset.\")\r\n- except Exception as e_mark:\r\n- print(f\" !!! ERROR: Failed to mark group '{parent_group.name}' as asset: {e_mark}\")\r\n- # Continue processing other parts if possible\r\n-\r\n- # Apply Asset Tags\r\n- if parent_group.asset_data:\r\n- if supplier_name:\r\n- add_tag_if_new(parent_group.asset_data, supplier_name)\r\n- if archetype:\r\n- add_tag_if_new(parent_group.asset_data, archetype)\r\n- # Add other tags if needed\r\n- # else: print(f\" Warn: Cannot apply tags, asset_data not available for '{parent_group.name}'.\") # Optional warning\r\n-\r\n- # Apply Aspect Ratio Correction\r\n- aspect_nodes = find_nodes_by_label(parent_group, ASPECT_RATIO_NODE_LABEL, 'ShaderNodeValue')\r\n- if aspect_nodes:\r\n- aspect_node = aspect_nodes[0]\r\n- correction_factor = calculate_factor_from_string(aspect_string)\r\n- # Check if update is needed (avoids unnecessary console spam)\r\n- if abs(aspect_node.outputs[0].default_value - correction_factor) > 0.0001:\r\n- aspect_node.outputs[0].default_value = correction_factor\r\n- print(f\" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f} (from string '{aspect_string}')\")\r\n- # else: print(f\" Warn: Aspect ratio node '{ASPECT_RATIO_NODE_LABEL}' not found in parent group.\") # Optional\r\n-\r\n-\r\n- # --- Child Group Handling ---\r\n- processed_asset_flag = True # Track if any map within the asset was actually processed in this run\r\n- # Iterate through map types and their resolutions from processed_map_resolutions\r\n- for map_type, resolutions_list in processed_map_resolutions.items():\r\n- print(f\" Processing Map Type: {map_type}\")\r\n-\r\n- # Apply Stats (moved here to be inside the map_type loop)\r\n- if map_type in APPLY_STATS_FOR_MAP_TYPES:\r\n- # Find the stats node in the parent group\r\n- stats_node_label = f\"{STATS_NODE_PREFIX}{map_type}\"\r\n- stats_nodes = find_nodes_by_label(parent_group, stats_node_label, 'ShaderNodeCombineXYZ')\r\n- if stats_nodes:\r\n- stats_node = stats_nodes[0]\r\n- # Find the stats data in the metadata (from image_stats_1k)\r\n- stats = image_stats_1k.get(map_type) # Expecting {\"min\": float, \"max\": float, \"mean\": float}\r\n- if stats and isinstance(stats, dict):\r\n- min_val = stats.get(\"min\")\r\n- max_val = stats.get(\"max\")\r\n- mean_val = stats.get(\"mean\") # Often stored as 'mean' or 'avg'\r\n-\r\n- updated_stat = False\r\n- if min_val is not None and abs(stats_node.inputs[0].default_value - min_val) > 0.0001:\r\n- stats_node.inputs[0].default_value = min_val\r\n- updated_stat = True\r\n- if max_val is not None and abs(stats_node.inputs[1].default_value - max_val) > 0.0001:\r\n- stats_node.inputs[1].default_value = max_val\r\n- updated_stat = True\r\n- if mean_val is not None and abs(stats_node.inputs[2].default_value - mean_val) > 0.0001:\r\n- stats_node.inputs[2].default_value = mean_val\r\n- updated_stat = True\r\n-\r\n- if updated_stat:\r\n- print(f\" Set stats in '{stats_node_label}': Min={min_val:.4f}, Max={max_val:.4f}, Mean={mean_val:.4f}\")\r\n- # else: print(f\" Info: No 'stats' dictionary found for map type '{map_type}' in image_stats_1k.\") # Optional\r\n- # else: print(f\" Warn: Stats node '{stats_node_label}' not found in parent group.\") # Optional\r\n- # else: print(f\" Info: Map type '{map_type}' not in APPLY_STATS_FOR_MAP_TYPES.\") # Optional\r\n-\r\n-\r\n- # Find placeholder node in parent\r\n- # Placeholders might be labeled directly with the map type (e.g., \"NRM\", \"COL\")\r\n- holder_nodes = find_nodes_by_label(parent_group, map_type, 'ShaderNodeGroup')\r\n- if not holder_nodes:\r\n- print(f\" !!! WARNING: No placeholder node labeled '{map_type}' found in parent group '{parent_group.name}'. Skipping this map type.\")\r\n- continue\r\n- holder_node = holder_nodes[0] # Assume first is correct\r\n-\r\n- # Determine child group name\r\n- target_child_name = f\"PBRTYPE_{asset_name}_{map_type}\"\r\n- child_group = bpy.data.node_groups.get(target_child_name)\r\n- is_new_child = False\r\n-\r\n- if child_group is None:\r\n- # print(f\" Creating new child group: '{target_child_name}'\") # Verbose\r\n- child_group = template_child.copy()\r\n- if not child_group:\r\n- print(f\" !!! ERROR: Failed to copy child template '{CHILD_TEMPLATE_NAME}'. Skipping map type '{map_type}'.\")\r\n- errors_encountered += 1\r\n- continue\r\n- child_group.name = target_child_name\r\n- child_groups_created += 1\r\n- is_new_child = True\r\n- else:\r\n- # print(f\" Updating existing child group: '{target_child_name}'\") # Verbose\r\n- child_groups_updated += 1\r\n-\r\n- # Assign child group to placeholder if needed\r\n- if holder_node.node_tree != child_group:\r\n- try:\r\n- holder_node.node_tree = child_group\r\n- print(f\" Assigned child group '{child_group.name}' to placeholder '{holder_node.label}'.\")\r\n- except TypeError as e_assign: # Catch potential type errors if placeholder isn't a group node\r\n- print(f\" !!! ERROR: Could not assign child group to placeholder '{holder_node.label}'. Is it a Group Node? Error: {e_assign}\")\r\n- continue # Skip this map type if assignment fails\r\n-\r\n- # Link placeholder output to parent output socket\r\n- try:\r\n- # Find parent's output node\r\n- group_output_node = next((n for n in parent_group.nodes if n.type == 'GROUP_OUTPUT'), None)\r\n- if group_output_node:\r\n- # Get the specific output socket on the placeholder (usually index 0 or named 'Color'/'Value')\r\n- source_socket = holder_node.outputs.get(\"Color\") or holder_node.outputs.get(\"Value\") or holder_node.outputs[0]\r\n- # Get the specific input socket on the parent output node (matching map_type)\r\n- target_socket = group_output_node.inputs.get(map_type)\r\n-\r\n- if source_socket and target_socket:\r\n- # Check if link already exists\r\n- link_exists = any(link.from_socket == source_socket and link.to_socket == target_socket for link in parent_group.links)\r\n- if not link_exists:\r\n- parent_group.links.new(source_socket, target_socket)\r\n- print(f\" Linked '{holder_node.label}' output to parent output socket '{map_type}'.\")\r\n- # else: # Optional warnings\r\n- # if not source_socket: print(f\" Warn: Could not find suitable output socket on placeholder '{holder_node.label}'.\")\r\n- # if not target_socket: print(f\" Warn: Could not find input socket '{map_type}' on parent output node.\")\r\n- # else: print(f\" Warn: Parent group '{parent_group.name}' has no Group Output node.\")\r\n-\r\n- except Exception as e_link:\r\n- print(f\" !!! ERROR linking sockets for '{map_type}': {e_link}\")\r\n-\r\n- # Ensure parent output socket type is Color (if it exists)\r\n- try:\r\n- # Use the interface API for modern Blender versions\r\n- item = parent_group.interface.items_tree.get(map_type)\r\n- if item and item.item_type == 'SOCKET' and item.in_out == 'OUTPUT':\r\n- # Common useful types: 'NodeSocketColor', 'NodeSocketVector', 'NodeSocketFloat'\r\n- # Defaulting to Color seems reasonable for most PBR outputs\r\n- if item.socket_type != 'NodeSocketColor':\r\n- item.socket_type = 'NodeSocketColor'\r\n- # print(f\" Set parent output socket '{map_type}' type to Color.\") # Optional info\r\n- except Exception as e_sock_type:\r\n- print(f\" Warn: Could not verify/set socket type for '{map_type}': {e_sock_type}\")\r\n-\r\n-\r\n- # --- Image Node Handling (Inside Child Group) ---\r\n- # Iterate through resolutions listed for this map type\r\n- if not isinstance(resolutions_list, list):\r\n- print(f\" !!! ERROR: Invalid format for map type '{map_type}' resolutions list in metadata. Skipping.\")\r\n- continue\r\n-\r\n- # Get the output format for this map type from map_details\r\n- map_output_format = map_details.get(map_type, {}).get(\"output_format\", \"png\") # Default to png if not found\r\n-\r\n- for resolution in resolutions_list:\r\n- # --- Manifest Check (Map/Resolution Level) ---\r\n- if ENABLE_MANIFEST and is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- # print(f\" Skipping {resolution} (Manifest)\") # Verbose\r\n- maps_skipped_manifest += 1\r\n- continue\r\n-\r\n- print(f\" Processing Resolution: {resolution}\")\r\n-\r\n- # Find image texture node within the CHILD group (labeled by resolution, e.g., \"4K\")\r\n- image_nodes = find_nodes_by_label(child_group, resolution, 'ShaderNodeTexImage')\r\n- if not image_nodes:\r\n- print(f\" !!! WARNING: No Image Texture node labeled '{resolution}' found in child group '{child_group.name}'. Cannot assign image.\")\r\n- continue # Skip this resolution if node not found\r\n-\r\n- # --- Reconstruct Image Path ---\r\n- # Expected path: ///__.\r\n- # Need to handle potential case sensitivity or variations in naming\r\n- # Let's assume the standard naming convention from the readme for now.\r\n- image_filename = f\"{asset_name}_{map_type}_{resolution}.{map_output_format}\"\r\n- image_path_str = str(root_path / supplier_name / asset_name / image_filename)\r\n- print(f\" Reconstructed Image Path: {image_path_str}\") # Add log to verify path\r\n-\r\n- # --- Load Image ---\r\n- img = None\r\n- image_load_failed = False\r\n- try:\r\n- image_path = Path(image_path_str)\r\n- if not image_path.is_file():\r\n- print(f\" !!! ERROR: Image file not found at reconstructed path: {image_path_str}\")\r\n- image_load_failed = True\r\n- else:\r\n- # Use check_existing=True to reuse existing datablocks if path matches\r\n- img = bpy.data.images.load(str(image_path), check_existing=True)\r\n- if not img:\r\n- print(f\" !!! ERROR: Failed loading image via bpy.data.images.load: {image_path_str}\")\r\n- image_load_failed = True\r\n- else:\r\n- images_loaded += 1 # Count successful loads\r\n- except RuntimeError as e_runtime_load:\r\n- # Catch specific Blender runtime errors (e.g., unsupported format)\r\n- print(f\" !!! ERROR loading image '{image_path_str}': {e_runtime_load}\")\r\n- image_load_failed = True\r\n- except Exception as e_gen_load:\r\n- print(f\" !!! UNEXPECTED ERROR loading image '{image_path_str}': {e_gen_load}\")\r\n- image_load_failed = True\r\n- errors_encountered += 1\r\n-\r\n- # --- Assign Image & Set Color Space ---\r\n- if not image_load_failed and img:\r\n- assigned_count_this_res = 0\r\n- for image_node in image_nodes:\r\n- if image_node.image != img:\r\n- image_node.image = img\r\n- assigned_count_this_res += 1\r\n-\r\n- if assigned_count_this_res > 0:\r\n- images_assigned += assigned_count_this_res\r\n- print(f\" Assigned image '{img.name}' to {assigned_count_this_res} node(s).\")\r\n-\r\n- # Set Color Space\r\n- correct_color_space = get_color_space(map_type)\r\n- try:\r\n- if img.colorspace_settings.name != correct_color_space:\r\n- img.colorspace_settings.name = correct_color_space\r\n- print(f\" Set '{img.name}' color space -> {correct_color_space}\")\r\n- except TypeError as e_cs: # Handle case where colorspace name is invalid\r\n- print(f\" !!! WARNING: Could not set color space '{correct_color_space}' for image '{img.name}'. Is the color space available in Blender? Error: {e_cs}\")\r\n- except Exception as e_cs_gen:\r\n- print(f\" !!! ERROR setting color space for image '{img.name}': {e_cs_gen}\")\r\n-\r\n-\r\n- # --- Update Manifest (Map/Resolution Level) ---\r\n- if update_manifest(manifest_data, asset_name, map_type, resolution):\r\n- manifest_needs_saving = True\r\n- # print(f\" Marked {map_type}/{resolution} processed in manifest.\") # Verbose\r\n- maps_processed += 1\r\n- processed_asset_flag = False # Mark that something was processed for this asset\r\n-\r\n- else:\r\n- errors_encountered += 1\r\n- processed_asset_flag = False # Still counts as an attempt for this asset\r\n-\r\n- # --- End Resolution Loop ---\r\n- # --- End Map Type Loop ---\r\n-\r\n- # --- Update Manifest (Asset Level - if fully processed) ---\r\n- # This logic might be redundant if we always check map level, but can be added\r\n- # if needed to mark the whole asset as 'touched' or 'completed'.\r\n- # For now, we rely on map-level updates.\r\n- # if not processed_asset_flag: # If any map was processed (or attempted)\r\n- # update_manifest(manifest_data, asset_name) # Mark asset as processed\r\n- # manifest_needs_saving = True\r\n-\r\n- assets_processed += 1\r\n-\r\n- except FileNotFoundError:\r\n- print(f\" !!! ERROR: Metadata file not found (should not happen if scan worked): {metadata_path}\")\r\n- errors_encountered += 1\r\n- except json.JSONDecodeError:\r\n- print(f\" !!! ERROR: Invalid JSON in metadata file: {metadata_path}\")\r\n- errors_encountered += 1\r\n- except Exception as e_main_loop:\r\n- print(f\" !!! UNEXPECTED ERROR processing asset from {metadata_path}: {e_main_loop}\")\r\n- import traceback\r\n- traceback.print_exc() # Print detailed traceback for debugging\r\n- errors_encountered += 1\r\n- # Continue to the next asset\r\n-\r\n- # --- End Metadata File Loop ---\r\n-\r\n- # --- Final Manifest Save ---\r\n- if ENABLE_MANIFEST and manifest_needs_saving:\r\n- print(\"\\nAttempting final manifest save...\")\r\n- save_manifest(context, manifest_data)\r\n- elif ENABLE_MANIFEST:\r\n- print(\"\\nManifest is enabled, but no changes require saving.\")\r\n- # --- End Final Manifest Save ---\r\n-\r\n- # --- Final Summary ---\r\n- end_time = time.time()\r\n- duration = end_time - start_time\r\n- print(\"\\n--- Script Run Finished ---\")\r\n- print(f\"Duration: {duration:.2f} seconds\")\r\n- print(f\"Metadata Files Found: {metadata_files_found}\")\r\n- print(f\"Assets Processed/Attempted: {assets_processed}\")\r\n- if ENABLE_MANIFEST:\r\n- print(f\"Assets Skipped (Manifest): {assets_skipped_manifest}\")\r\n- print(f\"Maps Skipped (Manifest): {maps_skipped_manifest}\")\r\n- print(f\"Parent Groups Created: {parent_groups_created}\")\r\n- print(f\"Parent Groups Updated: {parent_groups_updated}\")\r\n- print(f\"Child Groups Created: {child_groups_created}\")\r\n- print(f\"Child Groups Updated: {child_groups_updated}\")\r\n- print(f\"Images Loaded: {images_loaded}\")\r\n- print(f\"Image Nodes Assigned: {images_assigned}\")\r\n- print(f\"Individual Maps Processed: {maps_processed}\")\r\n- if errors_encountered > 0:\r\n- print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n- print(\"---------------------------\")\r\n-\r\n- return True\r\n-\r\n-\r\n-# --- Execution Block ---\r\n-\r\n-if __name__ == \"__main__\":\r\n- # Ensure we are running within Blender\r\n- try:\r\n- import bpy\r\n- except ImportError:\r\n- print(\"!!! ERROR: This script must be run from within Blender. !!!\")\r\n- else:\r\n- process_library(bpy.context)\n-# Blender Script: Create/Update Node Groups from Asset Processor Output\r\n-# Version: 1.0\r\n-# Description: Scans a library processed by the Asset Processor Tool,\r\n-# reads metadata.json files, and creates/updates corresponding\r\n-# PBR node groups in the active Blender file.\r\n-\r\n-import bpy\r\n-import os\r\n-import json\r\n-from pathlib import Path\r\n-import time\r\n-import re # For parsing aspect ratio string\r\n-\r\n-# --- USER CONFIGURATION ---\r\n-\r\n-# Path to the root output directory of the Asset Processor Tool\r\n-# Example: r\"G:\\Assets\\Processed\"\r\n-PROCESSED_ASSET_LIBRARY_ROOT = r\"G:\\02 Content\\10-19 Content\\13 Textures Power of Two\\13.00\" # <<< CHANGE THIS PATH!\r\n-\r\n-# Names of the required node group templates in the Blender file\r\n-PARENT_TEMPLATE_NAME = \"Template_PBRSET\"\r\n-CHILD_TEMPLATE_NAME = \"Template_PBRTYPE\"\r\n-\r\n-# Labels of specific nodes within the PARENT template\r\n-ASPECT_RATIO_NODE_LABEL = \"AspectRatioCorrection\" # Value node for UV X-scaling factor\r\n-STATS_NODE_PREFIX = \"Histogram-\" # Prefix for Combine XYZ nodes storing stats (e.g., \"Histogram-ROUGH\")\r\n-\r\n-# Enable/disable the manifest system to track processed assets/maps\r\n-# If enabled, requires the blend file to be saved.\r\n-ENABLE_MANIFEST = True\r\n-\r\n-# Map PBR type strings (from metadata) to Blender color spaces\r\n-# Add more mappings as needed based on your metadata types\r\n-PBR_COLOR_SPACE_MAP = {\r\n- \"AO\": \"Non-Color\", # Usually Non-Color, but depends on workflow\r\n- \"COL\": \"sRGB\",\r\n- \"DISP\": \"Non-Color\",\r\n- \"NRM\": \"Non-Color\",\r\n- \"REFL\": \"Non-Color\", # Reflection/Specular\r\n- \"ROUGH\": \"Non-Color\",\r\n- \"METAL\": \"Non-Color\",\r\n- \"OPC\": \"Non-Color\", # Opacity/Alpha\r\n- \"TRN\": \"Non-Color\", # Transmission\r\n- \"SSS\": \"sRGB\", # Subsurface Color\r\n- \"EMISS\": \"sRGB\", # Emission Color\r\n- # Add other types like GLOSS, HEIGHT, etc. if needed\r\n-}\r\n-DEFAULT_COLOR_SPACE = \"sRGB\" # Fallback if map type not in the dictionary\r\n-\r\n-# Map types for which stats should be applied (if found in metadata and node exists)\r\n-APPLY_STATS_FOR_MAP_TYPES = [\"ROUGH\", \"DISP\", \"METAL\"] # Add others if needed\r\n-\r\n-# --- END USER CONFIGURATION ---\r\n-\r\n-\r\n-# --- Helper Functions ---\r\n-\r\n-def find_nodes_by_label(node_tree, label, node_type=None):\r\n- \"\"\"Finds ALL nodes in a node tree matching the label and optionally type.\"\"\"\r\n- if not node_tree:\r\n- return []\r\n- matching_nodes = []\r\n- for node in node_tree.nodes:\r\n- # Use node.label for labeled nodes, node.name for non-labeled (like Group Input/Output)\r\n- node_identifier = node.label if node.label else node.name\r\n- if node_identifier and node_identifier == label:\r\n- if node_type is None or node.bl_idname == node_type or node.type == node_type: # Check bl_idname and type for flexibility\r\n- matching_nodes.append(node)\r\n- return matching_nodes\r\n-\r\n-def add_tag_if_new(asset_data, tag_name):\r\n- \"\"\"Adds a tag to the asset data if it's not None/empty and doesn't already exist.\"\"\"\r\n- if not asset_data or not tag_name or not isinstance(tag_name, str):\r\n- return False\r\n- cleaned_tag_name = tag_name.strip()\r\n- if not cleaned_tag_name:\r\n- return False\r\n-\r\n- # Check if tag already exists (case-insensitive check might be better sometimes)\r\n- if cleaned_tag_name not in [t.name for t in asset_data.tags]:\r\n- try:\r\n- asset_data.tags.new(cleaned_tag_name)\r\n- print(f\" + Added Asset Tag: '{cleaned_tag_name}'\")\r\n- return True\r\n- except Exception as e:\r\n- print(f\" Error adding tag '{cleaned_tag_name}': {e}\")\r\n- return False\r\n- return False # Tag already existed\r\n-\r\n-def get_color_space(map_type):\r\n- \"\"\"Returns the appropriate Blender color space name for a given map type string.\"\"\"\r\n- return PBR_COLOR_SPACE_MAP.get(map_type.upper(), DEFAULT_COLOR_SPACE)\r\n-\r\n-def calculate_factor_from_string(aspect_string):\r\n- \"\"\"\r\n- Parses the aspect_ratio_change_string from metadata and returns the\r\n- appropriate UV X-scaling factor needed to correct distortion.\r\n- Assumes the string format documented in Asset Processor Tool readme.md:\r\n- \"EVEN\", \"Xnnn\", \"Ynnn\", \"XnnnYnnn\".\r\n- Returns 1.0 if the string is invalid or \"EVEN\".\r\n- \"\"\"\r\n- if not aspect_string or aspect_string.upper() == \"EVEN\":\r\n- return 1.0\r\n-\r\n- x_factor = 1.0\r\n- y_factor = 1.0\r\n-\r\n- # Regex to find X and Y scaling parts\r\n- match_x = re.search(r\"X(\\d+)\", aspect_string, re.IGNORECASE)\r\n- match_y = re.search(r\"Y(\\d+)\", aspect_string, re.IGNORECASE)\r\n-\r\n- try:\r\n- if match_x:\r\n- amount_x = int(match_x.group(1))\r\n- if amount_x > 0:\r\n- x_factor = amount_x / 100.0\r\n- else:\r\n- print(f\" Warn: Invalid X amount in aspect string '{aspect_string}'. Using 1.0.\")\r\n-\r\n- if match_y:\r\n- amount_y = int(match_y.group(1))\r\n- if amount_y > 0:\r\n- y_factor = amount_y / 100.0\r\n- else:\r\n- print(f\" Warn: Invalid Y amount in aspect string '{aspect_string}'. Using 1.0.\")\r\n-\r\n- # The correction factor for the U (X) coordinate is Y/X\r\n- # If X was scaled by 1.5 (X150), U needs to be divided by 1.5 (multiplied by 1/1.5)\r\n- # If Y was scaled by 1.5 (Y150), U needs to be multiplied by 1.5\r\n- if x_factor == 0: # Avoid division by zero\r\n- print(f\" Warn: X factor is zero in aspect string '{aspect_string}'. Cannot calculate correction. Returning 1.0.\")\r\n- return 1.0\r\n-\r\n- correction_factor = y_factor / x_factor\r\n- return correction_factor\r\n-\r\n- except ValueError:\r\n- print(f\" Warn: Invalid number in aspect string '{aspect_string}'. Returning 1.0.\")\r\n- return 1.0\r\n- except Exception as e:\r\n- print(f\" Error parsing aspect string '{aspect_string}': {e}. Returning 1.0.\")\r\n- return 1.0\r\n-\r\n-\r\n-# --- Manifest Functions ---\r\n-\r\n-def get_manifest_path(context):\r\n- \"\"\"Gets the expected path for the manifest JSON file.\"\"\"\r\n- if not context or not context.blend_data or not context.blend_data.filepath:\r\n- return None # Cannot determine path if blend file is not saved\r\n- blend_path = Path(context.blend_data.filepath)\r\n- manifest_filename = f\"{blend_path.stem}_manifest.json\"\r\n- return blend_path.parent / manifest_filename\r\n-\r\n-def load_manifest(context):\r\n- \"\"\"Loads the manifest data from the JSON file.\"\"\"\r\n- if not ENABLE_MANIFEST:\r\n- return {} # Manifest disabled\r\n-\r\n- manifest_path = get_manifest_path(context)\r\n- if not manifest_path:\r\n- print(\" Manifest Info: Blend file not saved. Cannot load manifest.\")\r\n- return {} # Cannot load without a path\r\n-\r\n- if not manifest_path.exists():\r\n- print(f\" Manifest Info: No manifest file found at '{manifest_path.name}'. Starting fresh.\")\r\n- return {} # No manifest file exists yet\r\n-\r\n- try:\r\n- with open(manifest_path, 'r', encoding='utf-8') as f:\r\n- data = json.load(f)\r\n- print(f\" Manifest Loaded from: {manifest_path.name}\")\r\n- # Basic validation (check if it's a dictionary)\r\n- if not isinstance(data, dict):\r\n- print(f\"!!! WARNING: Manifest file '{manifest_path.name}' has invalid format (not a dictionary). Starting fresh. !!!\")\r\n- return {}\r\n- return data\r\n- except json.JSONDecodeError:\r\n- print(f\"!!! WARNING: Manifest file '{manifest_path.name}' is corrupted. Starting fresh. !!!\")\r\n- return {}\r\n- except Exception as e:\r\n- print(f\"!!! ERROR: Could not load manifest file '{manifest_path.name}': {e} !!!\")\r\n- return {} # Treat as starting fresh on error\r\n-\r\n-def save_manifest(context, manifest_data):\r\n- \"\"\"Saves the manifest data to the JSON file.\"\"\"\r\n- if not ENABLE_MANIFEST or not manifest_data: # Don't save if disabled or empty\r\n- return False\r\n-\r\n- manifest_path = get_manifest_path(context)\r\n- if not manifest_path:\r\n- print(\" Manifest Error: Blend file not saved. Cannot save manifest.\")\r\n- return False\r\n-\r\n- try:\r\n- with open(manifest_path, 'w', encoding='utf-8') as f:\r\n- json.dump(manifest_data, f, indent=2, sort_keys=True) # Use indent and sort for readability\r\n- print(f\" Manifest Saved to: {manifest_path.name}\")\r\n- return True\r\n- except Exception as e:\r\n- print(f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\\n\"\r\n- f\"!!! Manifest save FAILED to '{manifest_path.name}': {e} !!!\\n\"\r\n- f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\")\r\n- return False\r\n-\r\n-def is_asset_processed(manifest_data, asset_name):\r\n- \"\"\"Checks if the entire asset (all its maps/resolutions) is marked as processed.\"\"\"\r\n- if not ENABLE_MANIFEST: return False\r\n- # For now, we process map-by-map. An asset is considered processed\r\n- # if its entry exists, but we rely on map checks.\r\n- # This could be enhanced later if needed.\r\n- return asset_name in manifest_data\r\n-\r\n-def is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- \"\"\"Checks if a specific map type and resolution for an asset is processed.\"\"\"\r\n- if not ENABLE_MANIFEST: return False\r\n- return resolution in manifest_data.get(asset_name, {}).get(map_type, [])\r\n-\r\n-def update_manifest(manifest_data, asset_name, map_type=None, resolution=None):\r\n- \"\"\"Updates the manifest dictionary in memory.\"\"\"\r\n- if not ENABLE_MANIFEST: return False\r\n-\r\n- # Ensure asset entry exists\r\n- if asset_name not in manifest_data:\r\n- manifest_data[asset_name] = {}\r\n-\r\n- # If map_type and resolution are provided, update the specific map entry\r\n- if map_type and resolution:\r\n- if map_type not in manifest_data[asset_name]:\r\n- manifest_data[asset_name][map_type] = []\r\n-\r\n- if resolution not in manifest_data[asset_name][map_type]:\r\n- manifest_data[asset_name][map_type].append(resolution)\r\n- manifest_data[asset_name][map_type].sort() # Keep sorted\r\n- return True # Indicate that a change was made\r\n- return False # No change made to this specific map/res\r\n-\r\n-\r\n-# --- Core Logic ---\r\n-\r\n-def process_library(context):\r\n- global ENABLE_MANIFEST # Declare intent to modify global if needed\r\n- \"\"\"Scans the library, reads metadata, creates/updates node groups.\"\"\"\r\n- start_time = time.time()\r\n- print(f\"\\n--- Starting Node Group Processing ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---\")\r\n-\r\n- # --- Pre-run Checks ---\r\n- print(\"Performing pre-run checks...\")\r\n- valid_setup = True\r\n- # 1. Check Library Root Path\r\n- root_path = Path(PROCESSED_ASSET_LIBRARY_ROOT)\r\n- if not root_path.is_dir():\r\n- print(f\"!!! ERROR: Processed asset library root directory not found or not a directory:\")\r\n- print(f\"!!! '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n- valid_setup = False\r\n- else:\r\n- print(f\" Asset Library Root: '{root_path}'\")\r\n-\r\n- # 2. Check Templates\r\n- template_parent = bpy.data.node_groups.get(PARENT_TEMPLATE_NAME)\r\n- template_child = bpy.data.node_groups.get(CHILD_TEMPLATE_NAME)\r\n- if not template_parent:\r\n- print(f\"!!! ERROR: Parent template node group '{PARENT_TEMPLATE_NAME}' not found in this Blender file.\")\r\n- valid_setup = False\r\n- if not template_child:\r\n- print(f\"!!! ERROR: Child template node group '{CHILD_TEMPLATE_NAME}' not found in this Blender file.\")\r\n- valid_setup = False\r\n- if template_parent and template_child:\r\n- print(f\" Found Templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\")\r\n-\r\n- # 3. Check Blend File Saved (if manifest enabled)\r\n- if ENABLE_MANIFEST and not context.blend_data.filepath:\r\n- print(f\"!!! WARNING: Manifest is enabled, but the current Blender file is not saved.\")\r\n- print(f\"!!! Manifest cannot be loaded or saved. Processing will continue without manifest checks.\")\r\n- ENABLE_MANIFEST = False # Disable manifest for this run\r\n-\r\n- if not valid_setup:\r\n- print(\"\\n--- Script aborted due to configuration errors. Please fix the issues above. ---\")\r\n- return False\r\n- print(\"Pre-run checks passed.\")\r\n- # --- End Pre-run Checks ---\r\n-\r\n- manifest_data = load_manifest(context)\r\n- manifest_needs_saving = False\r\n-\r\n- # --- Initialize Counters ---\r\n- metadata_files_found = 0\r\n- assets_processed = 0\r\n- assets_skipped_manifest = 0\r\n- parent_groups_created = 0\r\n- parent_groups_updated = 0\r\n- child_groups_created = 0\r\n- child_groups_updated = 0\r\n- images_loaded = 0\r\n- images_assigned = 0\r\n- maps_processed = 0\r\n- maps_skipped_manifest = 0\r\n- errors_encountered = 0\r\n- # --- End Counters ---\r\n-\r\n- print(f\"\\nScanning for metadata files in '{root_path}'...\")\r\n-\r\n- # --- Scan for metadata.json ---\r\n- # Using rglob to find all metadata.json files recursively\r\n- metadata_paths = list(root_path.rglob('metadata.json'))\r\n- metadata_files_found = len(metadata_paths)\r\n- print(f\"Found {metadata_files_found} metadata.json files.\")\r\n-\r\n- if metadata_files_found == 0:\r\n- print(\"No metadata files found. Nothing to process.\")\r\n- print(\"--- Script Finished ---\")\r\n- return True # No work needed is considered success\r\n-\r\n- # --- Process Each Metadata File ---\r\n- for metadata_path in metadata_paths:\r\n- print(f\"\\n--- Processing Metadata: {metadata_path.relative_to(root_path)} ---\")\r\n- try:\r\n- with open(metadata_path, 'r', encoding='utf-8') as f:\r\n- metadata = json.load(f)\r\n-\r\n- # --- Extract Key Info ---\r\n- asset_name = metadata.get(\"asset_name\")\r\n- supplier_name = metadata.get(\"supplier_name\")\r\n- archetype = metadata.get(\"archetype\")\r\n maps_data = metadata.get(\"maps\")\r\n aspect_string = metadata.get(\"aspect_ratio_change_string\")\r\n \r\n if not asset_name or not maps_data:\r\n" }, { "date": 1745230243556, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -1,9 +1,12 @@\n # Blender Script: Create/Update Node Groups from Asset Processor Output\r\n-# Version: 1.0\r\n+# Version: 1.1\r\n # Description: Scans a library processed by the Asset Processor Tool,\r\n # reads metadata.json files, and creates/updates corresponding\r\n # PBR node groups in the active Blender file.\r\n+# Changes v1.1:\r\n+# - Updated metadata parsing to match actual structure (using processed_map_resolutions, image_stats_1k, map_details).\r\n+# - Added logic to reconstruct image file paths based on metadata and assumed naming convention.\r\n \r\n import bpy\r\n import os\r\n import json\r\n@@ -14,9 +17,10 @@\n # --- USER CONFIGURATION ---\r\n \r\n # Path to the root output directory of the Asset Processor Tool\r\n # Example: r\"G:\\Assets\\Processed\"\r\n-PROCESSED_ASSET_LIBRARY_ROOT = r\"G:\\02 Content\\10-19 Content\\13 Textures Power of Two\\13.00\" # <<< CHANGE THIS PATH!\r\n+# IMPORTANT: This should point to the base directory containing supplier folders (e.g., Poliigon)\r\n+PROCESSED_ASSET_LIBRARY_ROOT = r\"G:\\02 Content\\10-19 Content\\13 Textures Power of Two\\Asset_Processor_Output\" # <<< CHANGE THIS PATH!\r\n \r\n # Names of the required node group templates in the Blender file\r\n PARENT_TEMPLATE_NAME = \"Template_PBRSET\"\r\n CHILD_TEMPLATE_NAME = \"Template_PBRTYPE\"\r\n@@ -28,13 +32,20 @@\n # Enable/disable the manifest system to track processed assets/maps\r\n # If enabled, requires the blend file to be saved.\r\n ENABLE_MANIFEST = True\r\n \r\n+# Assumed filename pattern for processed images.\r\n+# {asset_name}, {map_type}, {resolution}, {format} will be replaced.\r\n+# Check Asset Processor Tool's config.py (TARGET_FILENAME_PATTERN) if this is wrong.\r\n+IMAGE_FILENAME_PATTERN = \"{asset_name}_{map_type}_{resolution}.{format}\"\r\n+\r\n # Map PBR type strings (from metadata) to Blender color spaces\r\n # Add more mappings as needed based on your metadata types\r\n PBR_COLOR_SPACE_MAP = {\r\n \"AO\": \"Non-Color\", # Usually Non-Color, but depends on workflow\r\n \"COL\": \"sRGB\",\r\n+ \"COL-1\": \"sRGB\", # Handle variants if present in metadata\r\n+ \"COL-2\": \"sRGB\",\r\n \"DISP\": \"Non-Color\",\r\n \"NRM\": \"Non-Color\",\r\n \"REFL\": \"Non-Color\", # Reflection/Specular\r\n \"ROUGH\": \"Non-Color\",\r\n@@ -47,9 +58,10 @@\n }\r\n DEFAULT_COLOR_SPACE = \"sRGB\" # Fallback if map type not in the dictionary\r\n \r\n # Map types for which stats should be applied (if found in metadata and node exists)\r\n-APPLY_STATS_FOR_MAP_TYPES = [\"ROUGH\", \"DISP\", \"METAL\"] # Add others if needed\r\n+# Reads stats from the 'image_stats_1k' section of metadata.json\r\n+APPLY_STATS_FOR_MAP_TYPES = [\"ROUGH\", \"DISP\", \"METAL\", \"AO\", \"REFL\"] # Add others if needed\r\n \r\n # --- END USER CONFIGURATION ---\r\n \r\n \r\n@@ -88,9 +100,11 @@\n return False # Tag already existed\r\n \r\n def get_color_space(map_type):\r\n \"\"\"Returns the appropriate Blender color space name for a given map type string.\"\"\"\r\n- return PBR_COLOR_SPACE_MAP.get(map_type.upper(), DEFAULT_COLOR_SPACE)\r\n+ # Handle potential numbered variants like COL-1, COL-2\r\n+ base_map_type = map_type.split('-')[0]\r\n+ return PBR_COLOR_SPACE_MAP.get(base_map_type.upper(), DEFAULT_COLOR_SPACE)\r\n \r\n def calculate_factor_from_string(aspect_string):\r\n \"\"\"\r\n Parses the aspect_ratio_change_string from metadata and returns the\r\n@@ -140,9 +154,34 @@\n except Exception as e:\r\n print(f\" Error parsing aspect string '{aspect_string}': {e}. Returning 1.0.\")\r\n return 1.0\r\n \r\n+def reconstruct_image_path(asset_dir_path, asset_name, map_type, resolution, file_format):\r\n+ \"\"\"\r\n+ Constructs the expected image file path based on the provided components\r\n+ and the IMAGE_FILENAME_PATTERN.\r\n+ \"\"\"\r\n+ if not all([asset_dir_path, asset_name, map_type, resolution, file_format]):\r\n+ return None\r\n \r\n+ try:\r\n+ filename = IMAGE_FILENAME_PATTERN.format(\r\n+ asset_name=asset_name,\r\n+ map_type=map_type,\r\n+ resolution=resolution,\r\n+ format=file_format\r\n+ )\r\n+ # The image file should be directly inside the asset directory\r\n+ full_path = asset_dir_path / filename\r\n+ return str(full_path)\r\n+ except KeyError as e:\r\n+ print(f\" !!! ERROR: Missing key '{e}' in IMAGE_FILENAME_PATTERN. Cannot reconstruct path.\")\r\n+ return None\r\n+ except Exception as e:\r\n+ print(f\" !!! ERROR reconstructing image path: {e}\")\r\n+ return None\r\n+\r\n+\r\n # --- Manifest Functions ---\r\n \r\n def get_manifest_path(context):\r\n \"\"\"Gets the expected path for the manifest JSON file.\"\"\"\r\n@@ -205,11 +244,9 @@\n \r\n def is_asset_processed(manifest_data, asset_name):\r\n \"\"\"Checks if the entire asset (all its maps/resolutions) is marked as processed.\"\"\"\r\n if not ENABLE_MANIFEST: return False\r\n- # For now, we process map-by-map. An asset is considered processed\r\n- # if its entry exists, but we rely on map checks.\r\n- # This could be enhanced later if needed.\r\n+ # Basic check if asset entry exists. Detailed check happens at map level.\r\n return asset_name in manifest_data\r\n \r\n def is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n \"\"\"Checks if a specific map type and resolution for an asset is processed.\"\"\"\r\n@@ -300,10 +337,20 @@\n \r\n print(f\"\\nScanning for metadata files in '{root_path}'...\")\r\n \r\n # --- Scan for metadata.json ---\r\n- # Using rglob to find all metadata.json files recursively\r\n- metadata_paths = list(root_path.rglob('metadata.json'))\r\n+ # Scan one level deeper for supplier folders (e.g., Poliigon)\r\n+ # Then scan within each supplier for asset folders containing metadata.json\r\n+ metadata_paths = []\r\n+ for supplier_dir in root_path.iterdir():\r\n+ if supplier_dir.is_dir():\r\n+ # Now look for asset folders inside the supplier directory\r\n+ for asset_dir in supplier_dir.iterdir():\r\n+ if asset_dir.is_dir():\r\n+ metadata_file = asset_dir / 'metadata.json'\r\n+ if metadata_file.is_file():\r\n+ metadata_paths.append(metadata_file)\r\n+\r\n metadata_files_found = len(metadata_paths)\r\n print(f\"Found {metadata_files_found} metadata.json files.\")\r\n \r\n if metadata_files_found == 0:\r\n@@ -312,8 +359,9 @@\n return True # No work needed is considered success\r\n \r\n # --- Process Each Metadata File ---\r\n for metadata_path in metadata_paths:\r\n+ asset_dir_path = metadata_path.parent # Get the directory containing the metadata file\r\n print(f\"\\n--- Processing Metadata: {metadata_path.relative_to(root_path)} ---\")\r\n try:\r\n with open(metadata_path, 'r', encoding='utf-8') as f:\r\n metadata = json.load(f)\r\n@@ -321,31 +369,41 @@\n # --- Extract Key Info ---\r\n asset_name = metadata.get(\"asset_name\")\r\n supplier_name = metadata.get(\"supplier_name\")\r\n archetype = metadata.get(\"archetype\")\r\n- maps_data = metadata.get(\"maps\")\r\n+ # Get map info from the correct keys\r\n+ processed_resolutions = metadata.get(\"processed_map_resolutions\") # Dict: {map_type: [res1, res2]}\r\n+ map_details = metadata.get(\"map_details\") # Dict: {map_type: {details}}\r\n+ image_stats_1k = metadata.get(\"image_stats_1k\") # Dict: {map_type: {stats}}\r\n aspect_string = metadata.get(\"aspect_ratio_change_string\")\r\n \r\n- if not asset_name or not maps_data:\r\n- print(f\" !!! ERROR: Metadata file is missing 'asset_name' or 'maps' data. Skipping.\")\r\n+ # Validate essential data\r\n+ if not asset_name:\r\n+ print(f\" !!! ERROR: Metadata file is missing 'asset_name'. Skipping.\")\r\n errors_encountered += 1\r\n continue\r\n+ if not processed_resolutions or not isinstance(processed_resolutions, dict):\r\n+ print(f\" !!! ERROR: Metadata file is missing or has invalid 'processed_map_resolutions'. Skipping asset '{asset_name}'.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ if not map_details or not isinstance(map_details, dict):\r\n+ print(f\" !!! WARNING: Metadata file is missing or has invalid 'map_details'. Path reconstruction might fail for asset '{asset_name}'.\")\r\n+ # Continue processing, but path reconstruction might fail later\r\n \r\n print(f\" Asset Name: {asset_name}\")\r\n \r\n- # --- Manifest Check (Asset Level) ---\r\n- # We primarily check maps, but can skip the whole asset if needed later\r\n+ # --- Manifest Check (Asset Level - Basic) ---\r\n if ENABLE_MANIFEST and is_asset_processed(manifest_data, asset_name):\r\n- # Check if ALL maps/resolutions are actually done, otherwise proceed\r\n- all_maps_done = True\r\n- for map_type, res_data in maps_data.items():\r\n- for resolution in res_data.keys():\r\n+ # Perform a quick check if *any* map needs processing for this asset\r\n+ needs_processing = False\r\n+ for map_type, resolutions in processed_resolutions.items():\r\n+ for resolution in resolutions:\r\n if not is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- all_maps_done = False\r\n+ needs_processing = True\r\n break\r\n- if not all_maps_done:\r\n+ if needs_processing:\r\n break\r\n- if all_maps_done:\r\n+ if not needs_processing:\r\n print(f\" Skipping asset '{asset_name}' (already fully processed according to manifest).\")\r\n assets_skipped_manifest += 1\r\n continue # Skip to next metadata file\r\n \r\n@@ -396,50 +454,62 @@\n aspect_node.outputs[0].default_value = correction_factor\r\n print(f\" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f} (from string '{aspect_string}')\")\r\n # else: print(f\" Warn: Aspect ratio node '{ASPECT_RATIO_NODE_LABEL}' not found in parent group.\") # Optional\r\n \r\n- # Apply Stats\r\n- for map_type_to_stat in APPLY_STATS_FOR_MAP_TYPES:\r\n- if map_type_to_stat in maps_data:\r\n- # Find the stats node in the parent group\r\n- stats_node_label = f\"{STATS_NODE_PREFIX}{map_type_to_stat}\"\r\n- stats_nodes = find_nodes_by_label(parent_group, stats_node_label, 'ShaderNodeCombineXYZ')\r\n- if stats_nodes:\r\n- stats_node = stats_nodes[0]\r\n- # Find the stats data in the metadata (usually from a specific resolution)\r\n- # Let's assume the stats are stored directly under the map_type entry\r\n- map_metadata = maps_data[map_type_to_stat]\r\n- stats = map_metadata.get(\"stats\") # Expecting {\"min\": float, \"max\": float, \"mean\": float}\r\n- if stats and isinstance(stats, dict):\r\n- min_val = stats.get(\"min\")\r\n- max_val = stats.get(\"max\")\r\n- mean_val = stats.get(\"mean\") # Often stored as 'mean' or 'avg'\r\n+ # Apply Stats (using image_stats_1k)\r\n+ if image_stats_1k and isinstance(image_stats_1k, dict):\r\n+ for map_type_to_stat in APPLY_STATS_FOR_MAP_TYPES:\r\n+ if map_type_to_stat in image_stats_1k:\r\n+ # Find the stats node in the parent group\r\n+ stats_node_label = f\"{STATS_NODE_PREFIX}{map_type_to_stat}\"\r\n+ stats_nodes = find_nodes_by_label(parent_group, stats_node_label, 'ShaderNodeCombineXYZ')\r\n+ if stats_nodes:\r\n+ stats_node = stats_nodes[0]\r\n+ stats = image_stats_1k[map_type_to_stat] # Get stats dict for this map type\r\n \r\n- updated_stat = False\r\n- if min_val is not None and abs(stats_node.inputs[0].default_value - min_val) > 0.0001:\r\n- stats_node.inputs[0].default_value = min_val\r\n- updated_stat = True\r\n- if max_val is not None and abs(stats_node.inputs[1].default_value - max_val) > 0.0001:\r\n- stats_node.inputs[1].default_value = max_val\r\n- updated_stat = True\r\n- if mean_val is not None and abs(stats_node.inputs[2].default_value - mean_val) > 0.0001:\r\n- stats_node.inputs[2].default_value = mean_val\r\n- updated_stat = True\r\n+ if stats and isinstance(stats, dict):\r\n+ # Handle potential list format for RGB stats (use first value) or direct float\r\n+ def get_stat_value(stat_val):\r\n+ if isinstance(stat_val, list):\r\n+ return stat_val[0] if stat_val else None\r\n+ return stat_val\r\n \r\n- if updated_stat:\r\n- print(f\" Set stats in '{stats_node_label}': Min={min_val:.4f}, Max={max_val:.4f}, Mean={mean_val:.4f}\")\r\n- # else: print(f\" Info: No 'stats' dictionary found for map type '{map_type_to_stat}' in metadata.\") # Optional\r\n- # else: print(f\" Warn: Stats node '{stats_node_label}' not found in parent group.\") # Optional\r\n- # else: print(f\" Info: Map type '{map_type_to_stat}' not present in metadata for stats application.\") # Optional\r\n+ min_val = get_stat_value(stats.get(\"min\"))\r\n+ max_val = get_stat_value(stats.get(\"max\"))\r\n+ mean_val = get_stat_value(stats.get(\"mean\")) # Often stored as 'mean' or 'avg'\r\n \r\n+ updated_stat = False\r\n+ if min_val is not None and abs(stats_node.inputs[0].default_value - min_val) > 0.0001:\r\n+ stats_node.inputs[0].default_value = min_val\r\n+ updated_stat = True\r\n+ if max_val is not None and abs(stats_node.inputs[1].default_value - max_val) > 0.0001:\r\n+ stats_node.inputs[1].default_value = max_val\r\n+ updated_stat = True\r\n+ if mean_val is not None and abs(stats_node.inputs[2].default_value - mean_val) > 0.0001:\r\n+ stats_node.inputs[2].default_value = mean_val\r\n+ updated_stat = True\r\n \r\n+ if updated_stat:\r\n+ print(f\" Set stats in '{stats_node_label}': Min={min_val:.4f}, Max={max_val:.4f}, Mean={mean_val:.4f}\")\r\n+ # else: print(f\" Info: No valid 'stats' dictionary found for map type '{map_type_to_stat}' in image_stats_1k.\") # Optional\r\n+ # else: print(f\" Warn: Stats node '{stats_node_label}' not found in parent group.\") # Optional\r\n+ # else: print(f\" Info: Map type '{map_type_to_stat}' not present in image_stats_1k for stats application.\") # Optional\r\n+ # else: print(f\" Warn: 'image_stats_1k' missing or invalid in metadata.\") # Optional\r\n+\r\n+\r\n # --- Child Group Handling ---\r\n- processed_asset_flag = True # Track if any map within the asset was actually processed in this run\r\n- for map_type, type_data in maps_data.items():\r\n+ # Iterate through the map types listed in processed_resolutions\r\n+ for map_type, resolutions in processed_resolutions.items():\r\n print(f\" Processing Map Type: {map_type}\")\r\n \r\n+ # Get details for this map type (needed for format)\r\n+ current_map_details = map_details.get(map_type, {})\r\n+ output_format = current_map_details.get(\"output_format\")\r\n+ if not output_format:\r\n+ print(f\" !!! WARNING: Missing 'output_format' in map_details for '{map_type}'. Cannot reconstruct path. Skipping map type.\")\r\n+ continue\r\n+\r\n # Find placeholder node in parent\r\n- # Placeholders might be labeled directly with the map type (e.g., \"NRM\", \"COL\")\r\n holder_nodes = find_nodes_by_label(parent_group, map_type, 'ShaderNodeGroup')\r\n if not holder_nodes:\r\n print(f\" !!! WARNING: No placeholder node labeled '{map_type}' found in parent group '{parent_group.name}'. Skipping this map type.\")\r\n continue\r\n@@ -511,29 +581,35 @@\n print(f\" Warn: Could not verify/set socket type for '{map_type}': {e_sock_type}\")\r\n \r\n \r\n # --- Image Node Handling (Inside Child Group) ---\r\n- # 'type_data' should be the dictionary containing resolutions and paths for this map_type\r\n- if not isinstance(type_data, dict):\r\n- print(f\" !!! ERROR: Invalid format for map type '{map_type}' data in metadata. Skipping.\")\r\n+ if not isinstance(resolutions, list):\r\n+ print(f\" !!! ERROR: Invalid format for resolutions list for map type '{map_type}'. Skipping.\")\r\n continue\r\n \r\n- for resolution, map_info in type_data.items():\r\n- # 'map_info' should be the dictionary containing 'path', 'stats', etc. for this resolution\r\n- if not isinstance(map_info, dict) or \"path\" not in map_info:\r\n- print(f\" !!! WARNING: Invalid or missing path data for {map_type}/{resolution}. Skipping.\")\r\n- continue\r\n-\r\n- image_path_str = map_info[\"path\"]\r\n-\r\n+ for resolution in resolutions:\r\n # --- Manifest Check (Map/Resolution Level) ---\r\n if ENABLE_MANIFEST and is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n # print(f\" Skipping {resolution} (Manifest)\") # Verbose\r\n maps_skipped_manifest += 1\r\n continue\r\n \r\n print(f\" Processing Resolution: {resolution}\")\r\n \r\n+ # Reconstruct the image path\r\n+ image_path_str = reconstruct_image_path(\r\n+ asset_dir_path=asset_dir_path,\r\n+ asset_name=asset_name,\r\n+ map_type=map_type,\r\n+ resolution=resolution,\r\n+ file_format=output_format\r\n+ )\r\n+\r\n+ if not image_path_str:\r\n+ print(f\" !!! ERROR: Failed to reconstruct image path for {map_type}/{resolution}. Skipping.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+\r\n # Find image texture node within the CHILD group (labeled by resolution, e.g., \"4K\")\r\n image_nodes = find_nodes_by_label(child_group, resolution, 'ShaderNodeTexImage')\r\n if not image_nodes:\r\n print(f\" !!! WARNING: No Image Texture node labeled '{resolution}' found in child group '{child_group.name}'. Cannot assign image.\")\r\n@@ -544,9 +620,9 @@\n image_load_failed = False\r\n try:\r\n image_path = Path(image_path_str)\r\n if not image_path.is_file():\r\n- print(f\" !!! ERROR: Image file not found: {image_path_str}\")\r\n+ print(f\" !!! ERROR: Reconstructed image file not found: {image_path_str}\")\r\n image_load_failed = True\r\n else:\r\n # Use check_existing=True to reuse existing datablocks if path matches\r\n img = bpy.data.images.load(str(image_path), check_existing=True)\r\n@@ -592,25 +668,16 @@\n if update_manifest(manifest_data, asset_name, map_type, resolution):\r\n manifest_needs_saving = True\r\n # print(f\" Marked {map_type}/{resolution} processed in manifest.\") # Verbose\r\n maps_processed += 1\r\n- processed_asset_flag = False # Mark that something was processed for this asset\r\n \r\n else:\r\n- errors_encountered += 1\r\n- processed_asset_flag = False # Still counts as an attempt for this asset\r\n+ # Increment error count if loading failed\r\n+ if image_load_failed: errors_encountered += 1\r\n \r\n # --- End Resolution Loop ---\r\n # --- End Map Type Loop ---\r\n \r\n- # --- Update Manifest (Asset Level - if fully processed) ---\r\n- # This logic might be redundant if we always check map level, but can be added\r\n- # if needed to mark the whole asset as 'touched' or 'completed'.\r\n- # For now, we rely on map-level updates.\r\n- # if not processed_asset_flag: # If any map was processed (or attempted)\r\n- # update_manifest(manifest_data, asset_name) # Mark asset as processed\r\n- # manifest_needs_saving = True\r\n-\r\n assets_processed += 1\r\n \r\n except FileNotFoundError:\r\n print(f\" !!! ERROR: Metadata file not found (should not happen if scan worked): {metadata_path}\")\r\n" }, { "date": 1745231081386, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -1,9 +1,13 @@\n # Blender Script: Create/Update Node Groups from Asset Processor Output\r\n-# Version: 1.1\r\n+# Version: 1.2\r\n # Description: Scans a library processed by the Asset Processor Tool,\r\n # reads metadata.json files, and creates/updates corresponding\r\n # PBR node groups in the active Blender file.\r\n+# Changes v1.2:\r\n+# - Added Base64 encoding for child node group names (PBRTYPE_...).\r\n+# - Added fallback logic for reconstructing image paths with different extensions.\r\n+# - Added logic to set custom asset preview for new parent groups (using lowest res COL map).\r\n # Changes v1.1:\r\n # - Updated metadata parsing to match actual structure (using processed_map_resolutions, image_stats_1k, map_details).\r\n # - Added logic to reconstruct image file paths based on metadata and assumed naming convention.\r\n \r\n@@ -12,8 +16,9 @@\n import json\r\n from pathlib import Path\r\n import time\r\n import re # For parsing aspect ratio string\r\n+import base64 # For encoding node group names\r\n \r\n # --- USER CONFIGURATION ---\r\n \r\n # Path to the root output directory of the Asset Processor Tool\r\n@@ -37,8 +42,18 @@\n # {asset_name}, {map_type}, {resolution}, {format} will be replaced.\r\n # Check Asset Processor Tool's config.py (TARGET_FILENAME_PATTERN) if this is wrong.\r\n IMAGE_FILENAME_PATTERN = \"{asset_name}_{map_type}_{resolution}.{format}\"\r\n \r\n+# Fallback extensions to try if the primary format from metadata is not found\r\n+# Order matters - first found will be used.\r\n+FALLBACK_IMAGE_EXTENSIONS = ['png', 'jpg', 'exr', 'tif']\r\n+\r\n+# Map type(s) to use for generating the asset preview (e.g., COL, COL-1)\r\n+# The script will look for these in order and use the first one found.\r\n+PREVIEW_MAP_TYPES = [\"COL\", \"COL-1\", \"COL-2\"]\r\n+# Preferred resolution order for preview (lowest first is often faster)\r\n+PREVIEW_RESOLUTION_ORDER = [\"1K\", \"512\", \"2K\", \"4K\"] # Adjust as needed\r\n+\r\n # Map PBR type strings (from metadata) to Blender color spaces\r\n # Add more mappings as needed based on your metadata types\r\n PBR_COLOR_SPACE_MAP = {\r\n \"AO\": \"Non-Color\", # Usually Non-Color, but depends on workflow\r\n@@ -66,8 +81,18 @@\n \r\n \r\n # --- Helper Functions ---\r\n \r\n+def encode_name_b64(name_str):\r\n+ \"\"\"Encodes a string using URL-safe Base64 for node group names.\"\"\"\r\n+ try:\r\n+ # Ensure the input is a string\r\n+ name_str = str(name_str)\r\n+ return base64.urlsafe_b64encode(name_str.encode('utf-8')).decode('ascii')\r\n+ except Exception as e:\r\n+ print(f\" Error base64 encoding '{name_str}': {e}\")\r\n+ return name_str # Fallback to original name on error\r\n+\r\n def find_nodes_by_label(node_tree, label, node_type=None):\r\n \"\"\"Finds ALL nodes in a node tree matching the label and optionally type.\"\"\"\r\n if not node_tree:\r\n return []\r\n@@ -154,34 +179,68 @@\n except Exception as e:\r\n print(f\" Error parsing aspect string '{aspect_string}': {e}. Returning 1.0.\")\r\n return 1.0\r\n \r\n-def reconstruct_image_path(asset_dir_path, asset_name, map_type, resolution, file_format):\r\n+def reconstruct_image_path_with_fallback(asset_dir_path, asset_name, map_type, resolution, primary_format):\r\n \"\"\"\r\n- Constructs the expected image file path based on the provided components\r\n- and the IMAGE_FILENAME_PATTERN.\r\n+ Constructs the expected image file path, trying the primary format first,\r\n+ then falling back to common extensions if the primary path doesn't exist.\r\n+ Returns the found path as a string, or None if not found.\r\n \"\"\"\r\n- if not all([asset_dir_path, asset_name, map_type, resolution, file_format]):\r\n+ if not all([asset_dir_path, asset_name, map_type, resolution, primary_format]):\r\n+ print(f\" !!! ERROR: Missing data for path reconstruction ({asset_name}/{map_type}/{resolution}).\")\r\n return None\r\n \r\n+ # 1. Try the primary format from metadata\r\n+ primary_path_str = None\r\n try:\r\n filename = IMAGE_FILENAME_PATTERN.format(\r\n asset_name=asset_name,\r\n map_type=map_type,\r\n resolution=resolution,\r\n- format=file_format\r\n+ format=primary_format.lower() # Ensure format is lowercase\r\n )\r\n- # The image file should be directly inside the asset directory\r\n- full_path = asset_dir_path / filename\r\n- return str(full_path)\r\n+ primary_path = asset_dir_path / filename\r\n+ primary_path_str = str(primary_path)\r\n+ if primary_path.is_file():\r\n+ # print(f\" Found primary path: {primary_path_str}\") # Verbose\r\n+ return primary_path_str\r\n+ # else: print(f\" Primary path not found: {primary_path_str}\") # Verbose\r\n except KeyError as e:\r\n print(f\" !!! ERROR: Missing key '{e}' in IMAGE_FILENAME_PATTERN. Cannot reconstruct path.\")\r\n- return None\r\n+ return None # Cannot proceed without valid pattern\r\n except Exception as e:\r\n- print(f\" !!! ERROR reconstructing image path: {e}\")\r\n- return None\r\n+ print(f\" !!! ERROR reconstructing primary image path: {e}\")\r\n+ # Continue to fallback even if primary reconstruction had issues\r\n \r\n+ # 2. Try fallback extensions if primary failed\r\n+ # print(f\" Trying fallback extensions for {map_type}/{resolution}...\") # Verbose\r\n+ for ext in FALLBACK_IMAGE_EXTENSIONS:\r\n+ if ext.lower() == primary_format.lower(): # Don't retry the primary format\r\n+ continue\r\n+ try:\r\n+ fallback_filename = IMAGE_FILENAME_PATTERN.format(\r\n+ asset_name=asset_name,\r\n+ map_type=map_type,\r\n+ resolution=resolution,\r\n+ format=ext.lower()\r\n+ )\r\n+ fallback_path = asset_dir_path / fallback_filename\r\n+ if fallback_path.is_file():\r\n+ print(f\" Found fallback path: {str(fallback_path)}\")\r\n+ return str(fallback_path)\r\n+ except KeyError:\r\n+ # Should not happen if primary format worked, but handle defensively\r\n+ print(f\" !!! ERROR: Missing key in IMAGE_FILENAME_PATTERN during fallback. Cannot reconstruct path.\")\r\n+ return None\r\n+ except Exception as e_fallback:\r\n+ print(f\" !!! ERROR reconstructing fallback image path ({ext}): {e_fallback}\")\r\n+ continue # Try next extension\r\n \r\n+ print(f\" !!! ERROR: Could not find image file for {map_type}/{resolution} using primary format '{primary_format}' or fallbacks {FALLBACK_IMAGE_EXTENSIONS}.\")\r\n+ return None # Not found after all checks\r\n+\r\n+\r\n # --- Manifest Functions ---\r\n \r\n def get_manifest_path(context):\r\n \"\"\"Gets the expected path for the manifest JSON file.\"\"\"\r\n@@ -332,8 +391,9 @@\n images_assigned = 0\r\n maps_processed = 0\r\n maps_skipped_manifest = 0\r\n errors_encountered = 0\r\n+ previews_set = 0\r\n # --- End Counters ---\r\n \r\n print(f\"\\nScanning for metadata files in '{root_path}'...\")\r\n \r\n@@ -477,16 +537,17 @@\n max_val = get_stat_value(stats.get(\"max\"))\r\n mean_val = get_stat_value(stats.get(\"mean\")) # Often stored as 'mean' or 'avg'\r\n \r\n updated_stat = False\r\n- if min_val is not None and abs(stats_node.inputs[0].default_value - min_val) > 0.0001:\r\n- stats_node.inputs[0].default_value = min_val\r\n+ # Check inputs exist before assigning\r\n+ if stats_node.inputs.get(\"X\") and min_val is not None and abs(stats_node.inputs[\"X\"].default_value - min_val) > 0.0001:\r\n+ stats_node.inputs[\"X\"].default_value = min_val\r\n updated_stat = True\r\n- if max_val is not None and abs(stats_node.inputs[1].default_value - max_val) > 0.0001:\r\n- stats_node.inputs[1].default_value = max_val\r\n+ if stats_node.inputs.get(\"Y\") and max_val is not None and abs(stats_node.inputs[\"Y\"].default_value - max_val) > 0.0001:\r\n+ stats_node.inputs[\"Y\"].default_value = max_val\r\n updated_stat = True\r\n- if mean_val is not None and abs(stats_node.inputs[2].default_value - mean_val) > 0.0001:\r\n- stats_node.inputs[2].default_value = mean_val\r\n+ if stats_node.inputs.get(\"Z\") and mean_val is not None and abs(stats_node.inputs[\"Z\"].default_value - mean_val) > 0.0001:\r\n+ stats_node.inputs[\"Z\"].default_value = mean_val\r\n updated_stat = True\r\n \r\n if updated_stat:\r\n print(f\" Set stats in '{stats_node_label}': Min={min_val:.4f}, Max={max_val:.4f}, Mean={mean_val:.4f}\")\r\n@@ -494,9 +555,49 @@\n # else: print(f\" Warn: Stats node '{stats_node_label}' not found in parent group.\") # Optional\r\n # else: print(f\" Info: Map type '{map_type_to_stat}' not present in image_stats_1k for stats application.\") # Optional\r\n # else: print(f\" Warn: 'image_stats_1k' missing or invalid in metadata.\") # Optional\r\n \r\n+ # --- Set Asset Preview (only for new parent groups) ---\r\n+ if is_new_parent and parent_group.asset_data:\r\n+ preview_path = None\r\n+ # Find the lowest resolution color map path\r\n+ for preview_map_type in PREVIEW_MAP_TYPES:\r\n+ if preview_map_type in processed_resolutions:\r\n+ available_resolutions = processed_resolutions[preview_map_type]\r\n+ # Find the lowest resolution available based on preferred order\r\n+ lowest_res = None\r\n+ for res_pref in PREVIEW_RESOLUTION_ORDER:\r\n+ if res_pref in available_resolutions:\r\n+ lowest_res = res_pref\r\n+ break\r\n+ if lowest_res:\r\n+ preview_map_details = map_details.get(preview_map_type, {})\r\n+ preview_format = preview_map_details.get(\"output_format\")\r\n+ if preview_format:\r\n+ preview_path = reconstruct_image_path_with_fallback(\r\n+ asset_dir_path=asset_dir_path,\r\n+ asset_name=asset_name,\r\n+ map_type=preview_map_type,\r\n+ resolution=lowest_res,\r\n+ primary_format=preview_format\r\n+ )\r\n+ if preview_path:\r\n+ break # Found a suitable preview image path\r\n+ # Load the preview if a path was found\r\n+ if preview_path:\r\n+ print(f\" Attempting to set preview from: {Path(preview_path).name}\")\r\n+ try:\r\n+ # Ensure the ID (node group) is the active one for the operator context\r\n+ with context.temp_override(id=parent_group):\r\n+ bpy.ops.ed.lib_id_load_custom_preview(filepath=preview_path)\r\n+ print(f\" Successfully set custom preview.\")\r\n+ previews_set += 1\r\n+ except Exception as e_preview:\r\n+ print(f\" !!! ERROR setting custom preview: {e_preview}\")\r\n+ errors_encountered += 1\r\n+ # else: print(f\" Info: Could not find suitable preview image ({PREVIEW_MAP_TYPES} at {PREVIEW_RESOLUTION_ORDER}) for '{asset_name}'.\")\r\n \r\n+\r\n # --- Child Group Handling ---\r\n # Iterate through the map types listed in processed_resolutions\r\n for map_type, resolutions in processed_resolutions.items():\r\n print(f\" Processing Map Type: {map_type}\")\r\n@@ -514,25 +615,27 @@\n print(f\" !!! WARNING: No placeholder node labeled '{map_type}' found in parent group '{parent_group.name}'. Skipping this map type.\")\r\n continue\r\n holder_node = holder_nodes[0] # Assume first is correct\r\n \r\n- # Determine child group name\r\n- target_child_name = f\"PBRTYPE_{asset_name}_{map_type}\"\r\n- child_group = bpy.data.node_groups.get(target_child_name)\r\n+ # Determine child group name (LOGICAL and ENCODED)\r\n+ logical_child_name = f\"{asset_name}_{map_type}\"\r\n+ target_child_name_b64 = encode_name_b64(logical_child_name) # Use Base64 name\r\n+\r\n+ child_group = bpy.data.node_groups.get(target_child_name_b64) # Find using encoded name\r\n is_new_child = False\r\n \r\n if child_group is None:\r\n- # print(f\" Creating new child group: '{target_child_name}'\") # Verbose\r\n+ # print(f\" Creating new child group: '{target_child_name_b64}' (logical: '{logical_child_name}')\") # Verbose\r\n child_group = template_child.copy()\r\n if not child_group:\r\n print(f\" !!! ERROR: Failed to copy child template '{CHILD_TEMPLATE_NAME}'. Skipping map type '{map_type}'.\")\r\n errors_encountered += 1\r\n continue\r\n- child_group.name = target_child_name\r\n+ child_group.name = target_child_name_b64 # Set encoded name\r\n child_groups_created += 1\r\n is_new_child = True\r\n else:\r\n- # print(f\" Updating existing child group: '{target_child_name}'\") # Verbose\r\n+ # print(f\" Updating existing child group: '{target_child_name_b64}'\") # Verbose\r\n child_groups_updated += 1\r\n \r\n # Assign child group to placeholder if needed\r\n if holder_node.node_tree != child_group:\r\n@@ -594,21 +697,21 @@\n continue\r\n \r\n print(f\" Processing Resolution: {resolution}\")\r\n \r\n- # Reconstruct the image path\r\n- image_path_str = reconstruct_image_path(\r\n+ # Reconstruct the image path using fallback logic\r\n+ image_path_str = reconstruct_image_path_with_fallback(\r\n asset_dir_path=asset_dir_path,\r\n asset_name=asset_name,\r\n map_type=map_type,\r\n resolution=resolution,\r\n- file_format=output_format\r\n+ primary_format=output_format # Pass the format from metadata\r\n )\r\n \r\n if not image_path_str:\r\n- print(f\" !!! ERROR: Failed to reconstruct image path for {map_type}/{resolution}. Skipping.\")\r\n+ # Error already printed by reconstruct function\r\n errors_encountered += 1\r\n- continue\r\n+ continue # Skip this resolution if path not found\r\n \r\n # Find image texture node within the CHILD group (labeled by resolution, e.g., \"4K\")\r\n image_nodes = find_nodes_by_label(child_group, resolution, 'ShaderNodeTexImage')\r\n if not image_nodes:\r\n@@ -618,20 +721,16 @@\n # --- Load Image ---\r\n img = None\r\n image_load_failed = False\r\n try:\r\n- image_path = Path(image_path_str)\r\n- if not image_path.is_file():\r\n- print(f\" !!! ERROR: Reconstructed image file not found: {image_path_str}\")\r\n+ image_path = Path(image_path_str) # Path object created from already found path string\r\n+ # Use check_existing=True to reuse existing datablocks if path matches\r\n+ img = bpy.data.images.load(str(image_path), check_existing=True)\r\n+ if not img:\r\n+ print(f\" !!! ERROR: Failed loading image via bpy.data.images.load: {image_path_str}\")\r\n image_load_failed = True\r\n else:\r\n- # Use check_existing=True to reuse existing datablocks if path matches\r\n- img = bpy.data.images.load(str(image_path), check_existing=True)\r\n- if not img:\r\n- print(f\" !!! ERROR: Failed loading image via bpy.data.images.load: {image_path_str}\")\r\n- image_load_failed = True\r\n- else:\r\n- images_loaded += 1 # Count successful loads\r\n+ images_loaded += 1 # Count successful loads\r\n except RuntimeError as e_runtime_load:\r\n # Catch specific Blender runtime errors (e.g., unsupported format)\r\n print(f\" !!! ERROR loading image '{image_path_str}': {e_runtime_load}\")\r\n image_load_failed = True\r\n@@ -718,8 +817,9 @@\n print(f\"Child Groups Updated: {child_groups_updated}\")\r\n print(f\"Images Loaded: {images_loaded}\")\r\n print(f\"Image Nodes Assigned: {images_assigned}\")\r\n print(f\"Individual Maps Processed: {maps_processed}\")\r\n+ print(f\"Asset Previews Set: {previews_set}\")\r\n if errors_encountered > 0:\r\n print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n print(\"---------------------------\")\r\n \r\n@@ -731,8 +831,9 @@\n if __name__ == \"__main__\":\r\n # Ensure we are running within Blender\r\n try:\r\n import bpy\r\n+ import base64 # Ensure base64 is imported here too if needed globally\r\n except ImportError:\r\n print(\"!!! ERROR: This script must be run from within Blender. !!!\")\r\n else:\r\n process_library(bpy.context)\n\\ No newline at end of file\n" }, { "date": 1745232352430, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -1,9 +1,13 @@\n # Blender Script: Create/Update Node Groups from Asset Processor Output\r\n-# Version: 1.2\r\n+# Version: 1.3\r\n # Description: Scans a library processed by the Asset Processor Tool,\r\n # reads metadata.json files, and creates/updates corresponding\r\n # PBR node groups in the active Blender file.\r\n+# Changes v1.3:\r\n+# - Added logic to find the highest resolution present for an asset.\r\n+# - Added logic to set a \"HighestResolution\" Value node in the parent group\r\n+# (maps 1K->1.0, 2K->2.0, 4K->3.0, 8K->4.0).\r\n # Changes v1.2:\r\n # - Added Base64 encoding for child node group names (PBRTYPE_...).\r\n # - Added fallback logic for reconstructing image paths with different extensions.\r\n # - Added logic to set custom asset preview for new parent groups (using lowest res COL map).\r\n@@ -32,8 +36,9 @@\n \r\n # Labels of specific nodes within the PARENT template\r\n ASPECT_RATIO_NODE_LABEL = \"AspectRatioCorrection\" # Value node for UV X-scaling factor\r\n STATS_NODE_PREFIX = \"Histogram-\" # Prefix for Combine XYZ nodes storing stats (e.g., \"Histogram-ROUGH\")\r\n+HIGHEST_RESOLUTION_NODE_LABEL = \"HighestResolution\" # Value node to store highest res index\r\n \r\n # Enable/disable the manifest system to track processed assets/maps\r\n # If enabled, requires the blend file to be saved.\r\n ENABLE_MANIFEST = True\r\n@@ -52,8 +57,13 @@\n PREVIEW_MAP_TYPES = [\"COL\", \"COL-1\", \"COL-2\"]\r\n # Preferred resolution order for preview (lowest first is often faster)\r\n PREVIEW_RESOLUTION_ORDER = [\"1K\", \"512\", \"2K\", \"4K\"] # Adjust as needed\r\n \r\n+# Mapping from resolution string to numerical value for the HighestResolution node\r\n+RESOLUTION_VALUE_MAP = {\"1K\": 1.0, \"2K\": 2.0, \"4K\": 3.0, \"8K\": 4.0}\r\n+# Order to check resolutions to find the highest present (highest value first)\r\n+RESOLUTION_ORDER_DESC = [\"8K\", \"4K\", \"2K\", \"1K\"] # Add others like \"512\" if needed and map them in RESOLUTION_VALUE_MAP\r\n+\r\n # Map PBR type strings (from metadata) to Blender color spaces\r\n # Add more mappings as needed based on your metadata types\r\n PBR_COLOR_SPACE_MAP = {\r\n \"AO\": \"Non-Color\", # Usually Non-Color, but depends on workflow\r\n@@ -214,9 +224,10 @@\n \r\n # 2. Try fallback extensions if primary failed\r\n # print(f\" Trying fallback extensions for {map_type}/{resolution}...\") # Verbose\r\n for ext in FALLBACK_IMAGE_EXTENSIONS:\r\n- if ext.lower() == primary_format.lower(): # Don't retry the primary format\r\n+ # Ensure ext is treated as string and handle potential None values defensively\r\n+ if not isinstance(ext, str) or not isinstance(primary_format, str) or ext.lower() == primary_format.lower():\r\n continue\r\n try:\r\n fallback_filename = IMAGE_FILENAME_PATTERN.format(\r\n asset_name=asset_name,\r\n@@ -392,8 +403,9 @@\n maps_processed = 0\r\n maps_skipped_manifest = 0\r\n errors_encountered = 0\r\n previews_set = 0\r\n+ highest_res_set = 0\r\n # --- End Counters ---\r\n \r\n print(f\"\\nScanning for metadata files in '{root_path}'...\")\r\n \r\n@@ -450,8 +462,27 @@\n # Continue processing, but path reconstruction might fail later\r\n \r\n print(f\" Asset Name: {asset_name}\")\r\n \r\n+ # --- Determine Highest Resolution ---\r\n+ highest_resolution_value = 0.0\r\n+ highest_resolution_str = \"Unknown\"\r\n+ all_resolutions_present = set()\r\n+ if processed_resolutions: # Check if the dict exists and is not empty\r\n+ for res_list in processed_resolutions.values():\r\n+ if isinstance(res_list, list):\r\n+ all_resolutions_present.update(res_list)\r\n+\r\n+ if all_resolutions_present:\r\n+ for res_str in RESOLUTION_ORDER_DESC:\r\n+ if res_str in all_resolutions_present:\r\n+ highest_resolution_value = RESOLUTION_VALUE_MAP.get(res_str, 0.0)\r\n+ highest_resolution_str = res_str\r\n+ if highest_resolution_value > 0.0:\r\n+ break # Found the highest valid resolution\r\n+\r\n+ print(f\" Highest resolution found: {highest_resolution_str} (Value: {highest_resolution_value})\")\r\n+\r\n # --- Manifest Check (Asset Level - Basic) ---\r\n if ENABLE_MANIFEST and is_asset_processed(manifest_data, asset_name):\r\n # Perform a quick check if *any* map needs processing for this asset\r\n needs_processing = False\r\n@@ -514,8 +545,19 @@\n aspect_node.outputs[0].default_value = correction_factor\r\n print(f\" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f} (from string '{aspect_string}')\")\r\n # else: print(f\" Warn: Aspect ratio node '{ASPECT_RATIO_NODE_LABEL}' not found in parent group.\") # Optional\r\n \r\n+ # Apply Highest Resolution Value\r\n+ hr_nodes = find_nodes_by_label(parent_group, HIGHEST_RESOLUTION_NODE_LABEL, 'ShaderNodeValue')\r\n+ if hr_nodes:\r\n+ hr_node = hr_nodes[0]\r\n+ if highest_resolution_value > 0.0 and abs(hr_node.outputs[0].default_value - highest_resolution_value) > 0.001:\r\n+ hr_node.outputs[0].default_value = highest_resolution_value\r\n+ print(f\" Set '{HIGHEST_RESOLUTION_NODE_LABEL}' value to {highest_resolution_value} ({highest_resolution_str})\")\r\n+ highest_res_set += 1 # Count successful sets\r\n+ # else: print(f\" Warn: Highest resolution node '{HIGHEST_RESOLUTION_NODE_LABEL}' not found in parent group.\") # Optional\r\n+\r\n+\r\n # Apply Stats (using image_stats_1k)\r\n if image_stats_1k and isinstance(image_stats_1k, dict):\r\n for map_type_to_stat in APPLY_STATS_FOR_MAP_TYPES:\r\n if map_type_to_stat in image_stats_1k:\r\n@@ -818,8 +860,9 @@\n print(f\"Images Loaded: {images_loaded}\")\r\n print(f\"Image Nodes Assigned: {images_assigned}\")\r\n print(f\"Individual Maps Processed: {maps_processed}\")\r\n print(f\"Asset Previews Set: {previews_set}\")\r\n+ print(f\"Highest Resolution Nodes Set: {highest_res_set}\") # Added counter\r\n if errors_encountered > 0:\r\n print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n print(\"---------------------------\")\r\n \r\n" }, { "date": 1745233342179, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -1,9 +1,12 @@\n # Blender Script: Create/Update Node Groups from Asset Processor Output\r\n-# Version: 1.3\r\n+# Version: 1.4\r\n # Description: Scans a library processed by the Asset Processor Tool,\r\n # reads metadata.json files, and creates/updates corresponding\r\n # PBR node groups in the active Blender file.\r\n+# Changes v1.4:\r\n+# - Corrected aspect ratio calculation to use actual image dimensions\r\n+# and the aspect_ratio_change_string, mirroring original script logic.\r\n # Changes v1.3:\r\n # - Added logic to find the highest resolution present for an asset.\r\n # - Added logic to set a \"HighestResolution\" Value node in the parent group\r\n # (maps 1K->1.0, 2K->2.0, 4K->3.0, 8K->4.0).\r\n@@ -51,13 +54,13 @@\n # Fallback extensions to try if the primary format from metadata is not found\r\n # Order matters - first found will be used.\r\n FALLBACK_IMAGE_EXTENSIONS = ['png', 'jpg', 'exr', 'tif']\r\n \r\n-# Map type(s) to use for generating the asset preview (e.g., COL, COL-1)\r\n+# Map type(s) to use for generating the asset preview AND for aspect ratio calculation reference\r\n # The script will look for these in order and use the first one found.\r\n-PREVIEW_MAP_TYPES = [\"COL\", \"COL-1\", \"COL-2\"]\r\n-# Preferred resolution order for preview (lowest first is often faster)\r\n-PREVIEW_RESOLUTION_ORDER = [\"1K\", \"512\", \"2K\", \"4K\"] # Adjust as needed\r\n+REFERENCE_MAP_TYPES = [\"COL\", \"COL-1\", \"COL-2\"] # Used for preview and aspect calc\r\n+# Preferred resolution order for reference image (lowest first is often faster)\r\n+REFERENCE_RESOLUTION_ORDER = [\"1K\", \"512\", \"2K\", \"4K\"] # Adjust as needed\r\n \r\n # Mapping from resolution string to numerical value for the HighestResolution node\r\n RESOLUTION_VALUE_MAP = {\"1K\": 1.0, \"2K\": 2.0, \"4K\": 3.0, \"8K\": 4.0}\r\n # Order to check resolutions to find the highest present (highest value first)\r\n@@ -139,58 +142,69 @@\n # Handle potential numbered variants like COL-1, COL-2\r\n base_map_type = map_type.split('-')[0]\r\n return PBR_COLOR_SPACE_MAP.get(base_map_type.upper(), DEFAULT_COLOR_SPACE)\r\n \r\n-def calculate_factor_from_string(aspect_string):\r\n+def calculate_aspect_correction_factor(image_width, image_height, aspect_string):\r\n \"\"\"\r\n- Parses the aspect_ratio_change_string from metadata and returns the\r\n- appropriate UV X-scaling factor needed to correct distortion.\r\n- Assumes the string format documented in Asset Processor Tool readme.md:\r\n- \"EVEN\", \"Xnnn\", \"Ynnn\", \"XnnnYnnn\".\r\n- Returns 1.0 if the string is invalid or \"EVEN\".\r\n+ Calculates the UV X-axis scaling factor needed to correct distortion,\r\n+ based on image dimensions and the aspect_ratio_change_string (\"EVEN\", \"Xnnn\", \"Ynnn\").\r\n+ Mirrors the logic from the original POC script.\r\n+ Returns 1.0 if dimensions are invalid or string is \"EVEN\" or invalid.\r\n \"\"\"\r\n- if not aspect_string or aspect_string.upper() == \"EVEN\":\r\n+ if image_height <= 0 or image_width <= 0:\r\n+ print(\" Warn: Invalid image dimensions for aspect ratio calculation. Returning 1.0.\")\r\n return 1.0\r\n \r\n- x_factor = 1.0\r\n- y_factor = 1.0\r\n+ # Calculate the actual aspect ratio of the image file\r\n+ current_aspect_ratio = image_width / image_height\r\n \r\n- # Regex to find X and Y scaling parts\r\n- match_x = re.search(r\"X(\\d+)\", aspect_string, re.IGNORECASE)\r\n- match_y = re.search(r\"Y(\\d+)\", aspect_string, re.IGNORECASE)\r\n+ if not aspect_string or aspect_string.upper() == \"EVEN\":\r\n+ # If scaling was even, the correction factor is just the image's aspect ratio\r\n+ # to make UVs match the image proportions.\r\n+ # print(f\" Aspect string is EVEN. Correction factor = current aspect ratio: {current_aspect_ratio:.4f}\")\r\n+ return current_aspect_ratio\r\n \r\n+ # Handle non-uniform scaling cases (\"Xnnn\", \"Ynnn\")\r\n+ match = re.match(r\"([XY])(\\d+)\", aspect_string, re.IGNORECASE)\r\n+ if not match:\r\n+ print(f\" Warn: Invalid Scaling string format '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f} as fallback.\")\r\n+ return current_aspect_ratio # Fallback to the image's own ratio\r\n+\r\n+ axis = match.group(1).upper()\r\n try:\r\n- if match_x:\r\n- amount_x = int(match_x.group(1))\r\n- if amount_x > 0:\r\n- x_factor = amount_x / 100.0\r\n- else:\r\n- print(f\" Warn: Invalid X amount in aspect string '{aspect_string}'. Using 1.0.\")\r\n+ amount = int(match.group(2))\r\n+ if amount <= 0:\r\n+ print(f\" Warn: Zero or negative Amount in Scaling string '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n+ return current_aspect_ratio\r\n+ except ValueError:\r\n+ print(f\" Warn: Invalid Amount in Scaling string '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n+ return current_aspect_ratio\r\n \r\n- if match_y:\r\n- amount_y = int(match_y.group(1))\r\n- if amount_y > 0:\r\n- y_factor = amount_y / 100.0\r\n- else:\r\n- print(f\" Warn: Invalid Y amount in aspect string '{aspect_string}'. Using 1.0.\")\r\n+ # Apply the non-uniform correction formula based on original script logic\r\n+ scaling_factor_percent = amount / 100.0\r\n+ correction_factor = current_aspect_ratio # Default\r\n \r\n- # The correction factor for the U (X) coordinate is Y/X\r\n- # If X was scaled by 1.5 (X150), U needs to be divided by 1.5 (multiplied by 1/1.5)\r\n- # If Y was scaled by 1.5 (Y150), U needs to be multiplied by 1.5\r\n- if x_factor == 0: # Avoid division by zero\r\n- print(f\" Warn: X factor is zero in aspect string '{aspect_string}'. Cannot calculate correction. Returning 1.0.\")\r\n- return 1.0\r\n+ try:\r\n+ if axis == 'X':\r\n+ if scaling_factor_percent == 0: raise ZeroDivisionError(\"X scaling factor is zero\")\r\n+ # If image was stretched horizontally (X > 1), divide UV.x by factor\r\n+ correction_factor = current_aspect_ratio / scaling_factor_percent\r\n+ elif axis == 'Y':\r\n+ # If image was stretched vertically (Y > 1), multiply UV.x by factor\r\n+ correction_factor = current_aspect_ratio * scaling_factor_percent\r\n+ # No 'else' needed due to regex structure\r\n \r\n- correction_factor = y_factor / x_factor\r\n- return correction_factor\r\n-\r\n- except ValueError:\r\n- print(f\" Warn: Invalid number in aspect string '{aspect_string}'. Returning 1.0.\")\r\n- return 1.0\r\n+ except ZeroDivisionError as e:\r\n+ print(f\" Warn: Division by zero during aspect factor calculation ({e}). Returning current ratio {current_aspect_ratio:.4f}.\")\r\n+ return current_aspect_ratio\r\n except Exception as e:\r\n- print(f\" Error parsing aspect string '{aspect_string}': {e}. Returning 1.0.\")\r\n- return 1.0\r\n+ print(f\" Error calculating aspect correction factor: {e}. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n+ return current_aspect_ratio\r\n \r\n+ # print(f\" Calculated aspect correction factor: {correction_factor:.4f} (from {image_width}x{image_height}, Scaling='{aspect_string}')\")\r\n+ return correction_factor\r\n+\r\n+\r\n def reconstruct_image_path_with_fallback(asset_dir_path, asset_name, map_type, resolution, primary_format):\r\n \"\"\"\r\n Constructs the expected image file path, trying the primary format first,\r\n then falling back to common extensions if the primary path doesn't exist.\r\n@@ -404,8 +418,9 @@\n maps_skipped_manifest = 0\r\n errors_encountered = 0\r\n previews_set = 0\r\n highest_res_set = 0\r\n+ aspect_ratio_set = 0\r\n # --- End Counters ---\r\n \r\n print(f\"\\nScanning for metadata files in '{root_path}'...\")\r\n \r\n@@ -481,8 +496,54 @@\n break # Found the highest valid resolution\r\n \r\n print(f\" Highest resolution found: {highest_resolution_str} (Value: {highest_resolution_value})\")\r\n \r\n+ # --- Load Reference Image for Aspect Ratio ---\r\n+ ref_image_path = None\r\n+ ref_image_width = 0\r\n+ ref_image_height = 0\r\n+ ref_image_loaded = False\r\n+ for ref_map_type in REFERENCE_MAP_TYPES:\r\n+ if ref_map_type in processed_resolutions:\r\n+ available_resolutions = processed_resolutions[ref_map_type]\r\n+ lowest_res = None\r\n+ for res_pref in REFERENCE_RESOLUTION_ORDER:\r\n+ if res_pref in available_resolutions:\r\n+ lowest_res = res_pref\r\n+ break\r\n+ if lowest_res:\r\n+ ref_map_details = map_details.get(ref_map_type, {})\r\n+ ref_format = ref_map_details.get(\"output_format\")\r\n+ if ref_format:\r\n+ ref_image_path = reconstruct_image_path_with_fallback(\r\n+ asset_dir_path=asset_dir_path,\r\n+ asset_name=asset_name,\r\n+ map_type=ref_map_type,\r\n+ resolution=lowest_res,\r\n+ primary_format=ref_format\r\n+ )\r\n+ if ref_image_path:\r\n+ break # Found a suitable reference image path\r\n+\r\n+ if ref_image_path:\r\n+ print(f\" Loading reference image for aspect ratio: {Path(ref_image_path).name}\")\r\n+ try:\r\n+ ref_img = bpy.data.images.load(ref_image_path, check_existing=True)\r\n+ if ref_img:\r\n+ ref_image_width = ref_img.size[0]\r\n+ ref_image_height = ref_img.size[1]\r\n+ ref_image_loaded = True\r\n+ print(f\" Reference image dimensions: {ref_image_width}x{ref_image_height}\")\r\n+ # We don't need to keep the image data block loaded after getting dimensions\r\n+ bpy.data.images.remove(ref_img)\r\n+ else:\r\n+ print(f\" !!! ERROR: Failed loading reference image via bpy.data.images.load: {ref_image_path}\")\r\n+ except Exception as e_ref_load:\r\n+ print(f\" !!! ERROR loading reference image '{ref_image_path}': {e_ref_load}\")\r\n+ else:\r\n+ print(f\" !!! WARNING: Could not find suitable reference image ({REFERENCE_MAP_TYPES} at {REFERENCE_RESOLUTION_ORDER}) for aspect ratio calculation.\")\r\n+\r\n+\r\n # --- Manifest Check (Asset Level - Basic) ---\r\n if ENABLE_MANIFEST and is_asset_processed(manifest_data, asset_name):\r\n # Perform a quick check if *any* map needs processing for this asset\r\n needs_processing = False\r\n@@ -538,13 +599,20 @@\n # Apply Aspect Ratio Correction\r\n aspect_nodes = find_nodes_by_label(parent_group, ASPECT_RATIO_NODE_LABEL, 'ShaderNodeValue')\r\n if aspect_nodes:\r\n aspect_node = aspect_nodes[0]\r\n- correction_factor = calculate_factor_from_string(aspect_string)\r\n- # Check if update is needed (avoids unnecessary console spam)\r\n+ correction_factor = 1.0 # Default if ref image fails\r\n+ if ref_image_loaded:\r\n+ correction_factor = calculate_aspect_correction_factor(ref_image_width, ref_image_height, aspect_string)\r\n+ print(f\" Calculated aspect correction factor: {correction_factor:.4f}\")\r\n+ else:\r\n+ print(f\" !!! WARNING: Using default aspect ratio correction (1.0) due to missing reference image.\")\r\n+\r\n+ # Check if update is needed\r\n if abs(aspect_node.outputs[0].default_value - correction_factor) > 0.0001:\r\n aspect_node.outputs[0].default_value = correction_factor\r\n- print(f\" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f} (from string '{aspect_string}')\")\r\n+ print(f\" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f}\")\r\n+ aspect_ratio_set += 1\r\n # else: print(f\" Warn: Aspect ratio node '{ASPECT_RATIO_NODE_LABEL}' not found in parent group.\") # Optional\r\n \r\n # Apply Highest Resolution Value\r\n hr_nodes = find_nodes_by_label(parent_group, HIGHEST_RESOLUTION_NODE_LABEL, 'ShaderNodeValue')\r\n@@ -598,46 +666,23 @@\n # else: print(f\" Info: Map type '{map_type_to_stat}' not present in image_stats_1k for stats application.\") # Optional\r\n # else: print(f\" Warn: 'image_stats_1k' missing or invalid in metadata.\") # Optional\r\n \r\n # --- Set Asset Preview (only for new parent groups) ---\r\n+ # Use the reference image path found earlier if available\r\n if is_new_parent and parent_group.asset_data:\r\n- preview_path = None\r\n- # Find the lowest resolution color map path\r\n- for preview_map_type in PREVIEW_MAP_TYPES:\r\n- if preview_map_type in processed_resolutions:\r\n- available_resolutions = processed_resolutions[preview_map_type]\r\n- # Find the lowest resolution available based on preferred order\r\n- lowest_res = None\r\n- for res_pref in PREVIEW_RESOLUTION_ORDER:\r\n- if res_pref in available_resolutions:\r\n- lowest_res = res_pref\r\n- break\r\n- if lowest_res:\r\n- preview_map_details = map_details.get(preview_map_type, {})\r\n- preview_format = preview_map_details.get(\"output_format\")\r\n- if preview_format:\r\n- preview_path = reconstruct_image_path_with_fallback(\r\n- asset_dir_path=asset_dir_path,\r\n- asset_name=asset_name,\r\n- map_type=preview_map_type,\r\n- resolution=lowest_res,\r\n- primary_format=preview_format\r\n- )\r\n- if preview_path:\r\n- break # Found a suitable preview image path\r\n- # Load the preview if a path was found\r\n- if preview_path:\r\n- print(f\" Attempting to set preview from: {Path(preview_path).name}\")\r\n+ if ref_image_loaded and ref_image_path: # Check if ref image was successfully loaded earlier\r\n+ print(f\" Attempting to set preview from reference image: {Path(ref_image_path).name}\")\r\n try:\r\n # Ensure the ID (node group) is the active one for the operator context\r\n with context.temp_override(id=parent_group):\r\n- bpy.ops.ed.lib_id_load_custom_preview(filepath=preview_path)\r\n+ bpy.ops.ed.lib_id_load_custom_preview(filepath=ref_image_path)\r\n print(f\" Successfully set custom preview.\")\r\n previews_set += 1\r\n except Exception as e_preview:\r\n print(f\" !!! ERROR setting custom preview: {e_preview}\")\r\n errors_encountered += 1\r\n- # else: print(f\" Info: Could not find suitable preview image ({PREVIEW_MAP_TYPES} at {PREVIEW_RESOLUTION_ORDER}) for '{asset_name}'.\")\r\n+ else:\r\n+ print(f\" Info: Could not set preview for '{asset_name}' as reference image was not found or loaded.\")\r\n \r\n \r\n # --- Child Group Handling ---\r\n # Iterate through the map types listed in processed_resolutions\r\n@@ -770,9 +815,13 @@\n if not img:\r\n print(f\" !!! ERROR: Failed loading image via bpy.data.images.load: {image_path_str}\")\r\n image_load_failed = True\r\n else:\r\n- images_loaded += 1 # Count successful loads\r\n+ # Only count as loaded if bpy.data.images.load succeeded\r\n+ # Check if it's newly loaded or reused\r\n+ is_newly_loaded = img.filepath == str(image_path) # Simple check, might not be perfect if paths are identical but data differs\r\n+ if is_newly_loaded: images_loaded += 1\r\n+\r\n except RuntimeError as e_runtime_load:\r\n # Catch specific Blender runtime errors (e.g., unsupported format)\r\n print(f\" !!! ERROR loading image '{image_path_str}': {e_runtime_load}\")\r\n image_load_failed = True\r\n@@ -860,9 +909,10 @@\n print(f\"Images Loaded: {images_loaded}\")\r\n print(f\"Image Nodes Assigned: {images_assigned}\")\r\n print(f\"Individual Maps Processed: {maps_processed}\")\r\n print(f\"Asset Previews Set: {previews_set}\")\r\n- print(f\"Highest Resolution Nodes Set: {highest_res_set}\") # Added counter\r\n+ print(f\"Highest Resolution Nodes Set: {highest_res_set}\")\r\n+ print(f\"Aspect Ratio Nodes Set: {aspect_ratio_set}\") # Added counter\r\n if errors_encountered > 0:\r\n print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n print(\"---------------------------\")\r\n \r\n" }, { "date": 1745234213954, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -43,9 +43,9 @@\n HIGHEST_RESOLUTION_NODE_LABEL = \"HighestResolution\" # Value node to store highest res index\r\n \r\n # Enable/disable the manifest system to track processed assets/maps\r\n # If enabled, requires the blend file to be saved.\r\n-ENABLE_MANIFEST = True\r\n+ENABLE_MANIFEST = False\r\n \r\n # Assumed filename pattern for processed images.\r\n # {asset_name}, {map_type}, {resolution}, {format} will be replaced.\r\n # Check Asset Processor Tool's config.py (TARGET_FILENAME_PATTERN) if this is wrong.\r\n@@ -69,20 +69,19 @@\n # Map PBR type strings (from metadata) to Blender color spaces\r\n # Add more mappings as needed based on your metadata types\r\n PBR_COLOR_SPACE_MAP = {\r\n \"AO\": \"Non-Color\", # Usually Non-Color, but depends on workflow\r\n- \"COL\": \"sRGB\",\r\n \"COL-1\": \"sRGB\", # Handle variants if present in metadata\r\n \"COL-2\": \"sRGB\",\r\n+ \"COL-3\": \"sRGB\",\r\n \"DISP\": \"Non-Color\",\r\n- \"NRM\": \"Non-Color\",\r\n \"REFL\": \"Non-Color\", # Reflection/Specular\r\n- \"ROUGH\": \"Non-Color\",\r\n \"METAL\": \"Non-Color\",\r\n- \"OPC\": \"Non-Color\", # Opacity/Alpha\r\n- \"TRN\": \"Non-Color\", # Transmission\r\n+ \"MASK\": \"Non-Color\", # Opacity/Alpha\r\n \"SSS\": \"sRGB\", # Subsurface Color\r\n \"EMISS\": \"sRGB\", # Emission Color\r\n+ \"NRMRGH\": \"Non-Color\",\r\n+ \"FUZZ\": \"Non-Color\",\r\n # Add other types like GLOSS, HEIGHT, etc. if needed\r\n }\r\n DEFAULT_COLOR_SPACE = \"sRGB\" # Fallback if map type not in the dictionary\r\n \r\n" }, { "date": 1745234305963, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -0,0 +1,874 @@\n+# Blender Script: Create/Update Node Groups from Asset Processor Output\r\n+# Version: 1.4\r\n+# Description: Scans a library processed by the Asset Processor Tool,\r\n+# reads metadata.json files, and creates/updates corresponding\r\n+# PBR node groups in the active Blender file.\r\n+# Changes v1.4:\r\n+# - Corrected aspect ratio calculation to use actual image dimensions\r\n+# and the aspect_ratio_change_string, mirroring original script logic.\r\n+# Changes v1.3:\r\n+# - Added logic to find the highest resolution present for an asset.\r\n+# - Added logic to set a \"HighestResolution\" Value node in the parent group\r\n+# (maps 1K->1.0, 2K->2.0, 4K->3.0, 8K->4.0).\r\n+# Changes v1.2:\r\n+# - Added Base64 encoding for child node group names (PBRTYPE_...).\r\n+# - Added fallback logic for reconstructing image paths with different extensions.\r\n+# - Added logic to set custom asset preview for new parent groups (using lowest res COL map).\r\n+# Changes v1.1:\r\n+# - Updated metadata parsing to match actual structure (using processed_map_resolutions, image_stats_1k, map_details).\r\n+# - Added logic to reconstruct image file paths based on metadata and assumed naming convention.\r\n+\r\n+import bpy\r\n+import os\r\n+import json\r\n+from pathlib import Path\r\n+import time\r\n+import re # For parsing aspect ratio string\r\n+import base64 # For encoding node group names\r\n+\r\n+# --- USER CONFIGURATION ---\r\n+\r\n+# Path to the root output directory of the Asset Processor Tool\r\n+# Example: r\"G:\\Assets\\Processed\"\r\n+# IMPORTANT: This should point to the base directory containing supplier folders (e.g., Poliigon)\r\n+PROCESSED_ASSET_LIBRARY_ROOT = r\"G:\\02 Content\\10-19 Content\\13 Textures Power of Two\\Asset_Processor_Output\" # <<< CHANGE THIS PATH!\r\n+\r\n+# Names of the required node group templates in the Blender file\r\n+PARENT_TEMPLATE_NAME = \"Template_PBRSET\"\r\n+CHILD_TEMPLATE_NAME = \"Template_PBRTYPE\"\r\n+\r\n+# Labels of specific nodes within the PARENT template\r\n+ASPECT_RATIO_NODE_LABEL = \"AspectRatioCorrection\" # Value node for UV X-scaling factor\r\n+STATS_NODE_PREFIX = \"Histogram-\" # Prefix for Combine XYZ nodes storing stats (e.g., \"Histogram-ROUGH\")\r\n+HIGHEST_RESOLUTION_NODE_LABEL = \"HighestResolution\" # Value node to store highest res index\r\n+\r\n+# Enable/disable the manifest system to track processed assets/maps\r\n+# If enabled, requires the blend file to be saved.\r\n+ENABLE_MANIFEST = True\r\n+\r\n+# Assumed filename pattern for processed images.\r\n+# {asset_name}, {map_type}, {resolution}, {format} will be replaced.\r\n+# Check Asset Processor Tool's config.py (TARGET_FILENAME_PATTERN) if this is wrong.\r\n+IMAGE_FILENAME_PATTERN = \"{asset_name}_{map_type}_{resolution}.{format}\"\r\n+\r\n+# Fallback extensions to try if the primary format from metadata is not found\r\n+# Order matters - first found will be used.\r\n+FALLBACK_IMAGE_EXTENSIONS = ['png', 'jpg', 'exr', 'tif']\r\n+\r\n+# Map type(s) to use for generating the asset preview AND for aspect ratio calculation reference\r\n+# The script will look for these in order and use the first one found.\r\n+REFERENCE_MAP_TYPES = [\"COL\", \"COL-1\", \"COL-2\"] # Used for preview and aspect calc\r\n+# Preferred resolution order for reference image (lowest first is often faster)\r\n+REFERENCE_RESOLUTION_ORDER = [\"1K\", \"512\", \"2K\", \"4K\"] # Adjust as needed\r\n+\r\n+# Mapping from resolution string to numerical value for the HighestResolution node\r\n+RESOLUTION_VALUE_MAP = {\"1K\": 1.0, \"2K\": 2.0, \"4K\": 3.0, \"8K\": 4.0}\r\n+# Order to check resolutions to find the highest present (highest value first)\r\n+RESOLUTION_ORDER_DESC = [\"8K\", \"4K\", \"2K\", \"1K\"] # Add others like \"512\" if needed and map them in RESOLUTION_VALUE_MAP\r\n+\r\n+# Map PBR type strings (from metadata) to Blender color spaces\r\n+# Add more mappings as needed based on your metadata types\r\n+PBR_COLOR_SPACE_MAP = {\r\n+ \"AO\": \"Non-Color\", # Usually Non-Color, but depends on workflow\r\n+ \"COL\": \"sRGB\",\r\n+ \"COL-1\": \"sRGB\", # Handle variants if present in metadata\r\n+ \"COL-2\": \"sRGB\",\r\n+ \"DISP\": \"Non-Color\",\r\n+ \"NRM\": \"Non-Color\",\r\n+ \"REFL\": \"Non-Color\", # Reflection/Specular\r\n+ \"ROUGH\": \"Non-Color\",\r\n+ \"METAL\": \"Non-Color\",\r\n+ \"OPC\": \"Non-Color\", # Opacity/Alpha\r\n+ \"TRN\": \"Non-Color\", # Transmission\r\n+ \"SSS\": \"sRGB\", # Subsurface Color\r\n+ \"EMISS\": \"sRGB\", # Emission Color\r\n+ # Add other types like GLOSS, HEIGHT, etc. if needed\r\n+}\r\n+DEFAULT_COLOR_SPACE = \"sRGB\" # Fallback if map type not in the dictionary\r\n+\r\n+# Map types for which stats should be applied (if found in metadata and node exists)\r\n+# Reads stats from the 'image_stats_1k' section of metadata.json\r\n+APPLY_STATS_FOR_MAP_TYPES = [\"ROUGH\", \"DISP\", \"METAL\", \"AO\", \"REFL\"] # Add others if needed\r\n+\r\n+# --- END USER CONFIGURATION ---\r\n+\r\n+\r\n+# --- Helper Functions ---\r\n+\r\n+def encode_name_b64(name_str):\r\n+ \"\"\"Encodes a string using URL-safe Base64 for node group names.\"\"\"\r\n+ try:\r\n+ # Ensure the input is a string\r\n+ name_str = str(name_str)\r\n+ return base64.urlsafe_b64encode(name_str.encode('utf-8')).decode('ascii')\r\n+ except Exception as e:\r\n+ print(f\" Error base64 encoding '{name_str}': {e}\")\r\n+ return current_aspect_ratio\r\n+\r\n+ # Handle non-uniform scaling cases (\"Xnnn\", \"Ynnn\")\r\n+ match = re.match(r\"([XY])(\\d+)\", aspect_string, re.IGNORECASE)\r\n+ if not match:\r\n+ print(f\" Warn: Invalid Scaling string format '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f} as fallback.\")\r\n+ return current_aspect_ratio # Fallback to the image's own ratio\r\n+\r\n+ axis = match.group(1).upper()\r\n+ try:\r\n+ amount = int(match.group(2))\r\n+ if amount <= 0:\r\n+ print(f\" Warn: Zero or negative Amount in Scaling string '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n+ return current_aspect_ratio\r\n+ except ValueError:\r\n+ print(f\" Warn: Invalid Amount in Scaling string '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n+ return current_aspect_ratio\r\n+\r\n+ # Apply the non-uniform correction formula based on original script logic\r\n+ scaling_factor_percent = amount / 100.0\r\n+ correction_factor = current_aspect_ratio # Default\r\n+\r\n+ try:\r\n+ if axis == 'X':\r\n+ if scaling_factor_percent == 0: raise ZeroDivisionError(\"X scaling factor is zero\")\r\n+ # If image was stretched horizontally (X > 1), divide UV.x by factor\r\n+ correction_factor = current_aspect_ratio / scaling_factor_percent\r\n+ elif axis == 'Y':\r\n+ # If image was stretched vertically (Y > 1), multiply UV.x by factor\r\n+ correction_factor = current_aspect_ratio * scaling_factor_percent\r\n+ # No 'else' needed due to regex structure\r\n+\r\n+ except ZeroDivisionError as e:\r\n+ print(f\" Warn: Division by zero during aspect factor calculation ({e}). Returning current ratio {current_aspect_ratio:.4f}.\")\r\n+ return current_aspect_ratio\r\n+ except Exception as e:\r\n+ print(f\" Error calculating aspect correction factor: {e}. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n+ return current_aspect_ratio\r\n+\r\n+ # print(f\" Calculated aspect correction factor: {correction_factor:.4f} (from {image_width}x{image_height}, Scaling='{aspect_string}')\")\r\n+ return correction_factor\r\n+\r\n+\r\n+def reconstruct_image_path_with_fallback(asset_dir_path, asset_name, map_type, resolution, primary_format):\r\n+ \"\"\"\r\n+ Constructs the expected image file path, trying the primary format first,\r\n+ then falling back to common extensions if the primary path doesn't exist.\r\n+ Returns the found path as a string, or None if not found.\r\n+ \"\"\"\r\n+ if not all([asset_dir_path, asset_name, map_type, resolution, primary_format]):\r\n+ print(f\" !!! ERROR: Missing data for path reconstruction ({asset_name}/{map_type}/{resolution}).\")\r\n+ return None\r\n+\r\n+ # 1. Try the primary format from metadata\r\n+ primary_path_str = None\r\n+ try:\r\n+ filename = IMAGE_FILENAME_PATTERN.format(\r\n+ asset_name=asset_name,\r\n+ map_type=map_type,\r\n+ resolution=resolution,\r\n+ format=primary_format.lower() # Ensure format is lowercase\r\n+ )\r\n+ primary_path = asset_dir_path / filename\r\n+ primary_path_str = str(primary_path)\r\n+ if primary_path.is_file():\r\n+ # print(f\" Found primary path: {primary_path_str}\") # Verbose\r\n+ return primary_path_str\r\n+ # else: print(f\" Primary path not found: {primary_path_str}\") # Verbose\r\n+ except KeyError as e:\r\n+ print(f\" !!! ERROR: Missing key '{e}' in IMAGE_FILENAME_PATTERN. Cannot reconstruct path.\")\r\n+ return None # Cannot proceed without valid pattern\r\n+ except Exception as e:\r\n+ print(f\" !!! ERROR reconstructing primary image path: {e}\")\r\n+ # Continue to fallback even if primary reconstruction had issues\r\n+\r\n+ # 2. Try fallback extensions if primary failed\r\n+ # print(f\" Trying fallback extensions for {map_type}/{resolution}...\") # Verbose\r\n+ for ext in FALLBACK_IMAGE_EXTENSIONS:\r\n+ # Ensure ext is treated as string and handle potential None values defensively\r\n+ if not isinstance(ext, str) or not isinstance(primary_format, str) or ext.lower() == primary_format.lower():\r\n+ continue\r\n+ try:\r\n+ fallback_filename = IMAGE_FILENAME_PATTERN.format(\r\n+ asset_name=asset_name,\r\n+ map_type=map_type,\r\n+ resolution=resolution,\r\n+ format=ext.lower()\r\n+ )\r\n+ fallback_path = asset_dir_path / fallback_filename\r\n+ if fallback_path.is_file():\r\n+ print(f\" Found fallback path: {str(fallback_path)}\")\r\n+ return str(fallback_path)\r\n+ except KeyError:\r\n+ # Should not happen if primary format worked, but handle defensively\r\n+ print(f\" !!! ERROR: Missing key in IMAGE_FILENAME_PATTERN during fallback. Cannot reconstruct path.\")\r\n+ return None\r\n+ except Exception as e_fallback:\r\n+ print(f\" !!! ERROR reconstructing fallback image path ({ext}): {e_fallback}\")\r\n+ continue # Try next extension\r\n+\r\n+ print(f\" !!! ERROR: Could not find image file for {map_type}/{resolution} using primary format '{primary_format}' or fallbacks {FALLBACK_IMAGE_EXTENSIONS}.\")\r\n+ return None # Not found after all checks\r\n+\r\n+\r\n+# --- Manifest Functions ---\r\n+\r\n+def get_manifest_path(context):\r\n+ \"\"\"Gets the expected path for the manifest JSON file.\"\"\"\r\n+ if not context or not context.blend_data or not context.blend_data.filepath:\r\n+ return None # Cannot determine path if blend file is not saved\r\n+ blend_path = Path(context.blend_data.filepath)\r\n+ manifest_filename = f\"{blend_path.stem}_manifest.json\"\r\n+ return blend_path.parent / manifest_filename\r\n+\r\n+def load_manifest(context):\r\n+ \"\"\"Loads the manifest data from the JSON file.\"\"\"\r\n+ if not ENABLE_MANIFEST:\r\n+ return {} # Manifest disabled\r\n+\r\n+ manifest_path = get_manifest_path(context)\r\n+ if not manifest_path:\r\n+ print(\" Manifest Info: Blend file not saved. Cannot load manifest.\")\r\n+ return {} # Cannot load without a path\r\n+\r\n+ if not manifest_path.exists():\r\n+ print(f\" Manifest Info: No manifest file found at '{manifest_path.name}'. Starting fresh.\")\r\n+ return {} # No manifest file exists yet\r\n+\r\n+ try:\r\n+ with open(manifest_path, 'r', encoding='utf-8') as f:\r\n+ data = json.load(f)\r\n+ print(f\" Manifest Loaded from: {manifest_path.name}\")\r\n+ # Basic validation (check if it's a dictionary)\r\n+ if not isinstance(data, dict):\r\n+ print(f\"!!! WARNING: Manifest file '{manifest_path.name}' has invalid format (not a dictionary). Starting fresh. !!!\")\r\n+ return {}\r\n+ return data\r\n+ except json.JSONDecodeError:\r\n+ print(f\"!!! WARNING: Manifest file '{manifest_path.name}' is corrupted. Starting fresh. !!!\")\r\n+ return {}\r\n+ except Exception as e:\r\n+ print(f\"!!! ERROR: Could not load manifest file '{manifest_path.name}': {e} !!!\")\r\n+ return {} # Treat as starting fresh on error\r\n+\r\n+def save_manifest(context, manifest_data):\r\n+ \"\"\"Saves the manifest data to the JSON file.\"\"\"\r\n+ if not ENABLE_MANIFEST or not manifest_data: # Don't save if disabled or empty\r\n+ return False\r\n+\r\n+ manifest_path = get_manifest_path(context)\r\n+ if not manifest_path:\r\n+ print(\" Manifest Error: Blend file not saved. Cannot save manifest.\")\r\n+ return False\r\n+\r\n+ try:\r\n+ with open(manifest_path, 'w', encoding='utf-8') as f:\r\n+ json.dump(manifest_data, f, indent=2, sort_keys=True) # Use indent and sort for readability\r\n+ print(f\" Manifest Saved to: {manifest_path.name}\")\r\n+ return True\r\n+ except Exception as e:\r\n+ print(f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\\n\"\r\n+ f\"!!! Manifest save FAILED to '{manifest_path.name}': {e} !!!\\n\"\r\n+ f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\")\r\n+ return False\r\n+\r\n+def is_asset_processed(manifest_data, asset_name):\r\n+ \"\"\"Checks if the entire asset (all its maps/resolutions) is marked as processed.\"\"\"\r\n+ if not ENABLE_MANIFEST: return False\r\n+ # Basic check if asset entry exists. Detailed check happens at map level.\r\n+ return asset_name in manifest_data\r\n+\r\n+def is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n+ \"\"\"Checks if a specific map type and resolution for an asset is processed.\"\"\"\r\n+ if not ENABLE_MANIFEST: return False\r\n+ return resolution in manifest_data.get(asset_name, {}).get(map_type, [])\r\n+\r\n+def update_manifest(manifest_data, asset_name, map_type=None, resolution=None):\r\n+ \"\"\"Updates the manifest dictionary in memory.\"\"\"\r\n+ if not ENABLE_MANIFEST: return False\r\n+\r\n+ # Ensure asset entry exists\r\n+ if asset_name not in manifest_data:\r\n+ manifest_data[asset_name] = {}\r\n+\r\n+ # If map_type and resolution are provided, update the specific map entry\r\n+ if map_type and resolution:\r\n+ if map_type not in manifest_data[asset_name]:\r\n+ manifest_data[asset_name][map_type] = []\r\n+\r\n+ if resolution not in manifest_data[asset_name][map_type]:\r\n+ manifest_data[asset_name][map_type].append(resolution)\r\n+ manifest_data[asset_name][map_type].sort() # Keep sorted\r\n+ return True # Indicate that a change was made\r\n+ return False # No change made to this specific map/res\r\n+\r\n+\r\n+# --- Core Logic ---\r\n+\r\n+def process_library(context):\r\n+ global ENABLE_MANIFEST # Declare intent to modify global if needed\r\n+ \"\"\"Scans the library, reads metadata, creates/updates node groups.\"\"\"\r\n+ start_time = time.time()\r\n+ print(f\"\\n--- Starting Node Group Processing ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---\")\r\n+\r\n+ # --- Pre-run Checks ---\r\n+ print(\"Performing pre-run checks...\")\r\n+ valid_setup = True\r\n+ # 1. Check Library Root Path\r\n+ root_path = Path(PROCESSED_ASSET_LIBRARY_ROOT)\r\n+ if not root_path.is_dir():\r\n+ print(f\"!!! ERROR: Processed asset library root directory not found or not a directory:\")\r\n+ print(f\"!!! '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n+ valid_setup = False\r\n+ else:\r\n+ print(f\" Asset Library Root: '{root_path}'\")\r\n+\r\n+ # 2. Check Templates\r\n+ template_parent = bpy.data.node_groups.get(PARENT_TEMPLATE_NAME)\r\n+ template_child = bpy.data.node_groups.get(CHILD_TEMPLATE_NAME)\r\n+ if not template_parent:\r\n+ print(f\"!!! ERROR: Parent template node group '{PARENT_TEMPLATE_NAME}' not found in this Blender file.\")\r\n+ valid_setup = False\r\n+ if not template_child:\r\n+ print(f\"!!! ERROR: Child template node group '{CHILD_TEMPLATE_NAME}' not found in this Blender file.\")\r\n+ valid_setup = False\r\n+ if template_parent and template_child:\r\n+ print(f\" Found Templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\")\r\n+\r\n+ # 3. Check Blend File Saved (if manifest enabled)\r\n+ if ENABLE_MANIFEST and not context.blend_data.filepath:\r\n+ print(f\"!!! WARNING: Manifest is enabled, but the current Blender file is not saved.\")\r\n+ print(f\"!!! Manifest cannot be loaded or saved. Processing will continue without manifest checks.\")\r\n+ ENABLE_MANIFEST = False # Disable manifest for this run\r\n+\r\n+ if not valid_setup:\r\n+ print(\"\\n--- Script aborted due to configuration errors. Please fix the issues above. ---\")\r\n+ return False\r\n+ print(\"Pre-run checks passed.\")\r\n+ # --- End Pre-run Checks ---\r\n+\r\n+ manifest_data = load_manifest(context)\r\n+ manifest_needs_saving = False\r\n+\r\n+ # --- Initialize Counters ---\r\n+ metadata_files_found = 0\r\n+ assets_processed = 0\r\n+ assets_skipped_manifest = 0\r\n+ parent_groups_created = 0\r\n+ parent_groups_updated = 0\r\n+ child_groups_created = 0\r\n+ child_groups_updated = 0\r\n+ images_loaded = 0\r\n+ images_assigned = 0\r\n+ maps_processed = 0\r\n+ maps_skipped_manifest = 0\r\n+ errors_encountered = 0\r\n+ previews_set = 0\r\n+ highest_res_set = 0\r\n+ aspect_ratio_set = 0\r\n+ # --- End Counters ---\r\n+\r\n+ print(f\"\\nScanning for metadata files in '{root_path}'...\")\r\n+\r\n+ # --- Scan for metadata.json ---\r\n+ # Scan one level deeper for supplier folders (e.g., Poliigon)\r\n+ # Then scan within each supplier for asset folders containing metadata.json\r\n+ metadata_paths = []\r\n+ for supplier_dir in root_path.iterdir():\r\n+ if supplier_dir.is_dir():\r\n+ # Now look for asset folders inside the supplier directory\r\n+ for asset_dir in supplier_dir.iterdir():\r\n+ if asset_dir.is_dir():\r\n+ metadata_file = asset_dir / 'metadata.json'\r\n+ if metadata_file.is_file():\r\n+ metadata_paths.append(metadata_file)\r\n+\r\n+ metadata_files_found = len(metadata_paths)\r\n+ print(f\"Found {metadata_files_found} metadata.json files.\")\r\n+\r\n+ if metadata_files_found == 0:\r\n+ print(\"No metadata files found. Nothing to process.\")\r\n+ print(\"--- Script Finished ---\")\r\n+ return True # No work needed is considered success\r\n+\r\n+ # --- Process Each Metadata File ---\r\n+ for metadata_path in metadata_paths:\r\n+ asset_dir_path = metadata_path.parent # Get the directory containing the metadata file\r\n+ print(f\"\\n--- Processing Metadata: {metadata_path.relative_to(root_path)} ---\")\r\n+ try:\r\n+ with open(metadata_path, 'r', encoding='utf-8') as f:\r\n+ metadata = json.load(f)\r\n+\r\n+ # --- Extract Key Info ---\r\n+ asset_name = metadata.get(\"asset_name\")\r\n+ supplier_name = metadata.get(\"supplier_name\")\r\n+ archetype = metadata.get(\"archetype\")\r\n+ # Get map info from the correct keys\r\n+ processed_resolutions = metadata.get(\"processed_map_resolutions\") # Dict: {map_type: [res1, res2]}\r\n+ map_details = metadata.get(\"map_details\") # Dict: {map_type: {details}}\r\n+ image_stats_1k = metadata.get(\"image_stats_1k\") # Dict: {map_type: {stats}}\r\n+ aspect_string = metadata.get(\"aspect_ratio_change_string\")\r\n+\r\n+ # Validate essential data\r\n+ if not asset_name:\r\n+ print(f\" !!! ERROR: Metadata file is missing 'asset_name'. Skipping.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ if not processed_resolutions or not isinstance(processed_resolutions, dict):\r\n+ print(f\" !!! ERROR: Metadata file is missing or has invalid 'processed_map_resolutions'. Skipping asset '{asset_name}'.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ if not map_details or not isinstance(map_details, dict):\r\n+ print(f\" !!! WARNING: Metadata file is missing or has invalid 'map_details'. Path reconstruction might fail for asset '{asset_name}'.\")\r\n+ # Continue processing, but path reconstruction might fail later\r\n+\r\n+ print(f\" Asset Name: {asset_name}\")\r\n+\r\n+ # --- Determine Highest Resolution ---\r\n+ highest_resolution_value = 0.0\r\n+ highest_resolution_str = \"Unknown\"\r\n+ all_resolutions_present = set()\r\n+ if processed_resolutions: # Check if the dict exists and is not empty\r\n+ for res_list in processed_resolutions.values():\r\n+ if isinstance(res_list, list):\r\n+ all_resolutions_present.update(res_list)\r\n+\r\n+ if all_resolutions_present:\r\n+ for res_str in RESOLUTION_ORDER_DESC:\r\n+ if res_str in all_resolutions_present:\r\n+ highest_resolution_value = RESOLUTION_VALUE_MAP.get(res_str, 0.0)\r\n+ highest_resolution_str = res_str\r\n+ if highest_resolution_value > 0.0:\r\n+ break # Found the highest valid resolution\r\n+\r\n+ print(f\" Highest resolution found: {highest_resolution_str} (Value: {highest_resolution_value})\")\r\n+\r\n+ # --- Load Reference Image for Aspect Ratio ---\r\n+ ref_image_path = None\r\n+ ref_image_width = 0\r\n+ ref_image_height = 0\r\n+ ref_image_loaded = False\r\n+ for ref_map_type in REFERENCE_MAP_TYPES:\r\n+ if ref_map_type in processed_resolutions:\r\n+ available_resolutions = processed_resolutions[ref_map_type]\r\n+ lowest_res = None\r\n+ for res_pref in REFERENCE_RESOLUTION_ORDER:\r\n+ if res_pref in available_resolutions:\r\n+ lowest_res = res_pref\r\n+ break\r\n+ if lowest_res:\r\n+ ref_map_details = map_details.get(ref_map_type, {})\r\n+ ref_format = ref_map_details.get(\"output_format\")\r\n+ if ref_format:\r\n+ ref_image_path = reconstruct_image_path_with_fallback(\r\n+ asset_dir_path=asset_dir_path,\r\n+ asset_name=asset_name,\r\n+ map_type=ref_map_type,\r\n+ resolution=lowest_res,\r\n+ primary_format=ref_format\r\n+ )\r\n+ if ref_image_path:\r\n+ break # Found a suitable reference image path\r\n+\r\n+ if ref_image_path:\r\n+ print(f\" Loading reference image for aspect ratio: {Path(ref_image_path).name}\")\r\n+ try:\r\n+ ref_img = bpy.data.images.load(ref_image_path, check_existing=True)\r\n+ if ref_img:\r\n+ ref_image_width = ref_img.size[0]\r\n+ ref_image_height = ref_img.size[1]\r\n+ ref_image_loaded = True\r\n+ print(f\" Reference image dimensions: {ref_image_width}x{ref_image_height}\")\r\n+ # We don't need to keep the image data block loaded after getting dimensions\r\n+ bpy.data.images.remove(ref_img)\r\n+ else:\r\n+ print(f\" !!! ERROR: Failed loading reference image via bpy.data.images.load: {ref_image_path}\")\r\n+ except Exception as e_ref_load:\r\n+ print(f\" !!! ERROR loading reference image '{ref_image_path}': {e_ref_load}\")\r\n+ else:\r\n+ print(f\" !!! WARNING: Could not find suitable reference image ({REFERENCE_MAP_TYPES} at {REFERENCE_RESOLUTION_ORDER}) for aspect ratio calculation.\")\r\n+\r\n+\r\n+ # --- Manifest Check (Asset Level - Basic) ---\r\n+ if ENABLE_MANIFEST and is_asset_processed(manifest_data, asset_name):\r\n+ # Perform a quick check if *any* map needs processing for this asset\r\n+ needs_processing = False\r\n+ for map_type, resolutions in processed_resolutions.items():\r\n+ for resolution in resolutions:\r\n+ if not is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n+ needs_processing = True\r\n+ break\r\n+ if needs_processing:\r\n+ break\r\n+ if not needs_processing:\r\n+ print(f\" Skipping asset '{asset_name}' (already fully processed according to manifest).\")\r\n+ assets_skipped_manifest += 1\r\n+ continue # Skip to next metadata file\r\n+\r\n+ # --- Parent Group Handling ---\r\n+ target_parent_name = f\"PBRSET_{asset_name}\"\r\n+ parent_group = bpy.data.node_groups.get(target_parent_name)\r\n+ is_new_parent = False\r\n+\r\n+ if parent_group is None:\r\n+ print(f\" Creating new parent group: '{target_parent_name}'\")\r\n+ parent_group = template_parent.copy()\r\n+ if not parent_group:\r\n+ print(f\" !!! ERROR: Failed to copy parent template '{PARENT_TEMPLATE_NAME}'. Skipping asset '{asset_name}'.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ parent_group.name = target_parent_name\r\n+ parent_groups_created += 1\r\n+ is_new_parent = True\r\n+ else:\r\n+ print(f\" Updating existing parent group: '{target_parent_name}'\")\r\n+ parent_groups_updated += 1\r\n+\r\n+ # Ensure marked as asset\r\n+ if not parent_group.asset_data:\r\n+ try:\r\n+ parent_group.asset_mark()\r\n+ print(f\" Marked '{parent_group.name}' as asset.\")\r\n+ except Exception as e_mark:\r\n+ print(f\" !!! ERROR: Failed to mark group '{parent_group.name}' as asset: {e_mark}\")\r\n+ # Continue processing other parts if possible\r\n+\r\n+ # Apply Asset Tags\r\n+ if parent_group.asset_data:\r\n+ if supplier_name:\r\n+ add_tag_if_new(parent_group.asset_data, supplier_name)\r\n+ if archetype:\r\n+ add_tag_if_new(parent_group.asset_data, archetype)\r\n+ # Add other tags if needed\r\n+ # else: print(f\" Warn: Cannot apply tags, asset_data not available for '{parent_group.name}'.\") # Optional warning\r\n+\r\n+ # Apply Aspect Ratio Correction\r\n+ aspect_nodes = find_nodes_by_label(parent_group, ASPECT_RATIO_NODE_LABEL, 'ShaderNodeValue')\r\n+ if aspect_nodes:\r\n+ aspect_node = aspect_nodes[0]\r\n+ correction_factor = 1.0 # Default if ref image fails\r\n+ if ref_image_loaded:\r\n+ correction_factor = calculate_aspect_correction_factor(ref_image_width, ref_image_height, aspect_string)\r\n+ print(f\" Calculated aspect correction factor: {correction_factor:.4f}\")\r\n+ else:\r\n+ print(f\" !!! WARNING: Using default aspect ratio correction (1.0) due to missing reference image.\")\r\n+\r\n+ # Check if update is needed\r\n+ if abs(aspect_node.outputs[0].default_value - correction_factor) > 0.0001:\r\n+ aspect_node.outputs[0].default_value = correction_factor\r\n+ print(f\" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f}\")\r\n+ aspect_ratio_set += 1\r\n+ # else: print(f\" Warn: Aspect ratio node '{ASPECT_RATIO_NODE_LABEL}' not found in parent group.\") # Optional\r\n+\r\n+ # Apply Highest Resolution Value\r\n+ hr_nodes = find_nodes_by_label(parent_group, HIGHEST_RESOLUTION_NODE_LABEL, 'ShaderNodeValue')\r\n+ if hr_nodes:\r\n+ hr_node = hr_nodes[0]\r\n+ if highest_resolution_value > 0.0 and abs(hr_node.outputs[0].default_value - highest_resolution_value) > 0.001:\r\n+ hr_node.outputs[0].default_value = highest_resolution_value\r\n+ print(f\" Set '{HIGHEST_RESOLUTION_NODE_LABEL}' value to {highest_resolution_value} ({highest_resolution_str})\")\r\n+ highest_res_set += 1 # Count successful sets\r\n+ # else: print(f\" Warn: Highest resolution node '{HIGHEST_RESOLUTION_NODE_LABEL}' not found in parent group.\") # Optional\r\n+\r\n+\r\n+ # Apply Stats (using image_stats_1k)\r\n+ if image_stats_1k and isinstance(image_stats_1k, dict):\r\n+ for map_type_to_stat in APPLY_STATS_FOR_MAP_TYPES:\r\n+ if map_type_to_stat in image_stats_1k:\r\n+ # Find the stats node in the parent group\r\n+ stats_node_label = f\"{STATS_NODE_PREFIX}{map_type_to_stat}\"\r\n+ stats_nodes = find_nodes_by_label(parent_group, stats_node_label, 'ShaderNodeCombineXYZ')\r\n+ if stats_nodes:\r\n+ stats_node = stats_nodes[0]\r\n+ stats = image_stats_1k[map_type_to_stat] # Get stats dict for this map type\r\n+\r\n+ if stats and isinstance(stats, dict):\r\n+ # Handle potential list format for RGB stats (use first value) or direct float\r\n+ def get_stat_value(stat_val):\r\n+ if isinstance(stat_val, list):\r\n+ return stat_val[0] if stat_val else None\r\n+ return stat_val\r\n+\r\n+ min_val = get_stat_value(stats.get(\"min\"))\r\n+ max_val = get_stat_value(stats.get(\"max\"))\r\n+ mean_val = get_stat_value(stats.get(\"mean\")) # Often stored as 'mean' or 'avg'\r\n+\r\n+ updated_stat = False\r\n+ # Check inputs exist before assigning\r\n+ if stats_node.inputs.get(\"X\") and min_val is not None and abs(stats_node.inputs[\"X\"].default_value - min_val) > 0.0001:\r\n+ stats_node.inputs[\"X\"].default_value = min_val\r\n+ updated_stat = True\r\n+ if stats_node.inputs.get(\"Y\") and max_val is not None and abs(stats_node.inputs[\"Y\"].default_value - max_val) > 0.0001:\r\n+ stats_node.inputs[\"Y\"].default_value = max_val\r\n+ updated_stat = True\r\n+ if stats_node.inputs.get(\"Z\") and mean_val is not None and abs(stats_node.inputs[\"Z\"].default_value - mean_val) > 0.0001:\r\n+ stats_node.inputs[\"Z\"].default_value = mean_val\r\n+ updated_stat = True\r\n+\r\n+ if updated_stat:\r\n+ print(f\" Set stats in '{stats_node_label}': Min={min_val:.4f}, Max={max_val:.4f}, Mean={mean_val:.4f}\")\r\n+ # else: print(f\" Info: No valid 'stats' dictionary found for map type '{map_type_to_stat}' in image_stats_1k.\") # Optional\r\n+ # else: print(f\" Warn: Stats node '{stats_node_label}' not found in parent group.\") # Optional\r\n+ # else: print(f\" Info: Map type '{map_type_to_stat}' not present in image_stats_1k for stats application.\") # Optional\r\n+ # else: print(f\" Warn: 'image_stats_1k' missing or invalid in metadata.\") # Optional\r\n+\r\n+ # --- Set Asset Preview (only for new parent groups) ---\r\n+ # Use the reference image path found earlier if available\r\n+ if is_new_parent and parent_group.asset_data:\r\n+ if ref_image_loaded and ref_image_path: # Check if ref image was successfully loaded earlier\r\n+ print(f\" Attempting to set preview from reference image: {Path(ref_image_path).name}\")\r\n+ try:\r\n+ # Ensure the ID (node group) is the active one for the operator context\r\n+ with context.temp_override(id=parent_group):\r\n+ bpy.ops.ed.lib_id_load_custom_preview(filepath=ref_image_path)\r\n+ print(f\" Successfully set custom preview.\")\r\n+ previews_set += 1\r\n+ except Exception as e_preview:\r\n+ print(f\" !!! ERROR setting custom preview: {e_preview}\")\r\n+ errors_encountered += 1\r\n+ else:\r\n+ print(f\" Info: Could not set preview for '{asset_name}' as reference image was not found or loaded.\")\r\n+\r\n+\r\n+ # --- Child Group Handling ---\r\n+ # Iterate through the map types listed in processed_resolutions\r\n+ for map_type, resolutions in processed_resolutions.items():\r\n+ print(f\" Processing Map Type: {map_type}\")\r\n+\r\n+ # Get details for this map type (needed for format)\r\n+ current_map_details = map_details.get(map_type, {})\r\n+ output_format = current_map_details.get(\"output_format\")\r\n+ if not output_format:\r\n+ print(f\" !!! WARNING: Missing 'output_format' in map_details for '{map_type}'. Cannot reconstruct path. Skipping map type.\")\r\n+ continue\r\n+\r\n+ # Find placeholder node in parent\r\n+ holder_nodes = find_nodes_by_label(parent_group, map_type, 'ShaderNodeGroup')\r\n+ if not holder_nodes:\r\n+ print(f\" !!! WARNING: No placeholder node labeled '{map_type}' found in parent group '{parent_group.name}'. Skipping this map type.\")\r\n+ continue\r\n+ holder_node = holder_nodes[0] # Assume first is correct\r\n+\r\n+ # Determine child group name (LOGICAL and ENCODED)\r\n+ logical_child_name = f\"{asset_name}_{map_type}\"\r\n+ target_child_name_b64 = encode_name_b64(logical_child_name) # Use Base64 name\r\n+\r\n+ child_group = bpy.data.node_groups.get(target_child_name_b64) # Find using encoded name\r\n+ is_new_child = False\r\n+\r\n+ if child_group is None:\r\n+ # print(f\" Creating new child group: '{target_child_name_b64}' (logical: '{logical_child_name}')\") # Verbose\r\n+ child_group = template_child.copy()\r\n+ if not child_group:\r\n+ print(f\" !!! ERROR: Failed to copy child template '{CHILD_TEMPLATE_NAME}'. Skipping map type '{map_type}'.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ child_group.name = target_child_name_b64 # Set encoded name\r\n+ child_groups_created += 1\r\n+ is_new_child = True\r\n+ else:\r\n+ # print(f\" Updating existing child group: '{target_child_name_b64}'\") # Verbose\r\n+ child_groups_updated += 1\r\n+\r\n+ # Assign child group to placeholder if needed\r\n+ if holder_node.node_tree != child_group:\r\n+ try:\r\n+ holder_node.node_tree = child_group\r\n+ print(f\" Assigned child group '{child_group.name}' to placeholder '{holder_node.label}'.\")\r\n+ except TypeError as e_assign: # Catch potential type errors if placeholder isn't a group node\r\n+ print(f\" !!! ERROR: Could not assign child group to placeholder '{holder_node.label}'. Is it a Group Node? Error: {e_assign}\")\r\n+ continue # Skip this map type if assignment fails\r\n+\r\n+ # Link placeholder output to parent output socket\r\n+ try:\r\n+ # Find parent's output node\r\n+ group_output_node = next((n for n in parent_group.nodes if n.type == 'GROUP_OUTPUT'), None)\r\n+ if group_output_node:\r\n+ # Get the specific output socket on the placeholder (usually index 0 or named 'Color'/'Value')\r\n+ source_socket = holder_node.outputs.get(\"Color\") or holder_node.outputs.get(\"Value\") or holder_node.outputs[0]\r\n+ # Get the specific input socket on the parent output node (matching map_type)\r\n+ target_socket = group_output_node.inputs.get(map_type)\r\n+\r\n+ if source_socket and target_socket:\r\n+ # Check if link already exists\r\n+ link_exists = any(link.from_socket == source_socket and link.to_socket == target_socket for link in parent_group.links)\r\n+ if not link_exists:\r\n+ parent_group.links.new(source_socket, target_socket)\r\n+ print(f\" Linked '{holder_node.label}' output to parent output socket '{map_type}'.\")\r\n+ # else: # Optional warnings\r\n+ # if not source_socket: print(f\" Warn: Could not find suitable output socket on placeholder '{holder_node.label}'.\")\r\n+ # if not target_socket: print(f\" Warn: Could not find input socket '{map_type}' on parent output node.\")\r\n+ # else: print(f\" Warn: Parent group '{parent_group.name}' has no Group Output node.\")\r\n+\r\n+ except Exception as e_link:\r\n+ print(f\" !!! ERROR linking sockets for '{map_type}': {e_link}\")\r\n+\r\n+ # Ensure parent output socket type is Color (if it exists)\r\n+ try:\r\n+ # Use the interface API for modern Blender versions\r\n+ item = parent_group.interface.items_tree.get(map_type)\r\n+ if item and item.item_type == 'SOCKET' and item.in_out == 'OUTPUT':\r\n+ # Common useful types: 'NodeSocketColor', 'NodeSocketVector', 'NodeSocketFloat'\r\n+ # Defaulting to Color seems reasonable for most PBR outputs\r\n+ if item.socket_type != 'NodeSocketColor':\r\n+ item.socket_type = 'NodeSocketColor'\r\n+ # print(f\" Set parent output socket '{map_type}' type to Color.\") # Optional info\r\n+ except Exception as e_sock_type:\r\n+ print(f\" Warn: Could not verify/set socket type for '{map_type}': {e_sock_type}\")\r\n+\r\n+\r\n+ # --- Image Node Handling (Inside Child Group) ---\r\n+ if not isinstance(resolutions, list):\r\n+ print(f\" !!! ERROR: Invalid format for resolutions list for map type '{map_type}'. Skipping.\")\r\n+ continue\r\n+\r\n+ for resolution in resolutions:\r\n+ # --- Manifest Check (Map/Resolution Level) ---\r\n+ if ENABLE_MANIFEST and is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n+ # print(f\" Skipping {resolution} (Manifest)\") # Verbose\r\n+ maps_skipped_manifest += 1\r\n+ continue\r\n+\r\n+ print(f\" Processing Resolution: {resolution}\")\r\n+\r\n+ # Reconstruct the image path using fallback logic\r\n+ image_path_str = reconstruct_image_path_with_fallback(\r\n+ asset_dir_path=asset_dir_path,\r\n+ asset_name=asset_name,\r\n+ map_type=map_type,\r\n+ resolution=resolution,\r\n+ primary_format=output_format # Pass the format from metadata\r\n+ )\r\n+\r\n+ if not image_path_str:\r\n+ # Error already printed by reconstruct function\r\n+ errors_encountered += 1\r\n+ continue # Skip this resolution if path not found\r\n+\r\n+ # Find image texture node within the CHILD group (labeled by resolution, e.g., \"4K\")\r\n+ image_nodes = find_nodes_by_label(child_group, resolution, 'ShaderNodeTexImage')\r\n+ if not image_nodes:\r\n+ print(f\" !!! WARNING: No Image Texture node labeled '{resolution}' found in child group '{child_group.name}'. Cannot assign image.\")\r\n+ continue # Skip this resolution if node not found\r\n+\r\n+ # --- Load Image ---\r\n+ img = None\r\n+ image_load_failed = False\r\n+ try:\r\n+ image_path = Path(image_path_str) # Path object created from already found path string\r\n+ # Use check_existing=True to reuse existing datablocks if path matches\r\n+ img = bpy.data.images.load(str(image_path), check_existing=True)\r\n+ if not img:\r\n+ print(f\" !!! ERROR: Failed loading image via bpy.data.images.load: {image_path_str}\")\r\n+ image_load_failed = True\r\n+ else:\r\n+ # Only count as loaded if bpy.data.images.load succeeded\r\n+ # Check if it's newly loaded or reused\r\n+ is_newly_loaded = img.filepath == str(image_path) # Simple check, might not be perfect if paths are identical but data differs\r\n+ if is_newly_loaded: images_loaded += 1\r\n+\r\n+ except RuntimeError as e_runtime_load:\r\n+ # Catch specific Blender runtime errors (e.g., unsupported format)\r\n+ print(f\" !!! ERROR loading image '{image_path_str}': {e_runtime_load}\")\r\n+ image_load_failed = True\r\n+ except Exception as e_gen_load:\r\n+ print(f\" !!! UNEXPECTED ERROR loading image '{image_path_str}': {e_gen_load}\")\r\n+ image_load_failed = True\r\n+ errors_encountered += 1\r\n+\r\n+ # --- Assign Image & Set Color Space ---\r\n+ if not image_load_failed and img:\r\n+ assigned_count_this_res = 0\r\n+ for image_node in image_nodes:\r\n+ if image_node.image != img:\r\n+ image_node.image = img\r\n+ assigned_count_this_res += 1\r\n+\r\n+ if assigned_count_this_res > 0:\r\n+ images_assigned += assigned_count_this_res\r\n+ print(f\" Assigned image '{img.name}' to {assigned_count_this_res} node(s).\")\r\n+\r\n+ # Set Color Space\r\n+ correct_color_space = get_color_space(map_type)\r\n+ try:\r\n+ if img.colorspace_settings.name != correct_color_space:\r\n+ img.colorspace_settings.name = correct_color_space\r\n+ print(f\" Set '{img.name}' color space -> {correct_color_space}\")\r\n+ except TypeError as e_cs: # Handle case where colorspace name is invalid\r\n+ print(f\" !!! WARNING: Could not set color space '{correct_color_space}' for image '{img.name}'. Is the color space available in Blender? Error: {e_cs}\")\r\n+ except Exception as e_cs_gen:\r\n+ print(f\" !!! ERROR setting color space for image '{img.name}': {e_cs_gen}\")\r\n+\r\n+\r\n+ # --- Update Manifest (Map/Resolution Level) ---\r\n+ if update_manifest(manifest_data, asset_name, map_type, resolution):\r\n+ manifest_needs_saving = True\r\n+ # print(f\" Marked {map_type}/{resolution} processed in manifest.\") # Verbose\r\n+ maps_processed += 1\r\n+\r\n+ else:\r\n+ # Increment error count if loading failed\r\n+ if image_load_failed: errors_encountered += 1\r\n+\r\n+ # --- End Resolution Loop ---\r\n+ # --- End Map Type Loop ---\r\n+\r\n+ assets_processed += 1\r\n+\r\n+ except FileNotFoundError:\r\n+ print(f\" !!! ERROR: Metadata file not found (should not happen if scan worked): {metadata_path}\")\r\n+ errors_encountered += 1\r\n+ except json.JSONDecodeError:\r\n+ print(f\" !!! ERROR: Invalid JSON in metadata file: {metadata_path}\")\r\n+ errors_encountered += 1\r\n+ except Exception as e_main_loop:\r\n+ print(f\" !!! UNEXPECTED ERROR processing asset from {metadata_path}: {e_main_loop}\")\r\n+ import traceback\r\n+ traceback.print_exc() # Print detailed traceback for debugging\r\n+ errors_encountered += 1\r\n+ # Continue to the next asset\r\n+\r\n+ # --- End Metadata File Loop ---\r\n+\r\n+ # --- Final Manifest Save ---\r\n+ if ENABLE_MANIFEST and manifest_needs_saving:\r\n+ print(\"\\nAttempting final manifest save...\")\r\n+ save_manifest(context, manifest_data)\r\n+ elif ENABLE_MANIFEST:\r\n+ print(\"\\nManifest is enabled, but no changes require saving.\")\r\n+ # --- End Final Manifest Save ---\r\n+\r\n+ # --- Final Summary ---\r\n+ end_time = time.time()\r\n+ duration = end_time - start_time\r\n+ print(\"\\n--- Script Run Finished ---\")\r\n+ print(f\"Duration: {duration:.2f} seconds\")\r\n+ print(f\"Metadata Files Found: {metadata_files_found}\")\r\n+ print(f\"Assets Processed/Attempted: {assets_processed}\")\r\n+ if ENABLE_MANIFEST:\r\n+ print(f\"Assets Skipped (Manifest): {assets_skipped_manifest}\")\r\n+ print(f\"Maps Skipped (Manifest): {maps_skipped_manifest}\")\r\n+ print(f\"Parent Groups Created: {parent_groups_created}\")\r\n+ print(f\"Parent Groups Updated: {parent_groups_updated}\")\r\n+ print(f\"Child Groups Created: {child_groups_created}\")\r\n+ print(f\"Child Groups Updated: {child_groups_updated}\")\r\n+ print(f\"Images Loaded: {images_loaded}\")\r\n+ print(f\"Image Nodes Assigned: {images_assigned}\")\r\n+ print(f\"Individual Maps Processed: {maps_processed}\")\r\n+ print(f\"Asset Previews Set: {previews_set}\")\r\n+ print(f\"Highest Resolution Nodes Set: {highest_res_set}\")\r\n+ print(f\"Aspect Ratio Nodes Set: {aspect_ratio_set}\") # Added counter\r\n+ if errors_encountered > 0:\r\n+ print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n+ print(\"---------------------------\")\r\n+\r\n+ return True\r\n+\r\n+\r\n+# --- Execution Block ---\r\n+\r\n+if __name__ == \"__main__\":\r\n+ # Ensure we are running within Blender\r\n+ try:\r\n+ import bpy\r\n+ import base64 # Ensure base64 is imported here too if needed globally\r\n+ except ImportError:\r\n+ print(\"!!! ERROR: This script must be run from within Blender. !!!\")\r\n+ else:\r\n+ process_library(bpy.context)\n\\ No newline at end of file\n" }, { "date": 1745234414799, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -1,12 +1,15 @@\n # Blender Script: Create/Update Node Groups from Asset Processor Output\r\n-# Version: 1.4\r\n+# Version: 1.5\r\n # Description: Scans a library processed by the Asset Processor Tool,\r\n # reads metadata.json files, and creates/updates corresponding\r\n # PBR node groups in the active Blender file.\r\n-# Changes v1.4:\r\n-# - Corrected aspect ratio calculation to use actual image dimensions\r\n-# and the aspect_ratio_change_string, mirroring original script logic.\r\n+# Changes v1.5:\r\n+# - Corrected aspect ratio calculation (`calculate_aspect_correction_factor`)\r\n+# to use actual image dimensions from a loaded reference image and the\r\n+# `aspect_ratio_change_string`, mirroring original script logic for\r\n+# \"EVEN\", \"Xnnn\", \"Ynnn\" formats.\r\n+# - Added logic in main loop to load reference image for dimensions.\r\n # Changes v1.3:\r\n # - Added logic to find the highest resolution present for an asset.\r\n # - Added logic to set a \"HighestResolution\" Value node in the parent group\r\n # (maps 1K->1.0, 2K->2.0, 4K->3.0, 8K->4.0).\r\n@@ -43,9 +46,9 @@\n HIGHEST_RESOLUTION_NODE_LABEL = \"HighestResolution\" # Value node to store highest res index\r\n \r\n # Enable/disable the manifest system to track processed assets/maps\r\n # If enabled, requires the blend file to be saved.\r\n-ENABLE_MANIFEST = True\r\n+ENABLE_MANIFEST = False # Disabled based on user feedback in previous run\r\n \r\n # Assumed filename pattern for processed images.\r\n # {asset_name}, {map_type}, {resolution}, {format} will be replaced.\r\n # Check Asset Processor Tool's config.py (TARGET_FILENAME_PATTERN) if this is wrong.\r\n@@ -72,8 +75,9 @@\n \"AO\": \"Non-Color\", # Usually Non-Color, but depends on workflow\r\n \"COL\": \"sRGB\",\r\n \"COL-1\": \"sRGB\", # Handle variants if present in metadata\r\n \"COL-2\": \"sRGB\",\r\n+ \"COL-3\": \"sRGB\",\r\n \"DISP\": \"Non-Color\",\r\n \"NRM\": \"Non-Color\",\r\n \"REFL\": \"Non-Color\", # Reflection/Specular\r\n \"ROUGH\": \"Non-Color\",\r\n@@ -81,880 +85,9 @@\n \"OPC\": \"Non-Color\", # Opacity/Alpha\r\n \"TRN\": \"Non-Color\", # Transmission\r\n \"SSS\": \"sRGB\", # Subsurface Color\r\n \"EMISS\": \"sRGB\", # Emission Color\r\n- # Add other types like GLOSS, HEIGHT, etc. if needed\r\n-}\r\n-DEFAULT_COLOR_SPACE = \"sRGB\" # Fallback if map type not in the dictionary\r\n-\r\n-# Map types for which stats should be applied (if found in metadata and node exists)\r\n-# Reads stats from the 'image_stats_1k' section of metadata.json\r\n-APPLY_STATS_FOR_MAP_TYPES = [\"ROUGH\", \"DISP\", \"METAL\", \"AO\", \"REFL\"] # Add others if needed\r\n-\r\n-# --- END USER CONFIGURATION ---\r\n-\r\n-\r\n-# --- Helper Functions ---\r\n-\r\n-def encode_name_b64(name_str):\r\n- \"\"\"Encodes a string using URL-safe Base64 for node group names.\"\"\"\r\n- try:\r\n- # Ensure the input is a string\r\n- name_str = str(name_str)\r\n- return base64.urlsafe_b64encode(name_str.encode('utf-8')).decode('ascii')\r\n- except Exception as e:\r\n- print(f\" Error base64 encoding '{name_str}': {e}\")\r\n- return current_aspect_ratio\r\n-\r\n- # Handle non-uniform scaling cases (\"Xnnn\", \"Ynnn\")\r\n- match = re.match(r\"([XY])(\\d+)\", aspect_string, re.IGNORECASE)\r\n- if not match:\r\n- print(f\" Warn: Invalid Scaling string format '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f} as fallback.\")\r\n- return current_aspect_ratio # Fallback to the image's own ratio\r\n-\r\n- axis = match.group(1).upper()\r\n- try:\r\n- amount = int(match.group(2))\r\n- if amount <= 0:\r\n- print(f\" Warn: Zero or negative Amount in Scaling string '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n- return current_aspect_ratio\r\n- except ValueError:\r\n- print(f\" Warn: Invalid Amount in Scaling string '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n- return current_aspect_ratio\r\n-\r\n- # Apply the non-uniform correction formula based on original script logic\r\n- scaling_factor_percent = amount / 100.0\r\n- correction_factor = current_aspect_ratio # Default\r\n-\r\n- try:\r\n- if axis == 'X':\r\n- if scaling_factor_percent == 0: raise ZeroDivisionError(\"X scaling factor is zero\")\r\n- # If image was stretched horizontally (X > 1), divide UV.x by factor\r\n- correction_factor = current_aspect_ratio / scaling_factor_percent\r\n- elif axis == 'Y':\r\n- # If image was stretched vertically (Y > 1), multiply UV.x by factor\r\n- correction_factor = current_aspect_ratio * scaling_factor_percent\r\n- # No 'else' needed due to regex structure\r\n-\r\n- except ZeroDivisionError as e:\r\n- print(f\" Warn: Division by zero during aspect factor calculation ({e}). Returning current ratio {current_aspect_ratio:.4f}.\")\r\n- return current_aspect_ratio\r\n- except Exception as e:\r\n- print(f\" Error calculating aspect correction factor: {e}. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n- return current_aspect_ratio\r\n-\r\n- # print(f\" Calculated aspect correction factor: {correction_factor:.4f} (from {image_width}x{image_height}, Scaling='{aspect_string}')\")\r\n- return correction_factor\r\n-\r\n-\r\n-def reconstruct_image_path_with_fallback(asset_dir_path, asset_name, map_type, resolution, primary_format):\r\n- \"\"\"\r\n- Constructs the expected image file path, trying the primary format first,\r\n- then falling back to common extensions if the primary path doesn't exist.\r\n- Returns the found path as a string, or None if not found.\r\n- \"\"\"\r\n- if not all([asset_dir_path, asset_name, map_type, resolution, primary_format]):\r\n- print(f\" !!! ERROR: Missing data for path reconstruction ({asset_name}/{map_type}/{resolution}).\")\r\n- return None\r\n-\r\n- # 1. Try the primary format from metadata\r\n- primary_path_str = None\r\n- try:\r\n- filename = IMAGE_FILENAME_PATTERN.format(\r\n- asset_name=asset_name,\r\n- map_type=map_type,\r\n- resolution=resolution,\r\n- format=primary_format.lower() # Ensure format is lowercase\r\n- )\r\n- primary_path = asset_dir_path / filename\r\n- primary_path_str = str(primary_path)\r\n- if primary_path.is_file():\r\n- # print(f\" Found primary path: {primary_path_str}\") # Verbose\r\n- return primary_path_str\r\n- # else: print(f\" Primary path not found: {primary_path_str}\") # Verbose\r\n- except KeyError as e:\r\n- print(f\" !!! ERROR: Missing key '{e}' in IMAGE_FILENAME_PATTERN. Cannot reconstruct path.\")\r\n- return None # Cannot proceed without valid pattern\r\n- except Exception as e:\r\n- print(f\" !!! ERROR reconstructing primary image path: {e}\")\r\n- # Continue to fallback even if primary reconstruction had issues\r\n-\r\n- # 2. Try fallback extensions if primary failed\r\n- # print(f\" Trying fallback extensions for {map_type}/{resolution}...\") # Verbose\r\n- for ext in FALLBACK_IMAGE_EXTENSIONS:\r\n- # Ensure ext is treated as string and handle potential None values defensively\r\n- if not isinstance(ext, str) or not isinstance(primary_format, str) or ext.lower() == primary_format.lower():\r\n- continue\r\n- try:\r\n- fallback_filename = IMAGE_FILENAME_PATTERN.format(\r\n- asset_name=asset_name,\r\n- map_type=map_type,\r\n- resolution=resolution,\r\n- format=ext.lower()\r\n- )\r\n- fallback_path = asset_dir_path / fallback_filename\r\n- if fallback_path.is_file():\r\n- print(f\" Found fallback path: {str(fallback_path)}\")\r\n- return str(fallback_path)\r\n- except KeyError:\r\n- # Should not happen if primary format worked, but handle defensively\r\n- print(f\" !!! ERROR: Missing key in IMAGE_FILENAME_PATTERN during fallback. Cannot reconstruct path.\")\r\n- return None\r\n- except Exception as e_fallback:\r\n- print(f\" !!! ERROR reconstructing fallback image path ({ext}): {e_fallback}\")\r\n- continue # Try next extension\r\n-\r\n- print(f\" !!! ERROR: Could not find image file for {map_type}/{resolution} using primary format '{primary_format}' or fallbacks {FALLBACK_IMAGE_EXTENSIONS}.\")\r\n- return None # Not found after all checks\r\n-\r\n-\r\n-# --- Manifest Functions ---\r\n-\r\n-def get_manifest_path(context):\r\n- \"\"\"Gets the expected path for the manifest JSON file.\"\"\"\r\n- if not context or not context.blend_data or not context.blend_data.filepath:\r\n- return None # Cannot determine path if blend file is not saved\r\n- blend_path = Path(context.blend_data.filepath)\r\n- manifest_filename = f\"{blend_path.stem}_manifest.json\"\r\n- return blend_path.parent / manifest_filename\r\n-\r\n-def load_manifest(context):\r\n- \"\"\"Loads the manifest data from the JSON file.\"\"\"\r\n- if not ENABLE_MANIFEST:\r\n- return {} # Manifest disabled\r\n-\r\n- manifest_path = get_manifest_path(context)\r\n- if not manifest_path:\r\n- print(\" Manifest Info: Blend file not saved. Cannot load manifest.\")\r\n- return {} # Cannot load without a path\r\n-\r\n- if not manifest_path.exists():\r\n- print(f\" Manifest Info: No manifest file found at '{manifest_path.name}'. Starting fresh.\")\r\n- return {} # No manifest file exists yet\r\n-\r\n- try:\r\n- with open(manifest_path, 'r', encoding='utf-8') as f:\r\n- data = json.load(f)\r\n- print(f\" Manifest Loaded from: {manifest_path.name}\")\r\n- # Basic validation (check if it's a dictionary)\r\n- if not isinstance(data, dict):\r\n- print(f\"!!! WARNING: Manifest file '{manifest_path.name}' has invalid format (not a dictionary). Starting fresh. !!!\")\r\n- return {}\r\n- return data\r\n- except json.JSONDecodeError:\r\n- print(f\"!!! WARNING: Manifest file '{manifest_path.name}' is corrupted. Starting fresh. !!!\")\r\n- return {}\r\n- except Exception as e:\r\n- print(f\"!!! ERROR: Could not load manifest file '{manifest_path.name}': {e} !!!\")\r\n- return {} # Treat as starting fresh on error\r\n-\r\n-def save_manifest(context, manifest_data):\r\n- \"\"\"Saves the manifest data to the JSON file.\"\"\"\r\n- if not ENABLE_MANIFEST or not manifest_data: # Don't save if disabled or empty\r\n- return False\r\n-\r\n- manifest_path = get_manifest_path(context)\r\n- if not manifest_path:\r\n- print(\" Manifest Error: Blend file not saved. Cannot save manifest.\")\r\n- return False\r\n-\r\n- try:\r\n- with open(manifest_path, 'w', encoding='utf-8') as f:\r\n- json.dump(manifest_data, f, indent=2, sort_keys=True) # Use indent and sort for readability\r\n- print(f\" Manifest Saved to: {manifest_path.name}\")\r\n- return True\r\n- except Exception as e:\r\n- print(f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\\n\"\r\n- f\"!!! Manifest save FAILED to '{manifest_path.name}': {e} !!!\\n\"\r\n- f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\")\r\n- return False\r\n-\r\n-def is_asset_processed(manifest_data, asset_name):\r\n- \"\"\"Checks if the entire asset (all its maps/resolutions) is marked as processed.\"\"\"\r\n- if not ENABLE_MANIFEST: return False\r\n- # Basic check if asset entry exists. Detailed check happens at map level.\r\n- return asset_name in manifest_data\r\n-\r\n-def is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- \"\"\"Checks if a specific map type and resolution for an asset is processed.\"\"\"\r\n- if not ENABLE_MANIFEST: return False\r\n- return resolution in manifest_data.get(asset_name, {}).get(map_type, [])\r\n-\r\n-def update_manifest(manifest_data, asset_name, map_type=None, resolution=None):\r\n- \"\"\"Updates the manifest dictionary in memory.\"\"\"\r\n- if not ENABLE_MANIFEST: return False\r\n-\r\n- # Ensure asset entry exists\r\n- if asset_name not in manifest_data:\r\n- manifest_data[asset_name] = {}\r\n-\r\n- # If map_type and resolution are provided, update the specific map entry\r\n- if map_type and resolution:\r\n- if map_type not in manifest_data[asset_name]:\r\n- manifest_data[asset_name][map_type] = []\r\n-\r\n- if resolution not in manifest_data[asset_name][map_type]:\r\n- manifest_data[asset_name][map_type].append(resolution)\r\n- manifest_data[asset_name][map_type].sort() # Keep sorted\r\n- return True # Indicate that a change was made\r\n- return False # No change made to this specific map/res\r\n-\r\n-\r\n-# --- Core Logic ---\r\n-\r\n-def process_library(context):\r\n- global ENABLE_MANIFEST # Declare intent to modify global if needed\r\n- \"\"\"Scans the library, reads metadata, creates/updates node groups.\"\"\"\r\n- start_time = time.time()\r\n- print(f\"\\n--- Starting Node Group Processing ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---\")\r\n-\r\n- # --- Pre-run Checks ---\r\n- print(\"Performing pre-run checks...\")\r\n- valid_setup = True\r\n- # 1. Check Library Root Path\r\n- root_path = Path(PROCESSED_ASSET_LIBRARY_ROOT)\r\n- if not root_path.is_dir():\r\n- print(f\"!!! ERROR: Processed asset library root directory not found or not a directory:\")\r\n- print(f\"!!! '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n- valid_setup = False\r\n- else:\r\n- print(f\" Asset Library Root: '{root_path}'\")\r\n-\r\n- # 2. Check Templates\r\n- template_parent = bpy.data.node_groups.get(PARENT_TEMPLATE_NAME)\r\n- template_child = bpy.data.node_groups.get(CHILD_TEMPLATE_NAME)\r\n- if not template_parent:\r\n- print(f\"!!! ERROR: Parent template node group '{PARENT_TEMPLATE_NAME}' not found in this Blender file.\")\r\n- valid_setup = False\r\n- if not template_child:\r\n- print(f\"!!! ERROR: Child template node group '{CHILD_TEMPLATE_NAME}' not found in this Blender file.\")\r\n- valid_setup = False\r\n- if template_parent and template_child:\r\n- print(f\" Found Templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\")\r\n-\r\n- # 3. Check Blend File Saved (if manifest enabled)\r\n- if ENABLE_MANIFEST and not context.blend_data.filepath:\r\n- print(f\"!!! WARNING: Manifest is enabled, but the current Blender file is not saved.\")\r\n- print(f\"!!! Manifest cannot be loaded or saved. Processing will continue without manifest checks.\")\r\n- ENABLE_MANIFEST = False # Disable manifest for this run\r\n-\r\n- if not valid_setup:\r\n- print(\"\\n--- Script aborted due to configuration errors. Please fix the issues above. ---\")\r\n- return False\r\n- print(\"Pre-run checks passed.\")\r\n- # --- End Pre-run Checks ---\r\n-\r\n- manifest_data = load_manifest(context)\r\n- manifest_needs_saving = False\r\n-\r\n- # --- Initialize Counters ---\r\n- metadata_files_found = 0\r\n- assets_processed = 0\r\n- assets_skipped_manifest = 0\r\n- parent_groups_created = 0\r\n- parent_groups_updated = 0\r\n- child_groups_created = 0\r\n- child_groups_updated = 0\r\n- images_loaded = 0\r\n- images_assigned = 0\r\n- maps_processed = 0\r\n- maps_skipped_manifest = 0\r\n- errors_encountered = 0\r\n- previews_set = 0\r\n- highest_res_set = 0\r\n- aspect_ratio_set = 0\r\n- # --- End Counters ---\r\n-\r\n- print(f\"\\nScanning for metadata files in '{root_path}'...\")\r\n-\r\n- # --- Scan for metadata.json ---\r\n- # Scan one level deeper for supplier folders (e.g., Poliigon)\r\n- # Then scan within each supplier for asset folders containing metadata.json\r\n- metadata_paths = []\r\n- for supplier_dir in root_path.iterdir():\r\n- if supplier_dir.is_dir():\r\n- # Now look for asset folders inside the supplier directory\r\n- for asset_dir in supplier_dir.iterdir():\r\n- if asset_dir.is_dir():\r\n- metadata_file = asset_dir / 'metadata.json'\r\n- if metadata_file.is_file():\r\n- metadata_paths.append(metadata_file)\r\n-\r\n- metadata_files_found = len(metadata_paths)\r\n- print(f\"Found {metadata_files_found} metadata.json files.\")\r\n-\r\n- if metadata_files_found == 0:\r\n- print(\"No metadata files found. Nothing to process.\")\r\n- print(\"--- Script Finished ---\")\r\n- return True # No work needed is considered success\r\n-\r\n- # --- Process Each Metadata File ---\r\n- for metadata_path in metadata_paths:\r\n- asset_dir_path = metadata_path.parent # Get the directory containing the metadata file\r\n- print(f\"\\n--- Processing Metadata: {metadata_path.relative_to(root_path)} ---\")\r\n- try:\r\n- with open(metadata_path, 'r', encoding='utf-8') as f:\r\n- metadata = json.load(f)\r\n-\r\n- # --- Extract Key Info ---\r\n- asset_name = metadata.get(\"asset_name\")\r\n- supplier_name = metadata.get(\"supplier_name\")\r\n- archetype = metadata.get(\"archetype\")\r\n- # Get map info from the correct keys\r\n- processed_resolutions = metadata.get(\"processed_map_resolutions\") # Dict: {map_type: [res1, res2]}\r\n- map_details = metadata.get(\"map_details\") # Dict: {map_type: {details}}\r\n- image_stats_1k = metadata.get(\"image_stats_1k\") # Dict: {map_type: {stats}}\r\n- aspect_string = metadata.get(\"aspect_ratio_change_string\")\r\n-\r\n- # Validate essential data\r\n- if not asset_name:\r\n- print(f\" !!! ERROR: Metadata file is missing 'asset_name'. Skipping.\")\r\n- errors_encountered += 1\r\n- continue\r\n- if not processed_resolutions or not isinstance(processed_resolutions, dict):\r\n- print(f\" !!! ERROR: Metadata file is missing or has invalid 'processed_map_resolutions'. Skipping asset '{asset_name}'.\")\r\n- errors_encountered += 1\r\n- continue\r\n- if not map_details or not isinstance(map_details, dict):\r\n- print(f\" !!! WARNING: Metadata file is missing or has invalid 'map_details'. Path reconstruction might fail for asset '{asset_name}'.\")\r\n- # Continue processing, but path reconstruction might fail later\r\n-\r\n- print(f\" Asset Name: {asset_name}\")\r\n-\r\n- # --- Determine Highest Resolution ---\r\n- highest_resolution_value = 0.0\r\n- highest_resolution_str = \"Unknown\"\r\n- all_resolutions_present = set()\r\n- if processed_resolutions: # Check if the dict exists and is not empty\r\n- for res_list in processed_resolutions.values():\r\n- if isinstance(res_list, list):\r\n- all_resolutions_present.update(res_list)\r\n-\r\n- if all_resolutions_present:\r\n- for res_str in RESOLUTION_ORDER_DESC:\r\n- if res_str in all_resolutions_present:\r\n- highest_resolution_value = RESOLUTION_VALUE_MAP.get(res_str, 0.0)\r\n- highest_resolution_str = res_str\r\n- if highest_resolution_value > 0.0:\r\n- break # Found the highest valid resolution\r\n-\r\n- print(f\" Highest resolution found: {highest_resolution_str} (Value: {highest_resolution_value})\")\r\n-\r\n- # --- Load Reference Image for Aspect Ratio ---\r\n- ref_image_path = None\r\n- ref_image_width = 0\r\n- ref_image_height = 0\r\n- ref_image_loaded = False\r\n- for ref_map_type in REFERENCE_MAP_TYPES:\r\n- if ref_map_type in processed_resolutions:\r\n- available_resolutions = processed_resolutions[ref_map_type]\r\n- lowest_res = None\r\n- for res_pref in REFERENCE_RESOLUTION_ORDER:\r\n- if res_pref in available_resolutions:\r\n- lowest_res = res_pref\r\n- break\r\n- if lowest_res:\r\n- ref_map_details = map_details.get(ref_map_type, {})\r\n- ref_format = ref_map_details.get(\"output_format\")\r\n- if ref_format:\r\n- ref_image_path = reconstruct_image_path_with_fallback(\r\n- asset_dir_path=asset_dir_path,\r\n- asset_name=asset_name,\r\n- map_type=ref_map_type,\r\n- resolution=lowest_res,\r\n- primary_format=ref_format\r\n- )\r\n- if ref_image_path:\r\n- break # Found a suitable reference image path\r\n-\r\n- if ref_image_path:\r\n- print(f\" Loading reference image for aspect ratio: {Path(ref_image_path).name}\")\r\n- try:\r\n- ref_img = bpy.data.images.load(ref_image_path, check_existing=True)\r\n- if ref_img:\r\n- ref_image_width = ref_img.size[0]\r\n- ref_image_height = ref_img.size[1]\r\n- ref_image_loaded = True\r\n- print(f\" Reference image dimensions: {ref_image_width}x{ref_image_height}\")\r\n- # We don't need to keep the image data block loaded after getting dimensions\r\n- bpy.data.images.remove(ref_img)\r\n- else:\r\n- print(f\" !!! ERROR: Failed loading reference image via bpy.data.images.load: {ref_image_path}\")\r\n- except Exception as e_ref_load:\r\n- print(f\" !!! ERROR loading reference image '{ref_image_path}': {e_ref_load}\")\r\n- else:\r\n- print(f\" !!! WARNING: Could not find suitable reference image ({REFERENCE_MAP_TYPES} at {REFERENCE_RESOLUTION_ORDER}) for aspect ratio calculation.\")\r\n-\r\n-\r\n- # --- Manifest Check (Asset Level - Basic) ---\r\n- if ENABLE_MANIFEST and is_asset_processed(manifest_data, asset_name):\r\n- # Perform a quick check if *any* map needs processing for this asset\r\n- needs_processing = False\r\n- for map_type, resolutions in processed_resolutions.items():\r\n- for resolution in resolutions:\r\n- if not is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- needs_processing = True\r\n- break\r\n- if needs_processing:\r\n- break\r\n- if not needs_processing:\r\n- print(f\" Skipping asset '{asset_name}' (already fully processed according to manifest).\")\r\n- assets_skipped_manifest += 1\r\n- continue # Skip to next metadata file\r\n-\r\n- # --- Parent Group Handling ---\r\n- target_parent_name = f\"PBRSET_{asset_name}\"\r\n- parent_group = bpy.data.node_groups.get(target_parent_name)\r\n- is_new_parent = False\r\n-\r\n- if parent_group is None:\r\n- print(f\" Creating new parent group: '{target_parent_name}'\")\r\n- parent_group = template_parent.copy()\r\n- if not parent_group:\r\n- print(f\" !!! ERROR: Failed to copy parent template '{PARENT_TEMPLATE_NAME}'. Skipping asset '{asset_name}'.\")\r\n- errors_encountered += 1\r\n- continue\r\n- parent_group.name = target_parent_name\r\n- parent_groups_created += 1\r\n- is_new_parent = True\r\n- else:\r\n- print(f\" Updating existing parent group: '{target_parent_name}'\")\r\n- parent_groups_updated += 1\r\n-\r\n- # Ensure marked as asset\r\n- if not parent_group.asset_data:\r\n- try:\r\n- parent_group.asset_mark()\r\n- print(f\" Marked '{parent_group.name}' as asset.\")\r\n- except Exception as e_mark:\r\n- print(f\" !!! ERROR: Failed to mark group '{parent_group.name}' as asset: {e_mark}\")\r\n- # Continue processing other parts if possible\r\n-\r\n- # Apply Asset Tags\r\n- if parent_group.asset_data:\r\n- if supplier_name:\r\n- add_tag_if_new(parent_group.asset_data, supplier_name)\r\n- if archetype:\r\n- add_tag_if_new(parent_group.asset_data, archetype)\r\n- # Add other tags if needed\r\n- # else: print(f\" Warn: Cannot apply tags, asset_data not available for '{parent_group.name}'.\") # Optional warning\r\n-\r\n- # Apply Aspect Ratio Correction\r\n- aspect_nodes = find_nodes_by_label(parent_group, ASPECT_RATIO_NODE_LABEL, 'ShaderNodeValue')\r\n- if aspect_nodes:\r\n- aspect_node = aspect_nodes[0]\r\n- correction_factor = 1.0 # Default if ref image fails\r\n- if ref_image_loaded:\r\n- correction_factor = calculate_aspect_correction_factor(ref_image_width, ref_image_height, aspect_string)\r\n- print(f\" Calculated aspect correction factor: {correction_factor:.4f}\")\r\n- else:\r\n- print(f\" !!! WARNING: Using default aspect ratio correction (1.0) due to missing reference image.\")\r\n-\r\n- # Check if update is needed\r\n- if abs(aspect_node.outputs[0].default_value - correction_factor) > 0.0001:\r\n- aspect_node.outputs[0].default_value = correction_factor\r\n- print(f\" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f}\")\r\n- aspect_ratio_set += 1\r\n- # else: print(f\" Warn: Aspect ratio node '{ASPECT_RATIO_NODE_LABEL}' not found in parent group.\") # Optional\r\n-\r\n- # Apply Highest Resolution Value\r\n- hr_nodes = find_nodes_by_label(parent_group, HIGHEST_RESOLUTION_NODE_LABEL, 'ShaderNodeValue')\r\n- if hr_nodes:\r\n- hr_node = hr_nodes[0]\r\n- if highest_resolution_value > 0.0 and abs(hr_node.outputs[0].default_value - highest_resolution_value) > 0.001:\r\n- hr_node.outputs[0].default_value = highest_resolution_value\r\n- print(f\" Set '{HIGHEST_RESOLUTION_NODE_LABEL}' value to {highest_resolution_value} ({highest_resolution_str})\")\r\n- highest_res_set += 1 # Count successful sets\r\n- # else: print(f\" Warn: Highest resolution node '{HIGHEST_RESOLUTION_NODE_LABEL}' not found in parent group.\") # Optional\r\n-\r\n-\r\n- # Apply Stats (using image_stats_1k)\r\n- if image_stats_1k and isinstance(image_stats_1k, dict):\r\n- for map_type_to_stat in APPLY_STATS_FOR_MAP_TYPES:\r\n- if map_type_to_stat in image_stats_1k:\r\n- # Find the stats node in the parent group\r\n- stats_node_label = f\"{STATS_NODE_PREFIX}{map_type_to_stat}\"\r\n- stats_nodes = find_nodes_by_label(parent_group, stats_node_label, 'ShaderNodeCombineXYZ')\r\n- if stats_nodes:\r\n- stats_node = stats_nodes[0]\r\n- stats = image_stats_1k[map_type_to_stat] # Get stats dict for this map type\r\n-\r\n- if stats and isinstance(stats, dict):\r\n- # Handle potential list format for RGB stats (use first value) or direct float\r\n- def get_stat_value(stat_val):\r\n- if isinstance(stat_val, list):\r\n- return stat_val[0] if stat_val else None\r\n- return stat_val\r\n-\r\n- min_val = get_stat_value(stats.get(\"min\"))\r\n- max_val = get_stat_value(stats.get(\"max\"))\r\n- mean_val = get_stat_value(stats.get(\"mean\")) # Often stored as 'mean' or 'avg'\r\n-\r\n- updated_stat = False\r\n- # Check inputs exist before assigning\r\n- if stats_node.inputs.get(\"X\") and min_val is not None and abs(stats_node.inputs[\"X\"].default_value - min_val) > 0.0001:\r\n- stats_node.inputs[\"X\"].default_value = min_val\r\n- updated_stat = True\r\n- if stats_node.inputs.get(\"Y\") and max_val is not None and abs(stats_node.inputs[\"Y\"].default_value - max_val) > 0.0001:\r\n- stats_node.inputs[\"Y\"].default_value = max_val\r\n- updated_stat = True\r\n- if stats_node.inputs.get(\"Z\") and mean_val is not None and abs(stats_node.inputs[\"Z\"].default_value - mean_val) > 0.0001:\r\n- stats_node.inputs[\"Z\"].default_value = mean_val\r\n- updated_stat = True\r\n-\r\n- if updated_stat:\r\n- print(f\" Set stats in '{stats_node_label}': Min={min_val:.4f}, Max={max_val:.4f}, Mean={mean_val:.4f}\")\r\n- # else: print(f\" Info: No valid 'stats' dictionary found for map type '{map_type_to_stat}' in image_stats_1k.\") # Optional\r\n- # else: print(f\" Warn: Stats node '{stats_node_label}' not found in parent group.\") # Optional\r\n- # else: print(f\" Info: Map type '{map_type_to_stat}' not present in image_stats_1k for stats application.\") # Optional\r\n- # else: print(f\" Warn: 'image_stats_1k' missing or invalid in metadata.\") # Optional\r\n-\r\n- # --- Set Asset Preview (only for new parent groups) ---\r\n- # Use the reference image path found earlier if available\r\n- if is_new_parent and parent_group.asset_data:\r\n- if ref_image_loaded and ref_image_path: # Check if ref image was successfully loaded earlier\r\n- print(f\" Attempting to set preview from reference image: {Path(ref_image_path).name}\")\r\n- try:\r\n- # Ensure the ID (node group) is the active one for the operator context\r\n- with context.temp_override(id=parent_group):\r\n- bpy.ops.ed.lib_id_load_custom_preview(filepath=ref_image_path)\r\n- print(f\" Successfully set custom preview.\")\r\n- previews_set += 1\r\n- except Exception as e_preview:\r\n- print(f\" !!! ERROR setting custom preview: {e_preview}\")\r\n- errors_encountered += 1\r\n- else:\r\n- print(f\" Info: Could not set preview for '{asset_name}' as reference image was not found or loaded.\")\r\n-\r\n-\r\n- # --- Child Group Handling ---\r\n- # Iterate through the map types listed in processed_resolutions\r\n- for map_type, resolutions in processed_resolutions.items():\r\n- print(f\" Processing Map Type: {map_type}\")\r\n-\r\n- # Get details for this map type (needed for format)\r\n- current_map_details = map_details.get(map_type, {})\r\n- output_format = current_map_details.get(\"output_format\")\r\n- if not output_format:\r\n- print(f\" !!! WARNING: Missing 'output_format' in map_details for '{map_type}'. Cannot reconstruct path. Skipping map type.\")\r\n- continue\r\n-\r\n- # Find placeholder node in parent\r\n- holder_nodes = find_nodes_by_label(parent_group, map_type, 'ShaderNodeGroup')\r\n- if not holder_nodes:\r\n- print(f\" !!! WARNING: No placeholder node labeled '{map_type}' found in parent group '{parent_group.name}'. Skipping this map type.\")\r\n- continue\r\n- holder_node = holder_nodes[0] # Assume first is correct\r\n-\r\n- # Determine child group name (LOGICAL and ENCODED)\r\n- logical_child_name = f\"{asset_name}_{map_type}\"\r\n- target_child_name_b64 = encode_name_b64(logical_child_name) # Use Base64 name\r\n-\r\n- child_group = bpy.data.node_groups.get(target_child_name_b64) # Find using encoded name\r\n- is_new_child = False\r\n-\r\n- if child_group is None:\r\n- # print(f\" Creating new child group: '{target_child_name_b64}' (logical: '{logical_child_name}')\") # Verbose\r\n- child_group = template_child.copy()\r\n- if not child_group:\r\n- print(f\" !!! ERROR: Failed to copy child template '{CHILD_TEMPLATE_NAME}'. Skipping map type '{map_type}'.\")\r\n- errors_encountered += 1\r\n- continue\r\n- child_group.name = target_child_name_b64 # Set encoded name\r\n- child_groups_created += 1\r\n- is_new_child = True\r\n- else:\r\n- # print(f\" Updating existing child group: '{target_child_name_b64}'\") # Verbose\r\n- child_groups_updated += 1\r\n-\r\n- # Assign child group to placeholder if needed\r\n- if holder_node.node_tree != child_group:\r\n- try:\r\n- holder_node.node_tree = child_group\r\n- print(f\" Assigned child group '{child_group.name}' to placeholder '{holder_node.label}'.\")\r\n- except TypeError as e_assign: # Catch potential type errors if placeholder isn't a group node\r\n- print(f\" !!! ERROR: Could not assign child group to placeholder '{holder_node.label}'. Is it a Group Node? Error: {e_assign}\")\r\n- continue # Skip this map type if assignment fails\r\n-\r\n- # Link placeholder output to parent output socket\r\n- try:\r\n- # Find parent's output node\r\n- group_output_node = next((n for n in parent_group.nodes if n.type == 'GROUP_OUTPUT'), None)\r\n- if group_output_node:\r\n- # Get the specific output socket on the placeholder (usually index 0 or named 'Color'/'Value')\r\n- source_socket = holder_node.outputs.get(\"Color\") or holder_node.outputs.get(\"Value\") or holder_node.outputs[0]\r\n- # Get the specific input socket on the parent output node (matching map_type)\r\n- target_socket = group_output_node.inputs.get(map_type)\r\n-\r\n- if source_socket and target_socket:\r\n- # Check if link already exists\r\n- link_exists = any(link.from_socket == source_socket and link.to_socket == target_socket for link in parent_group.links)\r\n- if not link_exists:\r\n- parent_group.links.new(source_socket, target_socket)\r\n- print(f\" Linked '{holder_node.label}' output to parent output socket '{map_type}'.\")\r\n- # else: # Optional warnings\r\n- # if not source_socket: print(f\" Warn: Could not find suitable output socket on placeholder '{holder_node.label}'.\")\r\n- # if not target_socket: print(f\" Warn: Could not find input socket '{map_type}' on parent output node.\")\r\n- # else: print(f\" Warn: Parent group '{parent_group.name}' has no Group Output node.\")\r\n-\r\n- except Exception as e_link:\r\n- print(f\" !!! ERROR linking sockets for '{map_type}': {e_link}\")\r\n-\r\n- # Ensure parent output socket type is Color (if it exists)\r\n- try:\r\n- # Use the interface API for modern Blender versions\r\n- item = parent_group.interface.items_tree.get(map_type)\r\n- if item and item.item_type == 'SOCKET' and item.in_out == 'OUTPUT':\r\n- # Common useful types: 'NodeSocketColor', 'NodeSocketVector', 'NodeSocketFloat'\r\n- # Defaulting to Color seems reasonable for most PBR outputs\r\n- if item.socket_type != 'NodeSocketColor':\r\n- item.socket_type = 'NodeSocketColor'\r\n- # print(f\" Set parent output socket '{map_type}' type to Color.\") # Optional info\r\n- except Exception as e_sock_type:\r\n- print(f\" Warn: Could not verify/set socket type for '{map_type}': {e_sock_type}\")\r\n-\r\n-\r\n- # --- Image Node Handling (Inside Child Group) ---\r\n- if not isinstance(resolutions, list):\r\n- print(f\" !!! ERROR: Invalid format for resolutions list for map type '{map_type}'. Skipping.\")\r\n- continue\r\n-\r\n- for resolution in resolutions:\r\n- # --- Manifest Check (Map/Resolution Level) ---\r\n- if ENABLE_MANIFEST and is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- # print(f\" Skipping {resolution} (Manifest)\") # Verbose\r\n- maps_skipped_manifest += 1\r\n- continue\r\n-\r\n- print(f\" Processing Resolution: {resolution}\")\r\n-\r\n- # Reconstruct the image path using fallback logic\r\n- image_path_str = reconstruct_image_path_with_fallback(\r\n- asset_dir_path=asset_dir_path,\r\n- asset_name=asset_name,\r\n- map_type=map_type,\r\n- resolution=resolution,\r\n- primary_format=output_format # Pass the format from metadata\r\n- )\r\n-\r\n- if not image_path_str:\r\n- # Error already printed by reconstruct function\r\n- errors_encountered += 1\r\n- continue # Skip this resolution if path not found\r\n-\r\n- # Find image texture node within the CHILD group (labeled by resolution, e.g., \"4K\")\r\n- image_nodes = find_nodes_by_label(child_group, resolution, 'ShaderNodeTexImage')\r\n- if not image_nodes:\r\n- print(f\" !!! WARNING: No Image Texture node labeled '{resolution}' found in child group '{child_group.name}'. Cannot assign image.\")\r\n- continue # Skip this resolution if node not found\r\n-\r\n- # --- Load Image ---\r\n- img = None\r\n- image_load_failed = False\r\n- try:\r\n- image_path = Path(image_path_str) # Path object created from already found path string\r\n- # Use check_existing=True to reuse existing datablocks if path matches\r\n- img = bpy.data.images.load(str(image_path), check_existing=True)\r\n- if not img:\r\n- print(f\" !!! ERROR: Failed loading image via bpy.data.images.load: {image_path_str}\")\r\n- image_load_failed = True\r\n- else:\r\n- # Only count as loaded if bpy.data.images.load succeeded\r\n- # Check if it's newly loaded or reused\r\n- is_newly_loaded = img.filepath == str(image_path) # Simple check, might not be perfect if paths are identical but data differs\r\n- if is_newly_loaded: images_loaded += 1\r\n-\r\n- except RuntimeError as e_runtime_load:\r\n- # Catch specific Blender runtime errors (e.g., unsupported format)\r\n- print(f\" !!! ERROR loading image '{image_path_str}': {e_runtime_load}\")\r\n- image_load_failed = True\r\n- except Exception as e_gen_load:\r\n- print(f\" !!! UNEXPECTED ERROR loading image '{image_path_str}': {e_gen_load}\")\r\n- image_load_failed = True\r\n- errors_encountered += 1\r\n-\r\n- # --- Assign Image & Set Color Space ---\r\n- if not image_load_failed and img:\r\n- assigned_count_this_res = 0\r\n- for image_node in image_nodes:\r\n- if image_node.image != img:\r\n- image_node.image = img\r\n- assigned_count_this_res += 1\r\n-\r\n- if assigned_count_this_res > 0:\r\n- images_assigned += assigned_count_this_res\r\n- print(f\" Assigned image '{img.name}' to {assigned_count_this_res} node(s).\")\r\n-\r\n- # Set Color Space\r\n- correct_color_space = get_color_space(map_type)\r\n- try:\r\n- if img.colorspace_settings.name != correct_color_space:\r\n- img.colorspace_settings.name = correct_color_space\r\n- print(f\" Set '{img.name}' color space -> {correct_color_space}\")\r\n- except TypeError as e_cs: # Handle case where colorspace name is invalid\r\n- print(f\" !!! WARNING: Could not set color space '{correct_color_space}' for image '{img.name}'. Is the color space available in Blender? Error: {e_cs}\")\r\n- except Exception as e_cs_gen:\r\n- print(f\" !!! ERROR setting color space for image '{img.name}': {e_cs_gen}\")\r\n-\r\n-\r\n- # --- Update Manifest (Map/Resolution Level) ---\r\n- if update_manifest(manifest_data, asset_name, map_type, resolution):\r\n- manifest_needs_saving = True\r\n- # print(f\" Marked {map_type}/{resolution} processed in manifest.\") # Verbose\r\n- maps_processed += 1\r\n-\r\n- else:\r\n- # Increment error count if loading failed\r\n- if image_load_failed: errors_encountered += 1\r\n-\r\n- # --- End Resolution Loop ---\r\n- # --- End Map Type Loop ---\r\n-\r\n- assets_processed += 1\r\n-\r\n- except FileNotFoundError:\r\n- print(f\" !!! ERROR: Metadata file not found (should not happen if scan worked): {metadata_path}\")\r\n- errors_encountered += 1\r\n- except json.JSONDecodeError:\r\n- print(f\" !!! ERROR: Invalid JSON in metadata file: {metadata_path}\")\r\n- errors_encountered += 1\r\n- except Exception as e_main_loop:\r\n- print(f\" !!! UNEXPECTED ERROR processing asset from {metadata_path}: {e_main_loop}\")\r\n- import traceback\r\n- traceback.print_exc() # Print detailed traceback for debugging\r\n- errors_encountered += 1\r\n- # Continue to the next asset\r\n-\r\n- # --- End Metadata File Loop ---\r\n-\r\n- # --- Final Manifest Save ---\r\n- if ENABLE_MANIFEST and manifest_needs_saving:\r\n- print(\"\\nAttempting final manifest save...\")\r\n- save_manifest(context, manifest_data)\r\n- elif ENABLE_MANIFEST:\r\n- print(\"\\nManifest is enabled, but no changes require saving.\")\r\n- # --- End Final Manifest Save ---\r\n-\r\n- # --- Final Summary ---\r\n- end_time = time.time()\r\n- duration = end_time - start_time\r\n- print(\"\\n--- Script Run Finished ---\")\r\n- print(f\"Duration: {duration:.2f} seconds\")\r\n- print(f\"Metadata Files Found: {metadata_files_found}\")\r\n- print(f\"Assets Processed/Attempted: {assets_processed}\")\r\n- if ENABLE_MANIFEST:\r\n- print(f\"Assets Skipped (Manifest): {assets_skipped_manifest}\")\r\n- print(f\"Maps Skipped (Manifest): {maps_skipped_manifest}\")\r\n- print(f\"Parent Groups Created: {parent_groups_created}\")\r\n- print(f\"Parent Groups Updated: {parent_groups_updated}\")\r\n- print(f\"Child Groups Created: {child_groups_created}\")\r\n- print(f\"Child Groups Updated: {child_groups_updated}\")\r\n- print(f\"Images Loaded: {images_loaded}\")\r\n- print(f\"Image Nodes Assigned: {images_assigned}\")\r\n- print(f\"Individual Maps Processed: {maps_processed}\")\r\n- print(f\"Asset Previews Set: {previews_set}\")\r\n- print(f\"Highest Resolution Nodes Set: {highest_res_set}\")\r\n- print(f\"Aspect Ratio Nodes Set: {aspect_ratio_set}\") # Added counter\r\n- if errors_encountered > 0:\r\n- print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n- print(\"---------------------------\")\r\n-\r\n- return True\r\n-\r\n-\r\n-# --- Execution Block ---\r\n-\r\n-if __name__ == \"__main__\":\r\n- # Ensure we are running within Blender\r\n- try:\r\n- import bpy\r\n- import base64 # Ensure base64 is imported here too if needed globally\r\n- except ImportError:\r\n- print(\"!!! ERROR: This script must be run from within Blender. !!!\")\r\n- else:\r\n- process_library(bpy.context)\n-# Blender Script: Create/Update Node Groups from Asset Processor Output\r\n-# Version: 1.4\r\n-# Description: Scans a library processed by the Asset Processor Tool,\r\n-# reads metadata.json files, and creates/updates corresponding\r\n-# PBR node groups in the active Blender file.\r\n-# Changes v1.4:\r\n-# - Corrected aspect ratio calculation to use actual image dimensions\r\n-# and the aspect_ratio_change_string, mirroring original script logic.\r\n-# Changes v1.3:\r\n-# - Added logic to find the highest resolution present for an asset.\r\n-# - Added logic to set a \"HighestResolution\" Value node in the parent group\r\n-# (maps 1K->1.0, 2K->2.0, 4K->3.0, 8K->4.0).\r\n-# Changes v1.2:\r\n-# - Added Base64 encoding for child node group names (PBRTYPE_...).\r\n-# - Added fallback logic for reconstructing image paths with different extensions.\r\n-# - Added logic to set custom asset preview for new parent groups (using lowest res COL map).\r\n-# Changes v1.1:\r\n-# - Updated metadata parsing to match actual structure (using processed_map_resolutions, image_stats_1k, map_details).\r\n-# - Added logic to reconstruct image file paths based on metadata and assumed naming convention.\r\n-\r\n-import bpy\r\n-import os\r\n-import json\r\n-from pathlib import Path\r\n-import time\r\n-import re # For parsing aspect ratio string\r\n-import base64 # For encoding node group names\r\n-\r\n-# --- USER CONFIGURATION ---\r\n-\r\n-# Path to the root output directory of the Asset Processor Tool\r\n-# Example: r\"G:\\Assets\\Processed\"\r\n-# IMPORTANT: This should point to the base directory containing supplier folders (e.g., Poliigon)\r\n-PROCESSED_ASSET_LIBRARY_ROOT = r\"G:\\02 Content\\10-19 Content\\13 Textures Power of Two\\Asset_Processor_Output\" # <<< CHANGE THIS PATH!\r\n-\r\n-# Names of the required node group templates in the Blender file\r\n-PARENT_TEMPLATE_NAME = \"Template_PBRSET\"\r\n-CHILD_TEMPLATE_NAME = \"Template_PBRTYPE\"\r\n-\r\n-# Labels of specific nodes within the PARENT template\r\n-ASPECT_RATIO_NODE_LABEL = \"AspectRatioCorrection\" # Value node for UV X-scaling factor\r\n-STATS_NODE_PREFIX = \"Histogram-\" # Prefix for Combine XYZ nodes storing stats (e.g., \"Histogram-ROUGH\")\r\n-HIGHEST_RESOLUTION_NODE_LABEL = \"HighestResolution\" # Value node to store highest res index\r\n-\r\n-# Enable/disable the manifest system to track processed assets/maps\r\n-# If enabled, requires the blend file to be saved.\r\n-ENABLE_MANIFEST = False\r\n-\r\n-# Assumed filename pattern for processed images.\r\n-# {asset_name}, {map_type}, {resolution}, {format} will be replaced.\r\n-# Check Asset Processor Tool's config.py (TARGET_FILENAME_PATTERN) if this is wrong.\r\n-IMAGE_FILENAME_PATTERN = \"{asset_name}_{map_type}_{resolution}.{format}\"\r\n-\r\n-# Fallback extensions to try if the primary format from metadata is not found\r\n-# Order matters - first found will be used.\r\n-FALLBACK_IMAGE_EXTENSIONS = ['png', 'jpg', 'exr', 'tif']\r\n-\r\n-# Map type(s) to use for generating the asset preview AND for aspect ratio calculation reference\r\n-# The script will look for these in order and use the first one found.\r\n-REFERENCE_MAP_TYPES = [\"COL\", \"COL-1\", \"COL-2\"] # Used for preview and aspect calc\r\n-# Preferred resolution order for reference image (lowest first is often faster)\r\n-REFERENCE_RESOLUTION_ORDER = [\"1K\", \"512\", \"2K\", \"4K\"] # Adjust as needed\r\n-\r\n-# Mapping from resolution string to numerical value for the HighestResolution node\r\n-RESOLUTION_VALUE_MAP = {\"1K\": 1.0, \"2K\": 2.0, \"4K\": 3.0, \"8K\": 4.0}\r\n-# Order to check resolutions to find the highest present (highest value first)\r\n-RESOLUTION_ORDER_DESC = [\"8K\", \"4K\", \"2K\", \"1K\"] # Add others like \"512\" if needed and map them in RESOLUTION_VALUE_MAP\r\n-\r\n-# Map PBR type strings (from metadata) to Blender color spaces\r\n-# Add more mappings as needed based on your metadata types\r\n-PBR_COLOR_SPACE_MAP = {\r\n- \"AO\": \"Non-Color\", # Usually Non-Color, but depends on workflow\r\n- \"COL-1\": \"sRGB\", # Handle variants if present in metadata\r\n- \"COL-2\": \"sRGB\",\r\n- \"COL-3\": \"sRGB\",\r\n- \"DISP\": \"Non-Color\",\r\n- \"REFL\": \"Non-Color\", # Reflection/Specular\r\n- \"METAL\": \"Non-Color\",\r\n- \"MASK\": \"Non-Color\", # Opacity/Alpha\r\n- \"SSS\": \"sRGB\", # Subsurface Color\r\n- \"EMISS\": \"sRGB\", # Emission Color\r\n- \"NRMRGH\": \"Non-Color\",\r\n+ \"NRMRGH\": \"Non-Color\", # Added for merged map\r\n \"FUZZ\": \"Non-Color\",\r\n # Add other types like GLOSS, HEIGHT, etc. if needed\r\n }\r\n DEFAULT_COLOR_SPACE = \"sRGB\" # Fallback if map type not in the dictionary\r\n@@ -1013,9 +146,10 @@\n def get_color_space(map_type):\r\n \"\"\"Returns the appropriate Blender color space name for a given map type string.\"\"\"\r\n # Handle potential numbered variants like COL-1, COL-2\r\n base_map_type = map_type.split('-')[0]\r\n- return PBR_COLOR_SPACE_MAP.get(base_map_type.upper(), DEFAULT_COLOR_SPACE)\r\n+ return PBR_COLOR_SPACE_MAP.get(map_type.upper(), # Check full name first (e.g., NRMRGH)\r\n+ PBR_COLOR_SPACE_MAP.get(base_map_type.upper(), DEFAULT_COLOR_SPACE)) # Fallback to base type\r\n \r\n def calculate_aspect_correction_factor(image_width, image_height, aspect_string):\r\n \"\"\"\r\n Calculates the UV X-axis scaling factor needed to correct distortion,\r\n@@ -1036,9 +170,10 @@\n # print(f\" Aspect string is EVEN. Correction factor = current aspect ratio: {current_aspect_ratio:.4f}\")\r\n return current_aspect_ratio\r\n \r\n # Handle non-uniform scaling cases (\"Xnnn\", \"Ynnn\")\r\n- match = re.match(r\"([XY])(\\d+)\", aspect_string, re.IGNORECASE)\r\n+ # Use search instead of match to find anywhere in string (though unlikely needed based on format)\r\n+ match = re.search(r\"([XY])(\\d+)\", aspect_string, re.IGNORECASE)\r\n if not match:\r\n print(f\" Warn: Invalid Scaling string format '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f} as fallback.\")\r\n return current_aspect_ratio # Fallback to the image's own ratio\r\n \r\n@@ -1063,9 +198,9 @@\n correction_factor = current_aspect_ratio / scaling_factor_percent\r\n elif axis == 'Y':\r\n # If image was stretched vertically (Y > 1), multiply UV.x by factor\r\n correction_factor = current_aspect_ratio * scaling_factor_percent\r\n- # No 'else' needed due to regex structure\r\n+ # No 'else' needed as regex ensures X or Y\r\n \r\n except ZeroDivisionError as e:\r\n print(f\" Warn: Division by zero during aspect factor calculation ({e}). Returning current ratio {current_aspect_ratio:.4f}.\")\r\n return current_aspect_ratio\r\n@@ -1076,45 +211,47 @@\n # print(f\" Calculated aspect correction factor: {correction_factor:.4f} (from {image_width}x{image_height}, Scaling='{aspect_string}')\")\r\n return correction_factor\r\n \r\n \r\n-def reconstruct_image_path_with_fallback(asset_dir_path, asset_name, map_type, resolution, primary_format):\r\n+def reconstruct_image_path_with_fallback(asset_dir_path, asset_name, map_type, resolution, primary_format=None):\r\n \"\"\"\r\n- Constructs the expected image file path, trying the primary format first,\r\n- then falling back to common extensions if the primary path doesn't exist.\r\n+ Constructs the expected image file path.\r\n+ If primary_format is provided, tries that first.\r\n+ Then falls back to common extensions if the path doesn't exist or primary_format was None.\r\n Returns the found path as a string, or None if not found.\r\n \"\"\"\r\n- if not all([asset_dir_path, asset_name, map_type, resolution, primary_format]):\r\n+ if not all([asset_dir_path, asset_name, map_type, resolution]):\r\n print(f\" !!! ERROR: Missing data for path reconstruction ({asset_name}/{map_type}/{resolution}).\")\r\n return None\r\n \r\n- # 1. Try the primary format from metadata\r\n- primary_path_str = None\r\n- try:\r\n- filename = IMAGE_FILENAME_PATTERN.format(\r\n- asset_name=asset_name,\r\n- map_type=map_type,\r\n- resolution=resolution,\r\n- format=primary_format.lower() # Ensure format is lowercase\r\n- )\r\n- primary_path = asset_dir_path / filename\r\n- primary_path_str = str(primary_path)\r\n- if primary_path.is_file():\r\n- # print(f\" Found primary path: {primary_path_str}\") # Verbose\r\n- return primary_path_str\r\n- # else: print(f\" Primary path not found: {primary_path_str}\") # Verbose\r\n- except KeyError as e:\r\n- print(f\" !!! ERROR: Missing key '{e}' in IMAGE_FILENAME_PATTERN. Cannot reconstruct path.\")\r\n- return None # Cannot proceed without valid pattern\r\n- except Exception as e:\r\n- print(f\" !!! ERROR reconstructing primary image path: {e}\")\r\n- # Continue to fallback even if primary reconstruction had issues\r\n+ found_path = None\r\n \r\n- # 2. Try fallback extensions if primary failed\r\n+ # 1. Try the primary format if provided\r\n+ if primary_format:\r\n+ try:\r\n+ filename = IMAGE_FILENAME_PATTERN.format(\r\n+ asset_name=asset_name,\r\n+ map_type=map_type,\r\n+ resolution=resolution,\r\n+ format=primary_format.lower() # Ensure format is lowercase\r\n+ )\r\n+ primary_path = asset_dir_path / filename\r\n+ if primary_path.is_file():\r\n+ # print(f\" Found primary path: {str(primary_path)}\") # Verbose\r\n+ return str(primary_path)\r\n+ # else: print(f\" Primary path not found: {str(primary_path)}\") # Verbose\r\n+ except KeyError as e:\r\n+ print(f\" !!! ERROR: Missing key '{e}' in IMAGE_FILENAME_PATTERN. Cannot reconstruct path.\")\r\n+ return None # Cannot proceed without valid pattern\r\n+ except Exception as e:\r\n+ print(f\" !!! ERROR reconstructing primary image path: {e}\")\r\n+ # Continue to fallback\r\n+\r\n+ # 2. Try fallback extensions\r\n # print(f\" Trying fallback extensions for {map_type}/{resolution}...\") # Verbose\r\n for ext in FALLBACK_IMAGE_EXTENSIONS:\r\n- # Ensure ext is treated as string and handle potential None values defensively\r\n- if not isinstance(ext, str) or not isinstance(primary_format, str) or ext.lower() == primary_format.lower():\r\n+ # Skip if we already tried this extension as primary (and it failed)\r\n+ if primary_format and ext.lower() == primary_format.lower():\r\n continue\r\n try:\r\n fallback_filename = IMAGE_FILENAME_PATTERN.format(\r\n asset_name=asset_name,\r\n@@ -1124,18 +261,22 @@\n )\r\n fallback_path = asset_dir_path / fallback_filename\r\n if fallback_path.is_file():\r\n print(f\" Found fallback path: {str(fallback_path)}\")\r\n- return str(fallback_path)\r\n+ return str(fallback_path) # Found it!\r\n except KeyError:\r\n # Should not happen if primary format worked, but handle defensively\r\n print(f\" !!! ERROR: Missing key in IMAGE_FILENAME_PATTERN during fallback. Cannot reconstruct path.\")\r\n return None\r\n except Exception as e_fallback:\r\n print(f\" !!! ERROR reconstructing fallback image path ({ext}): {e_fallback}\")\r\n continue # Try next extension\r\n \r\n- print(f\" !!! ERROR: Could not find image file for {map_type}/{resolution} using primary format '{primary_format}' or fallbacks {FALLBACK_IMAGE_EXTENSIONS}.\")\r\n+ # If we get here, neither primary nor fallbacks worked\r\n+ if primary_format:\r\n+ print(f\" !!! ERROR: Could not find image file for {map_type}/{resolution} using primary format '{primary_format}' or fallbacks {FALLBACK_IMAGE_EXTENSIONS}.\")\r\n+ else:\r\n+ print(f\" !!! ERROR: Could not find image file for {map_type}/{resolution} using fallbacks {FALLBACK_IMAGE_EXTENSIONS}.\")\r\n return None # Not found after all checks\r\n \r\n \r\n # --- Manifest Functions ---\r\n@@ -1330,34 +471,36 @@\n asset_name = metadata.get(\"asset_name\")\r\n supplier_name = metadata.get(\"supplier_name\")\r\n archetype = metadata.get(\"archetype\")\r\n # Get map info from the correct keys\r\n- processed_resolutions = metadata.get(\"processed_map_resolutions\") # Dict: {map_type: [res1, res2]}\r\n- map_details = metadata.get(\"map_details\") # Dict: {map_type: {details}}\r\n+ processed_resolutions = metadata.get(\"processed_map_resolutions\", {}) # Default to empty dict\r\n+ merged_resolutions = metadata.get(\"merged_map_resolutions\", {}) # Get merged maps too\r\n+ map_details = metadata.get(\"map_details\", {}) # Default to empty dict\r\n image_stats_1k = metadata.get(\"image_stats_1k\") # Dict: {map_type: {stats}}\r\n aspect_string = metadata.get(\"aspect_ratio_change_string\")\r\n \r\n+ # Combine processed and merged maps for iteration\r\n+ all_map_resolutions = {**processed_resolutions, **merged_resolutions}\r\n+\r\n # Validate essential data\r\n if not asset_name:\r\n print(f\" !!! ERROR: Metadata file is missing 'asset_name'. Skipping.\")\r\n errors_encountered += 1\r\n continue\r\n- if not processed_resolutions or not isinstance(processed_resolutions, dict):\r\n- print(f\" !!! ERROR: Metadata file is missing or has invalid 'processed_map_resolutions'. Skipping asset '{asset_name}'.\")\r\n+ if not all_map_resolutions:\r\n+ print(f\" !!! ERROR: Metadata file has no 'processed_map_resolutions' or 'merged_map_resolutions'. Skipping asset '{asset_name}'.\")\r\n errors_encountered += 1\r\n continue\r\n- if not map_details or not isinstance(map_details, dict):\r\n- print(f\" !!! WARNING: Metadata file is missing or has invalid 'map_details'. Path reconstruction might fail for asset '{asset_name}'.\")\r\n- # Continue processing, but path reconstruction might fail later\r\n+ # map_details check remains a warning as merged maps won't be in it\r\n \r\n print(f\" Asset Name: {asset_name}\")\r\n \r\n # --- Determine Highest Resolution ---\r\n highest_resolution_value = 0.0\r\n highest_resolution_str = \"Unknown\"\r\n all_resolutions_present = set()\r\n- if processed_resolutions: # Check if the dict exists and is not empty\r\n- for res_list in processed_resolutions.values():\r\n+ if all_map_resolutions: # Check combined dict\r\n+ for res_list in all_map_resolutions.values():\r\n if isinstance(res_list, list):\r\n all_resolutions_present.update(res_list)\r\n \r\n if all_resolutions_present:\r\n@@ -1374,40 +517,42 @@\n ref_image_path = None\r\n ref_image_width = 0\r\n ref_image_height = 0\r\n ref_image_loaded = False\r\n+ # Use combined resolutions dict to find reference map\r\n for ref_map_type in REFERENCE_MAP_TYPES:\r\n- if ref_map_type in processed_resolutions:\r\n- available_resolutions = processed_resolutions[ref_map_type]\r\n+ if ref_map_type in all_map_resolutions:\r\n+ available_resolutions = all_map_resolutions[ref_map_type]\r\n lowest_res = None\r\n for res_pref in REFERENCE_RESOLUTION_ORDER:\r\n if res_pref in available_resolutions:\r\n lowest_res = res_pref\r\n break\r\n if lowest_res:\r\n+ # Get format from map_details if available, otherwise None\r\n ref_map_details = map_details.get(ref_map_type, {})\r\n ref_format = ref_map_details.get(\"output_format\")\r\n- if ref_format:\r\n- ref_image_path = reconstruct_image_path_with_fallback(\r\n- asset_dir_path=asset_dir_path,\r\n- asset_name=asset_name,\r\n- map_type=ref_map_type,\r\n- resolution=lowest_res,\r\n- primary_format=ref_format\r\n- )\r\n- if ref_image_path:\r\n- break # Found a suitable reference image path\r\n+ ref_image_path = reconstruct_image_path_with_fallback(\r\n+ asset_dir_path=asset_dir_path,\r\n+ asset_name=asset_name,\r\n+ map_type=ref_map_type,\r\n+ resolution=lowest_res,\r\n+ primary_format=ref_format # Pass None if not in map_details\r\n+ )\r\n+ if ref_image_path:\r\n+ break # Found a suitable reference image path\r\n \r\n if ref_image_path:\r\n print(f\" Loading reference image for aspect ratio: {Path(ref_image_path).name}\")\r\n try:\r\n+ # Load image temporarily\r\n ref_img = bpy.data.images.load(ref_image_path, check_existing=True)\r\n if ref_img:\r\n ref_image_width = ref_img.size[0]\r\n ref_image_height = ref_img.size[1]\r\n ref_image_loaded = True\r\n print(f\" Reference image dimensions: {ref_image_width}x{ref_image_height}\")\r\n- # We don't need to keep the image data block loaded after getting dimensions\r\n+ # Remove the temporary image datablock to save memory\r\n bpy.data.images.remove(ref_img)\r\n else:\r\n print(f\" !!! ERROR: Failed loading reference image via bpy.data.images.load: {ref_image_path}\")\r\n except Exception as e_ref_load:\r\n@@ -1419,9 +564,9 @@\n # --- Manifest Check (Asset Level - Basic) ---\r\n if ENABLE_MANIFEST and is_asset_processed(manifest_data, asset_name):\r\n # Perform a quick check if *any* map needs processing for this asset\r\n needs_processing = False\r\n- for map_type, resolutions in processed_resolutions.items():\r\n+ for map_type, resolutions in all_map_resolutions.items(): # Check combined maps\r\n for resolution in resolutions:\r\n if not is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n needs_processing = True\r\n break\r\n@@ -1480,21 +625,23 @@\n else:\r\n print(f\" !!! WARNING: Using default aspect ratio correction (1.0) due to missing reference image.\")\r\n \r\n # Check if update is needed\r\n- if abs(aspect_node.outputs[0].default_value - correction_factor) > 0.0001:\r\n+ current_val = aspect_node.outputs[0].default_value\r\n+ if abs(current_val - correction_factor) > 0.0001:\r\n aspect_node.outputs[0].default_value = correction_factor\r\n- print(f\" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f}\")\r\n+ print(f\" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f} (was {current_val:.4f})\")\r\n aspect_ratio_set += 1\r\n # else: print(f\" Warn: Aspect ratio node '{ASPECT_RATIO_NODE_LABEL}' not found in parent group.\") # Optional\r\n \r\n # Apply Highest Resolution Value\r\n hr_nodes = find_nodes_by_label(parent_group, HIGHEST_RESOLUTION_NODE_LABEL, 'ShaderNodeValue')\r\n if hr_nodes:\r\n hr_node = hr_nodes[0]\r\n- if highest_resolution_value > 0.0 and abs(hr_node.outputs[0].default_value - highest_resolution_value) > 0.001:\r\n+ current_hr_val = hr_node.outputs[0].default_value\r\n+ if highest_resolution_value > 0.0 and abs(current_hr_val - highest_resolution_value) > 0.001:\r\n hr_node.outputs[0].default_value = highest_resolution_value\r\n- print(f\" Set '{HIGHEST_RESOLUTION_NODE_LABEL}' value to {highest_resolution_value} ({highest_resolution_str})\")\r\n+ print(f\" Set '{HIGHEST_RESOLUTION_NODE_LABEL}' value to {highest_resolution_value} ({highest_resolution_str}) (was {current_hr_val:.1f})\")\r\n highest_res_set += 1 # Count successful sets\r\n # else: print(f\" Warn: Highest resolution node '{HIGHEST_RESOLUTION_NODE_LABEL}' not found in parent group.\") # Optional\r\n \r\n \r\n@@ -1521,16 +668,20 @@\n mean_val = get_stat_value(stats.get(\"mean\")) # Often stored as 'mean' or 'avg'\r\n \r\n updated_stat = False\r\n # Check inputs exist before assigning\r\n- if stats_node.inputs.get(\"X\") and min_val is not None and abs(stats_node.inputs[\"X\"].default_value - min_val) > 0.0001:\r\n- stats_node.inputs[\"X\"].default_value = min_val\r\n+ input_x = stats_node.inputs.get(\"X\")\r\n+ input_y = stats_node.inputs.get(\"Y\")\r\n+ input_z = stats_node.inputs.get(\"Z\")\r\n+\r\n+ if input_x and min_val is not None and abs(input_x.default_value - min_val) > 0.0001:\r\n+ input_x.default_value = min_val\r\n updated_stat = True\r\n- if stats_node.inputs.get(\"Y\") and max_val is not None and abs(stats_node.inputs[\"Y\"].default_value - max_val) > 0.0001:\r\n- stats_node.inputs[\"Y\"].default_value = max_val\r\n+ if input_y and max_val is not None and abs(input_y.default_value - max_val) > 0.0001:\r\n+ input_y.default_value = max_val\r\n updated_stat = True\r\n- if stats_node.inputs.get(\"Z\") and mean_val is not None and abs(stats_node.inputs[\"Z\"].default_value - mean_val) > 0.0001:\r\n- stats_node.inputs[\"Z\"].default_value = mean_val\r\n+ if input_z and mean_val is not None and abs(input_z.default_value - mean_val) > 0.0001:\r\n+ input_z.default_value = mean_val\r\n updated_stat = True\r\n \r\n if updated_stat:\r\n print(f\" Set stats in '{stats_node_label}': Min={min_val:.4f}, Max={max_val:.4f}, Mean={mean_val:.4f}\")\r\n@@ -1557,19 +708,26 @@\n print(f\" Info: Could not set preview for '{asset_name}' as reference image was not found or loaded.\")\r\n \r\n \r\n # --- Child Group Handling ---\r\n- # Iterate through the map types listed in processed_resolutions\r\n- for map_type, resolutions in processed_resolutions.items():\r\n+ # Iterate through the COMBINED map types\r\n+ for map_type, resolutions in all_map_resolutions.items():\r\n print(f\" Processing Map Type: {map_type}\")\r\n \r\n- # Get details for this map type (needed for format)\r\n+ # Determine if this is a merged map (not in map_details)\r\n+ is_merged_map = map_type not in map_details\r\n+\r\n+ # Get details for this map type if available\r\n current_map_details = map_details.get(map_type, {})\r\n+ # For merged maps, primary_format will be None\r\n output_format = current_map_details.get(\"output_format\")\r\n- if not output_format:\r\n- print(f\" !!! WARNING: Missing 'output_format' in map_details for '{map_type}'. Cannot reconstruct path. Skipping map type.\")\r\n- continue\r\n \r\n+ if not output_format and not is_merged_map:\r\n+ # This case should ideally not happen if metadata is well-formed\r\n+ # but handle defensively for processed maps.\r\n+ print(f\" !!! WARNING: Missing 'output_format' in map_details for processed map '{map_type}'. Path reconstruction might fail.\")\r\n+ # We will rely solely on fallback for this map type\r\n+\r\n # Find placeholder node in parent\r\n holder_nodes = find_nodes_by_label(parent_group, map_type, 'ShaderNodeGroup')\r\n if not holder_nodes:\r\n print(f\" !!! WARNING: No placeholder node labeled '{map_type}' found in parent group '{parent_group.name}'. Skipping this map type.\")\r\n@@ -1658,14 +816,15 @@\n \r\n print(f\" Processing Resolution: {resolution}\")\r\n \r\n # Reconstruct the image path using fallback logic\r\n+ # Pass output_format (which might be None for merged maps)\r\n image_path_str = reconstruct_image_path_with_fallback(\r\n asset_dir_path=asset_dir_path,\r\n asset_name=asset_name,\r\n map_type=map_type,\r\n resolution=resolution,\r\n- primary_format=output_format # Pass the format from metadata\r\n+ primary_format=output_format\r\n )\r\n \r\n if not image_path_str:\r\n # Error already printed by reconstruct function\r\n@@ -1690,9 +849,9 @@\n image_load_failed = True\r\n else:\r\n # Only count as loaded if bpy.data.images.load succeeded\r\n # Check if it's newly loaded or reused\r\n- is_newly_loaded = img.filepath == str(image_path) # Simple check, might not be perfect if paths are identical but data differs\r\n+ is_newly_loaded = img.library is None # Newly loaded images don't have a library initially\r\n if is_newly_loaded: images_loaded += 1\r\n \r\n except RuntimeError as e_runtime_load:\r\n # Catch specific Blender runtime errors (e.g., unsupported format)\r\n" }, { "date": 1745261945437, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -372,14 +372,24 @@\n \r\n \r\n # --- Core Logic ---\r\n \r\n-def process_library(context):\r\n+def process_library(context, asset_library_root_override=None): # Add override parameter\r\n global ENABLE_MANIFEST # Declare intent to modify global if needed\r\n+ global PROCESSED_ASSET_LIBRARY_ROOT # Allow modification of global\r\n \"\"\"Scans the library, reads metadata, creates/updates node groups.\"\"\"\r\n start_time = time.time()\r\n print(f\"\\n--- Starting Node Group Processing ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---\")\r\n \r\n+ # --- Determine Asset Library Root ---\r\n+ if asset_library_root_override:\r\n+ PROCESSED_ASSET_LIBRARY_ROOT = asset_library_root_override\r\n+ print(f\"Using asset library root from argument: '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n+ elif not PROCESSED_ASSET_LIBRARY_ROOT:\r\n+ print(\"!!! ERROR: Processed asset library root not set in script and not provided via argument.\")\r\n+ print(\"--- Script aborted. ---\")\r\n+ return False\r\n+\r\n # --- Pre-run Checks ---\r\n print(\"Performing pre-run checks...\")\r\n valid_setup = True\r\n # 1. Check Library Root Path\r\n@@ -957,8 +967,25 @@\n # Ensure we are running within Blender\r\n try:\r\n import bpy\r\n import base64 # Ensure base64 is imported here too if needed globally\r\n+ import sys\r\n except ImportError:\r\n print(\"!!! ERROR: This script must be run from within Blender. !!!\")\r\n else:\r\n\\ No newline at end of file\n- process_library(bpy.context)\n+ # --- Argument Parsing for Asset Library Root ---\r\n+ asset_root_arg = None\r\n+ try:\r\n+ # Blender arguments passed after '--' appear in sys.argv\r\n+ if \"--\" in sys.argv:\r\n+ args_after_dash = sys.argv[sys.argv.index(\"--\") + 1:]\r\n+ if len(args_after_dash) >= 1:\r\n+ asset_root_arg = args_after_dash[0]\r\n+ print(f\"Found asset library root argument: {asset_root_arg}\")\r\n+ else:\r\n+ print(\"Info: '--' found but no arguments after it.\")\r\n+ # else: print(\"Info: No '--' found in arguments.\") # Optional debug\r\n+ except Exception as e:\r\n+ print(f\"Error parsing command line arguments: {e}\")\r\n+ # --- End Argument Parsing ---\r\n+\r\n+ process_library(bpy.context, asset_library_root_override=asset_root_arg)\n\\ No newline at end of file\n" }, { "date": 1745261958348, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -33,9 +33,10 @@\n \r\n # Path to the root output directory of the Asset Processor Tool\r\n # Example: r\"G:\\Assets\\Processed\"\r\n # IMPORTANT: This should point to the base directory containing supplier folders (e.g., Poliigon)\r\n-PROCESSED_ASSET_LIBRARY_ROOT = r\"G:\\02 Content\\10-19 Content\\13 Textures Power of Two\\Asset_Processor_Output\" # <<< CHANGE THIS PATH!\r\n+# This will be overridden by command-line arguments if provided.\r\n+PROCESSED_ASSET_LIBRARY_ROOT = None # Set to None initially\r\n \r\n # Names of the required node group templates in the Blender file\r\n PARENT_TEMPLATE_NAME = \"Template_PBRSET\"\r\n CHILD_TEMPLATE_NAME = \"Template_PBRTYPE\"\r\n" }, { "date": 1745263537952, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -958,8 +958,16 @@\n if errors_encountered > 0:\r\n print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n print(\"---------------------------\")\r\n \r\n+ # --- Explicit Save ---\r\n+ try:\r\n+ bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)\r\n+ print(\"\\n--- Explicitly saved the .blend file. ---\")\r\n+ except Exception as e_save:\r\n+ print(f\"\\n!!! ERROR explicitly saving .blend file: {e_save} !!!\")\r\n+ errors_encountered += 1 # Count save errors\r\n+\r\n return True\r\n \r\n \r\n # --- Execution Block ---\r\n" }, { "date": 1745263806905, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -379,8 +379,9 @@\n global PROCESSED_ASSET_LIBRARY_ROOT # Allow modification of global\r\n \"\"\"Scans the library, reads metadata, creates/updates node groups.\"\"\"\r\n start_time = time.time()\r\n print(f\"\\n--- Starting Node Group Processing ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---\")\r\n+ print(f\"DEBUG: Received asset_library_root_override: {asset_library_root_override}\") # DEBUG LOG\r\n \r\n # --- Determine Asset Library Root ---\r\n if asset_library_root_override:\r\n PROCESSED_ASSET_LIBRARY_ROOT = asset_library_root_override\r\n@@ -388,8 +389,9 @@\n elif not PROCESSED_ASSET_LIBRARY_ROOT:\r\n print(\"!!! ERROR: Processed asset library root not set in script and not provided via argument.\")\r\n print(\"--- Script aborted. ---\")\r\n return False\r\n+ print(f\"DEBUG: Using final PROCESSED_ASSET_LIBRARY_ROOT: {PROCESSED_ASSET_LIBRARY_ROOT}\") # DEBUG LOG\r\n \r\n # --- Pre-run Checks ---\r\n print(\"Performing pre-run checks...\")\r\n valid_setup = True\r\n@@ -400,8 +402,9 @@\n print(f\"!!! '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n valid_setup = False\r\n else:\r\n print(f\" Asset Library Root: '{root_path}'\")\r\n+ print(f\"DEBUG: Checking for templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\") # DEBUG LOG\r\n \r\n # 2. Check Templates\r\n template_parent = bpy.data.node_groups.get(PARENT_TEMPLATE_NAME)\r\n template_child = bpy.data.node_groups.get(CHILD_TEMPLATE_NAME)\r\n@@ -412,10 +415,12 @@\n print(f\"!!! ERROR: Child template node group '{CHILD_TEMPLATE_NAME}' not found in this Blender file.\")\r\n valid_setup = False\r\n if template_parent and template_child:\r\n print(f\" Found Templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\")\r\n+ print(f\"DEBUG: Template Parent Found: {template_parent is not None}\") # DEBUG LOG\r\n+ print(f\"DEBUG: Template Child Found: {template_child is not None}\") # DEBUG LOG\r\n \r\n- # 3. Check Blend File Saved (if manifest enabled)\r\n+ # 3. Check Blend File Saved (if manifest enabled)\r\n if ENABLE_MANIFEST and not context.blend_data.filepath:\r\n print(f\"!!! WARNING: Manifest is enabled, but the current Blender file is not saved.\")\r\n print(f\"!!! Manifest cannot be loaded or saved. Processing will continue without manifest checks.\")\r\n ENABLE_MANIFEST = False # Disable manifest for this run\r\n@@ -463,8 +468,9 @@\n metadata_paths.append(metadata_file)\r\n \r\n metadata_files_found = len(metadata_paths)\r\n print(f\"Found {metadata_files_found} metadata.json files.\")\r\n+ print(f\"DEBUG: Metadata paths found: {metadata_paths}\") # DEBUG LOG\r\n \r\n if metadata_files_found == 0:\r\n print(\"No metadata files found. Nothing to process.\")\r\n print(\"--- Script Finished ---\")\r\n@@ -473,8 +479,9 @@\n # --- Process Each Metadata File ---\r\n for metadata_path in metadata_paths:\r\n asset_dir_path = metadata_path.parent # Get the directory containing the metadata file\r\n print(f\"\\n--- Processing Metadata: {metadata_path.relative_to(root_path)} ---\")\r\n+ print(f\"DEBUG: Processing file: {metadata_path}\") # DEBUG LOG\r\n try:\r\n with open(metadata_path, 'r', encoding='utf-8') as f:\r\n metadata = json.load(f)\r\n \r\n@@ -594,8 +601,9 @@\n is_new_parent = False\r\n \r\n if parent_group is None:\r\n print(f\" Creating new parent group: '{target_parent_name}'\")\r\n+ print(f\"DEBUG: Copying parent template '{PARENT_TEMPLATE_NAME}'\") # DEBUG LOG\r\n parent_group = template_parent.copy()\r\n if not parent_group:\r\n print(f\" !!! ERROR: Failed to copy parent template '{PARENT_TEMPLATE_NAME}'. Skipping asset '{asset_name}'.\")\r\n errors_encountered += 1\r\n@@ -604,8 +612,9 @@\n parent_groups_created += 1\r\n is_new_parent = True\r\n else:\r\n print(f\" Updating existing parent group: '{target_parent_name}'\")\r\n+ print(f\"DEBUG: Found existing parent group.\") # DEBUG LOG\r\n parent_groups_updated += 1\r\n \r\n # Ensure marked as asset\r\n if not parent_group.asset_data:\r\n@@ -720,8 +729,9 @@\n \r\n \r\n # --- Child Group Handling ---\r\n # Iterate through the COMBINED map types\r\n+ print(f\"DEBUG: Starting child group loop for asset '{asset_name}'. Map types: {list(all_map_resolutions.keys())}\") # DEBUG LOG\r\n for map_type, resolutions in all_map_resolutions.items():\r\n print(f\" Processing Map Type: {map_type}\")\r\n \r\n # Determine if this is a merged map (not in map_details)\r\n@@ -752,8 +762,9 @@\n child_group = bpy.data.node_groups.get(target_child_name_b64) # Find using encoded name\r\n is_new_child = False\r\n \r\n if child_group is None:\r\n+ print(f\"DEBUG: Child group '{target_child_name_b64}' not found. Creating new one.\") # DEBUG LOG\r\n # print(f\" Creating new child group: '{target_child_name_b64}' (logical: '{logical_child_name}')\") # Verbose\r\n child_group = template_child.copy()\r\n if not child_group:\r\n print(f\" !!! ERROR: Failed to copy child template '{CHILD_TEMPLATE_NAME}'. Skipping map type '{map_type}'.\")\r\n@@ -762,8 +773,9 @@\n child_group.name = target_child_name_b64 # Set encoded name\r\n child_groups_created += 1\r\n is_new_child = True\r\n else:\r\n+ print(f\"DEBUG: Found existing child group '{target_child_name_b64}'.\") # DEBUG LOG\r\n # print(f\" Updating existing child group: '{target_child_name_b64}'\") # Verbose\r\n child_groups_updated += 1\r\n \r\n # Assign child group to placeholder if needed\r\n@@ -823,8 +835,9 @@\n if ENABLE_MANIFEST and is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n # print(f\" Skipping {resolution} (Manifest)\") # Verbose\r\n maps_skipped_manifest += 1\r\n continue\r\n+ print(f\"DEBUG: Processing map '{map_type}' resolution '{resolution}'. Manifest skip check passed.\") # DEBUG LOG\r\n \r\n print(f\" Processing Resolution: {resolution}\")\r\n \r\n # Reconstruct the image path using fallback logic\r\n@@ -959,8 +972,9 @@\n print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n print(\"---------------------------\")\r\n \r\n # --- Explicit Save ---\r\n+ print(f\"DEBUG: Attempting explicit save for file: {bpy.data.filepath}\") # DEBUG LOG\r\n try:\r\n bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)\r\n print(\"\\n--- Explicitly saved the .blend file. ---\")\r\n except Exception as e_save:\r\n" }, { "date": 1745263848166, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -379,9 +379,9 @@\n global PROCESSED_ASSET_LIBRARY_ROOT # Allow modification of global\r\n \"\"\"Scans the library, reads metadata, creates/updates node groups.\"\"\"\r\n start_time = time.time()\r\n print(f\"\\n--- Starting Node Group Processing ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---\")\r\n- print(f\"DEBUG: Received asset_library_root_override: {asset_library_root_override}\") # DEBUG LOG\r\n+ print(f\" DEBUG: Received asset_library_root_override: {asset_library_root_override}\") # DEBUG LOG (Indented)\r\n \r\n # --- Determine Asset Library Root ---\r\n if asset_library_root_override:\r\n PROCESSED_ASSET_LIBRARY_ROOT = asset_library_root_override\r\n@@ -389,9 +389,9 @@\n elif not PROCESSED_ASSET_LIBRARY_ROOT:\r\n print(\"!!! ERROR: Processed asset library root not set in script and not provided via argument.\")\r\n print(\"--- Script aborted. ---\")\r\n return False\r\n- print(f\"DEBUG: Using final PROCESSED_ASSET_LIBRARY_ROOT: {PROCESSED_ASSET_LIBRARY_ROOT}\") # DEBUG LOG\r\n+ print(f\" DEBUG: Using final PROCESSED_ASSET_LIBRARY_ROOT: {PROCESSED_ASSET_LIBRARY_ROOT}\") # DEBUG LOG (Indented)\r\n \r\n # --- Pre-run Checks ---\r\n print(\"Performing pre-run checks...\")\r\n valid_setup = True\r\n@@ -402,9 +402,9 @@\n print(f\"!!! '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n valid_setup = False\r\n else:\r\n print(f\" Asset Library Root: '{root_path}'\")\r\n- print(f\"DEBUG: Checking for templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\") # DEBUG LOG\r\n+ print(f\" DEBUG: Checking for templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\") # DEBUG LOG (Indented)\r\n \r\n # 2. Check Templates\r\n template_parent = bpy.data.node_groups.get(PARENT_TEMPLATE_NAME)\r\n template_child = bpy.data.node_groups.get(CHILD_TEMPLATE_NAME)\r\n@@ -415,13 +415,13 @@\n print(f\"!!! ERROR: Child template node group '{CHILD_TEMPLATE_NAME}' not found in this Blender file.\")\r\n valid_setup = False\r\n if template_parent and template_child:\r\n print(f\" Found Templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\")\r\n- print(f\"DEBUG: Template Parent Found: {template_parent is not None}\") # DEBUG LOG\r\n- print(f\"DEBUG: Template Child Found: {template_child is not None}\") # DEBUG LOG\r\n+ print(f\" DEBUG: Template Parent Found: {template_parent is not None}\") # DEBUG LOG (Indented)\r\n+ print(f\" DEBUG: Template Child Found: {template_child is not None}\") # DEBUG LOG (Indented)\r\n \r\n # 3. Check Blend File Saved (if manifest enabled)\r\n- if ENABLE_MANIFEST and not context.blend_data.filepath:\r\n+ if ENABLE_MANIFEST and not context.blend_data.filepath:\r\n print(f\"!!! WARNING: Manifest is enabled, but the current Blender file is not saved.\")\r\n print(f\"!!! Manifest cannot be loaded or saved. Processing will continue without manifest checks.\")\r\n ENABLE_MANIFEST = False # Disable manifest for this run\r\n \r\n@@ -468,9 +468,9 @@\n metadata_paths.append(metadata_file)\r\n \r\n metadata_files_found = len(metadata_paths)\r\n print(f\"Found {metadata_files_found} metadata.json files.\")\r\n- print(f\"DEBUG: Metadata paths found: {metadata_paths}\") # DEBUG LOG\r\n+ print(f\" DEBUG: Metadata paths found: {metadata_paths}\") # DEBUG LOG (Indented)\r\n \r\n if metadata_files_found == 0:\r\n print(\"No metadata files found. Nothing to process.\")\r\n print(\"--- Script Finished ---\")\r\n@@ -479,9 +479,9 @@\n # --- Process Each Metadata File ---\r\n for metadata_path in metadata_paths:\r\n asset_dir_path = metadata_path.parent # Get the directory containing the metadata file\r\n print(f\"\\n--- Processing Metadata: {metadata_path.relative_to(root_path)} ---\")\r\n- print(f\"DEBUG: Processing file: {metadata_path}\") # DEBUG LOG\r\n+ print(f\" DEBUG: Processing file: {metadata_path}\") # DEBUG LOG (Indented)\r\n try:\r\n with open(metadata_path, 'r', encoding='utf-8') as f:\r\n metadata = json.load(f)\r\n \r\n@@ -601,9 +601,9 @@\n is_new_parent = False\r\n \r\n if parent_group is None:\r\n print(f\" Creating new parent group: '{target_parent_name}'\")\r\n- print(f\"DEBUG: Copying parent template '{PARENT_TEMPLATE_NAME}'\") # DEBUG LOG\r\n+ print(f\" DEBUG: Copying parent template '{PARENT_TEMPLATE_NAME}'\") # DEBUG LOG (Indented)\r\n parent_group = template_parent.copy()\r\n if not parent_group:\r\n print(f\" !!! ERROR: Failed to copy parent template '{PARENT_TEMPLATE_NAME}'. Skipping asset '{asset_name}'.\")\r\n errors_encountered += 1\r\n@@ -612,9 +612,9 @@\n parent_groups_created += 1\r\n is_new_parent = True\r\n else:\r\n print(f\" Updating existing parent group: '{target_parent_name}'\")\r\n- print(f\"DEBUG: Found existing parent group.\") # DEBUG LOG\r\n+ print(f\" DEBUG: Found existing parent group.\") # DEBUG LOG (Indented)\r\n parent_groups_updated += 1\r\n \r\n # Ensure marked as asset\r\n if not parent_group.asset_data:\r\n@@ -729,9 +729,9 @@\n \r\n \r\n # --- Child Group Handling ---\r\n # Iterate through the COMBINED map types\r\n- print(f\"DEBUG: Starting child group loop for asset '{asset_name}'. Map types: {list(all_map_resolutions.keys())}\") # DEBUG LOG\r\n+ print(f\" DEBUG: Starting child group loop for asset '{asset_name}'. Map types: {list(all_map_resolutions.keys())}\") # DEBUG LOG (Indented)\r\n for map_type, resolutions in all_map_resolutions.items():\r\n print(f\" Processing Map Type: {map_type}\")\r\n \r\n # Determine if this is a merged map (not in map_details)\r\n@@ -762,9 +762,9 @@\n child_group = bpy.data.node_groups.get(target_child_name_b64) # Find using encoded name\r\n is_new_child = False\r\n \r\n if child_group is None:\r\n- print(f\"DEBUG: Child group '{target_child_name_b64}' not found. Creating new one.\") # DEBUG LOG\r\n+ print(f\" DEBUG: Child group '{target_child_name_b64}' not found. Creating new one.\") # DEBUG LOG (Indented)\r\n # print(f\" Creating new child group: '{target_child_name_b64}' (logical: '{logical_child_name}')\") # Verbose\r\n child_group = template_child.copy()\r\n if not child_group:\r\n print(f\" !!! ERROR: Failed to copy child template '{CHILD_TEMPLATE_NAME}'. Skipping map type '{map_type}'.\")\r\n@@ -773,9 +773,9 @@\n child_group.name = target_child_name_b64 # Set encoded name\r\n child_groups_created += 1\r\n is_new_child = True\r\n else:\r\n- print(f\"DEBUG: Found existing child group '{target_child_name_b64}'.\") # DEBUG LOG\r\n+ print(f\" DEBUG: Found existing child group '{target_child_name_b64}'.\") # DEBUG LOG (Indented)\r\n # print(f\" Updating existing child group: '{target_child_name_b64}'\") # Verbose\r\n child_groups_updated += 1\r\n \r\n # Assign child group to placeholder if needed\r\n@@ -835,9 +835,9 @@\n if ENABLE_MANIFEST and is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n # print(f\" Skipping {resolution} (Manifest)\") # Verbose\r\n maps_skipped_manifest += 1\r\n continue\r\n- print(f\"DEBUG: Processing map '{map_type}' resolution '{resolution}'. Manifest skip check passed.\") # DEBUG LOG\r\n+ print(f\" DEBUG: Processing map '{map_type}' resolution '{resolution}'. Manifest skip check passed.\") # DEBUG LOG (Indented)\r\n \r\n print(f\" Processing Resolution: {resolution}\")\r\n \r\n # Reconstruct the image path using fallback logic\r\n@@ -972,9 +972,9 @@\n print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n print(\"---------------------------\")\r\n \r\n # --- Explicit Save ---\r\n- print(f\"DEBUG: Attempting explicit save for file: {bpy.data.filepath}\") # DEBUG LOG\r\n+ print(f\" DEBUG: Attempting explicit save for file: {bpy.data.filepath}\") # DEBUG LOG (Indented)\r\n try:\r\n bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)\r\n print(\"\\n--- Explicitly saved the .blend file. ---\")\r\n except Exception as e_save:\r\n" }, { "date": 1745263876730, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -0,0 +1,51 @@\n+ print(f\"Child Groups Updated: {child_groups_updated}\")\r\n+ print(f\"Images Loaded: {images_loaded}\")\r\n+ print(f\"Image Nodes Assigned: {images_assigned}\")\r\n+ print(f\"Individual Maps Processed: {maps_processed}\")\r\n+ print(f\"Asset Previews Set: {previews_set}\")\r\n+ print(f\"Highest Resolution Nodes Set: {highest_res_set}\")\r\n+ print(f\"Aspect Ratio Nodes Set: {aspect_ratio_set}\") # Added counter\r\n+ if errors_encountered > 0:\r\n+ print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n+ print(\"---------------------------\")\r\n+\r\n+ # --- Explicit Save ---\r\n+ print(f\" DEBUG: Attempting explicit save for file: {bpy.data.filepath}\") # DEBUG LOG (Indented)\r\n+ try:\r\n+ bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)\r\n+ print(\"\\n--- Explicitly saved the .blend file. ---\")\r\n+ except Exception as e_save:\r\n+ print(f\"\\n!!! ERROR explicitly saving .blend file: {e_save} !!!\")\r\n+ errors_encountered += 1 # Count save errors\r\n+\r\n+ return True\r\n+\r\n+\r\n+# --- Execution Block ---\r\n+\r\n+if __name__ == \"__main__\":\r\n+ # Ensure we are running within Blender\r\n+ try:\r\n+ import bpy\r\n+ import base64 # Ensure base64 is imported here too if needed globally\r\n+ import sys\r\n+ except ImportError:\r\n+ print(\"!!! ERROR: This script must be run from within Blender. !!!\")\r\n+ else:\r\n+ # --- Argument Parsing for Asset Library Root ---\r\n+ asset_root_arg = None\r\n+ try:\r\n+ # Blender arguments passed after '--' appear in sys.argv\r\n+ if \"--\" in sys.argv:\r\n+ args_after_dash = sys.argv[sys.argv.index(\"--\") + 1:]\r\n+ if len(args_after_dash) >= 1:\r\n+ asset_root_arg = args_after_dash[0]\r\n+ print(f\"Found asset library root argument: {asset_root_arg}\")\r\n+ else:\r\n+ print(\"Info: '--' found but no arguments after it.\")\r\n+ # else: print(\"Info: No '--' found in arguments.\") # Optional debug\r\n+ except Exception as e:\r\n+ print(f\"Error parsing command line arguments: {e}\")\r\n+ # --- End Argument Parsing ---\r\n+\r\n+ process_library(bpy.context, asset_library_root_override=asset_root_arg)\n\\ No newline at end of file\n" }, { "date": 1745264002733, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -1,55 +1,4 @@\n- print(f\"Child Groups Updated: {child_groups_updated}\")\r\n- print(f\"Images Loaded: {images_loaded}\")\r\n- print(f\"Image Nodes Assigned: {images_assigned}\")\r\n- print(f\"Individual Maps Processed: {maps_processed}\")\r\n- print(f\"Asset Previews Set: {previews_set}\")\r\n- print(f\"Highest Resolution Nodes Set: {highest_res_set}\")\r\n- print(f\"Aspect Ratio Nodes Set: {aspect_ratio_set}\") # Added counter\r\n- if errors_encountered > 0:\r\n- print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n- print(\"---------------------------\")\r\n-\r\n- # --- Explicit Save ---\r\n- print(f\" DEBUG: Attempting explicit save for file: {bpy.data.filepath}\") # DEBUG LOG (Indented)\r\n- try:\r\n- bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)\r\n- print(\"\\n--- Explicitly saved the .blend file. ---\")\r\n- except Exception as e_save:\r\n- print(f\"\\n!!! ERROR explicitly saving .blend file: {e_save} !!!\")\r\n- errors_encountered += 1 # Count save errors\r\n-\r\n- return True\r\n-\r\n-\r\n-# --- Execution Block ---\r\n-\r\n-if __name__ == \"__main__\":\r\n- # Ensure we are running within Blender\r\n- try:\r\n- import bpy\r\n- import base64 # Ensure base64 is imported here too if needed globally\r\n- import sys\r\n- except ImportError:\r\n- print(\"!!! ERROR: This script must be run from within Blender. !!!\")\r\n- else:\r\n- # --- Argument Parsing for Asset Library Root ---\r\n- asset_root_arg = None\r\n- try:\r\n- # Blender arguments passed after '--' appear in sys.argv\r\n- if \"--\" in sys.argv:\r\n- args_after_dash = sys.argv[sys.argv.index(\"--\") + 1:]\r\n- if len(args_after_dash) >= 1:\r\n- asset_root_arg = args_after_dash[0]\r\n- print(f\"Found asset library root argument: {asset_root_arg}\")\r\n- else:\r\n- print(\"Info: '--' found but no arguments after it.\")\r\n- # else: print(\"Info: No '--' found in arguments.\") # Optional debug\r\n- except Exception as e:\r\n- print(f\"Error parsing command line arguments: {e}\")\r\n- # --- End Argument Parsing ---\r\n-\r\n- process_library(bpy.context, asset_library_root_override=asset_root_arg)\n # Blender Script: Create/Update Node Groups from Asset Processor Output\r\n # Version: 1.5\r\n # Description: Scans a library processed by the Asset Processor Tool,\r\n # reads metadata.json files, and creates/updates corresponding\r\n@@ -466,13 +415,13 @@\n print(f\"!!! ERROR: Child template node group '{CHILD_TEMPLATE_NAME}' not found in this Blender file.\")\r\n valid_setup = False\r\n if template_parent and template_child:\r\n print(f\" Found Templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\")\r\n- print(f\" DEBUG: Template Parent Found: {template_parent is not None}\") # DEBUG LOG (Indented)\r\n- print(f\" DEBUG: Template Child Found: {template_child is not None}\") # DEBUG LOG (Indented)\r\n+ print(f\" DEBUG: Template Parent Found: {template_parent is not None}\") # DEBUG LOG (Indented)\r\n+ print(f\" DEBUG: Template Child Found: {template_child is not None}\") # DEBUG LOG (Indented)\r\n \r\n- # 3. Check Blend File Saved (if manifest enabled)\r\n- if ENABLE_MANIFEST and not context.blend_data.filepath:\r\n+ # 3. Check Blend File Saved (if manifest enabled)\r\n+ if ENABLE_MANIFEST and not context.blend_data.filepath:\r\n print(f\"!!! WARNING: Manifest is enabled, but the current Blender file is not saved.\")\r\n print(f\"!!! Manifest cannot be loaded or saved. Processing will continue without manifest checks.\")\r\n ENABLE_MANIFEST = False # Disable manifest for this run\r\n \r\n@@ -559,8 +508,9 @@\n print(f\" !!! ERROR: Metadata file has no 'processed_map_resolutions' or 'merged_map_resolutions'. Skipping asset '{asset_name}'.\")\r\n errors_encountered += 1\r\n continue\r\n # map_details check remains a warning as merged maps won't be in it\r\n+ print(f\" DEBUG: Valid metadata loaded for asset: {asset_name}\") # DEBUG LOG (Indented)\r\n \r\n print(f\" Asset Name: {asset_name}\")\r\n \r\n # --- Determine Highest Resolution ---\r\n@@ -804,8 +754,9 @@\n if not holder_nodes:\r\n print(f\" !!! WARNING: No placeholder node labeled '{map_type}' found in parent group '{parent_group.name}'. Skipping this map type.\")\r\n continue\r\n holder_node = holder_nodes[0] # Assume first is correct\r\n+ print(f\" DEBUG: Found placeholder node '{holder_node.label}' for map type '{map_type}'.\") # DEBUG LOG (Indented)\r\n \r\n # Determine child group name (LOGICAL and ENCODED)\r\n logical_child_name = f\"{asset_name}_{map_type}\"\r\n target_child_name_b64 = encode_name_b64(logical_child_name) # Use Base64 name\r\n@@ -899,8 +850,9 @@\n map_type=map_type,\r\n resolution=resolution,\r\n primary_format=output_format\r\n )\r\n+ print(f\" DEBUG: Reconstructed image path for {map_type}/{resolution}: {image_path_str}\") # DEBUG LOG (Indented)\r\n \r\n if not image_path_str:\r\n # Error already printed by reconstruct function\r\n errors_encountered += 1\r\n@@ -910,8 +862,9 @@\n image_nodes = find_nodes_by_label(child_group, resolution, 'ShaderNodeTexImage')\r\n if not image_nodes:\r\n print(f\" !!! WARNING: No Image Texture node labeled '{resolution}' found in child group '{child_group.name}'. Cannot assign image.\")\r\n continue # Skip this resolution if node not found\r\n+ print(f\" DEBUG: Found {len(image_nodes)} image node(s) labeled '{resolution}' in child group '{child_group.name}'.\") # DEBUG LOG (Indented)\r\n \r\n # --- Load Image ---\r\n img = None\r\n image_load_failed = False\r\n@@ -1061,5 +1014,4 @@\n except Exception as e:\r\n print(f\"Error parsing command line arguments: {e}\")\r\n # --- End Argument Parsing ---\r\n \r\n- process_library(bpy.context, asset_library_root_override=asset_root_arg)\n\\ No newline at end of file\n" }, { "date": 1745264122725, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -0,0 +1,1019 @@\n+# Blender Script: Create/Update Node Groups from Asset Processor Output\r\n+# Version: 1.5\r\n+# Description: Scans a library processed by the Asset Processor Tool,\r\n+# reads metadata.json files, and creates/updates corresponding\r\n+# PBR node groups in the active Blender file.\r\n+# Changes v1.5:\r\n+# - Corrected aspect ratio calculation (`calculate_aspect_correction_factor`)\r\n+# to use actual image dimensions from a loaded reference image and the\r\n+# `aspect_ratio_change_string`, mirroring original script logic for\r\n+# \"EVEN\", \"Xnnn\", \"Ynnn\" formats.\r\n+# - Added logic in main loop to load reference image for dimensions.\r\n+# Changes v1.3:\r\n+# - Added logic to find the highest resolution present for an asset.\r\n+# - Added logic to set a \"HighestResolution\" Value node in the parent group\r\n+# (maps 1K->1.0, 2K->2.0, 4K->3.0, 8K->4.0).\r\n+# Changes v1.2:\r\n+# - Added Base64 encoding for child node group names (PBRTYPE_...).\r\n+# - Added fallback logic for reconstructing image paths with different extensions.\r\n+# - Added logic to set custom asset preview for new parent groups (using lowest res COL map).\r\n+# Changes v1.1:\r\n+# - Updated metadata parsing to match actual structure (using processed_map_resolutions, image_stats_1k, map_details).\r\n+# - Added logic to reconstruct image file paths based on metadata and assumed naming convention.\r\n+\r\n+import bpy\r\n+import os\r\n+import json\r\n+from pathlib import Path\r\n+import time\r\n+import re # For parsing aspect ratio string\r\n+import base64 # For encoding node group names\r\n+import sys # <<< ADDED IMPORT\r\n+\r\n+# --- USER CONFIGURATION ---\r\n+\r\n+# Path to the root output directory of the Asset Processor Tool\r\n+# Example: r\"G:\\Assets\\Processed\"\r\n+# IMPORTANT: This should point to the base directory containing supplier folders (e.g., Poliigon)\r\n+# This will be overridden by command-line arguments if provided.\r\n+PROCESSED_ASSET_LIBRARY_ROOT = None # Set to None initially\r\n+\r\n+# Names of the required node group templates in the Blender file\r\n+PARENT_TEMPLATE_NAME = \"Template_PBRSET\"\r\n+CHILD_TEMPLATE_NAME = \"Template_PBRTYPE\"\r\n+\r\n+# Labels of specific nodes within the PARENT template\r\n+ASPECT_RATIO_NODE_LABEL = \"AspectRatioCorrection\" # Value node for UV X-scaling factor\r\n+STATS_NODE_PREFIX = \"Histogram-\" # Prefix for Combine XYZ nodes storing stats (e.g., \"Histogram-ROUGH\")\r\n+HIGHEST_RESOLUTION_NODE_LABEL = \"HighestResolution\" # Value node to store highest res index\r\n+\r\n+# Enable/disable the manifest system to track processed assets/maps\r\n+# If enabled, requires the blend file to be saved.\r\n+ENABLE_MANIFEST = False # Disabled based on user feedback in previous run\r\n+\r\n+# Assumed filename pattern for processed images.\r\n+# {asset_name}, {map_type}, {resolution}, {format} will be replaced.\r\n+# Check Asset Processor Tool's config.py (TARGET_FILENAME_PATTERN) if this is wrong.\r\n+IMAGE_FILENAME_PATTERN = \"{asset_name}_{map_type}_{resolution}.{format}\"\r\n+\r\n+# Fallback extensions to try if the primary format from metadata is not found\r\n+# Order matters - first found will be used.\r\n+FALLBACK_IMAGE_EXTENSIONS = ['png', 'jpg', 'exr', 'tif']\r\n+\r\n+# Map type(s) to use for generating the asset preview AND for aspect ratio calculation reference\r\n+# The script will look for these in order and use the first one found.\r\n+REFERENCE_MAP_TYPES = [\"COL\", \"COL-1\", \"COL-2\"] # Used for preview and aspect calc\r\n+# Preferred resolution order for reference image (lowest first is often faster)\r\n+REFERENCE_RESOLUTION_ORDER = [\"1K\", \"512\", \"2K\", \"4K\"] # Adjust as needed\r\n+\r\n+# Mapping from resolution string to numerical value for the HighestResolution node\r\n+RESOLUTION_VALUE_MAP = {\"1K\": 1.0, \"2K\": 2.0, \"4K\": 3.0, \"8K\": 4.0}\r\n+# Order to check resolutions to find the highest present (highest value first)\r\n+RESOLUTION_ORDER_DESC = [\"8K\", \"4K\", \"2K\", \"1K\"] # Add others like \"512\" if needed and map them in RESOLUTION_VALUE_MAP\r\n+\r\n+# Map PBR type strings (from metadata) to Blender color spaces\r\n+# Add more mappings as needed based on your metadata types\r\n+PBR_COLOR_SPACE_MAP = {\r\n+ \"AO\": \"Non-Color\", # Usually Non-Color, but depends on workflow\r\n+ \"COL\": \"sRGB\",\r\n+ \"COL-1\": \"sRGB\", # Handle variants if present in metadata\r\n+ \"COL-2\": \"sRGB\",\r\n+ \"COL-3\": \"sRGB\",\r\n+ \"DISP\": \"Non-Color\",\r\n+ \"NRM\": \"Non-Color\",\r\n+ \"REFL\": \"Non-Color\", # Reflection/Specular\r\n+ \"ROUGH\": \"Non-Color\",\r\n+ \"METAL\": \"Non-Color\",\r\n+ \"OPC\": \"Non-Color\", # Opacity/Alpha\r\n+ \"TRN\": \"Non-Color\", # Transmission\r\n+ \"SSS\": \"sRGB\", # Subsurface Color\r\n+ \"EMISS\": \"sRGB\", # Emission Color\r\n+ \"NRMRGH\": \"Non-Color\", # Added for merged map\r\n+ \"FUZZ\": \"Non-Color\",\r\n+ # Add other types like GLOSS, HEIGHT, etc. if needed\r\n+}\r\n+DEFAULT_COLOR_SPACE = \"sRGB\" # Fallback if map type not in the dictionary\r\n+\r\n+# Map types for which stats should be applied (if found in metadata and node exists)\r\n+# Reads stats from the 'image_stats_1k' section of metadata.json\r\n+APPLY_STATS_FOR_MAP_TYPES = [\"ROUGH\", \"DISP\", \"METAL\", \"AO\", \"REFL\"] # Add others if needed\r\n+\r\n+# --- END USER CONFIGURATION ---\r\n+\r\n+\r\n+# --- Helper Functions ---\r\n+\r\n+def encode_name_b64(name_str):\r\n+ \"\"\"Encodes a string using URL-safe Base64 for node group names.\"\"\"\r\n+ try:\r\n+ # Ensure the input is a string\r\n+ name_str = str(name_str)\r\n+ return base64.urlsafe_b64encode(name_str.encode('utf-8')).decode('ascii')\r\n+ except Exception as e:\r\n+ print(f\" Error base64 encoding '{name_str}': {e}\")\r\n+ return name_str # Fallback to original name on error\r\n+\r\n+def find_nodes_by_label(node_tree, label, node_type=None):\r\n+ \"\"\"Finds ALL nodes in a node tree matching the label and optionally type.\"\"\"\r\n+ if not node_tree:\r\n+ return []\r\n+ matching_nodes = []\r\n+ for node in node_tree.nodes:\r\n+ # Use node.label for labeled nodes, node.name for non-labeled (like Group Input/Output)\r\n+ node_identifier = node.label if node.label else node.name\r\n+ if node_identifier and node_identifier == label:\r\n+ if node_type is None or node.bl_idname == node_type or node.type == node_type: # Check bl_idname and type for flexibility\r\n+ matching_nodes.append(node)\r\n+ return matching_nodes\r\n+\r\n+def add_tag_if_new(asset_data, tag_name):\r\n+ \"\"\"Adds a tag to the asset data if it's not None/empty and doesn't already exist.\"\"\"\r\n+ if not asset_data or not tag_name or not isinstance(tag_name, str):\r\n+ return False\r\n+ cleaned_tag_name = tag_name.strip()\r\n+ if not cleaned_tag_name:\r\n+ return False\r\n+\r\n+ # Check if tag already exists (case-insensitive check might be better sometimes)\r\n+ if cleaned_tag_name not in [t.name for t in asset_data.tags]:\r\n+ try:\r\n+ asset_data.tags.new(cleaned_tag_name)\r\n+ print(f\" + Added Asset Tag: '{cleaned_tag_name}'\")\r\n+ return True\r\n+ except Exception as e:\r\n+ print(f\" Error adding tag '{cleaned_tag_name}': {e}\")\r\n+ return False\r\n+ return False # Tag already existed\r\n+\r\n+def get_color_space(map_type):\r\n+ \"\"\"Returns the appropriate Blender color space name for a given map type string.\"\"\"\r\n+ # Handle potential numbered variants like COL-1, COL-2\r\n+ base_map_type = map_type.split('-')[0]\r\n+ return PBR_COLOR_SPACE_MAP.get(map_type.upper(), # Check full name first (e.g., NRMRGH)\r\n+ PBR_COLOR_SPACE_MAP.get(base_map_type.upper(), DEFAULT_COLOR_SPACE)) # Fallback to base type\r\n+\r\n+def calculate_aspect_correction_factor(image_width, image_height, aspect_string):\r\n+ \"\"\"\r\n+ Calculates the UV X-axis scaling factor needed to correct distortion,\r\n+ based on image dimensions and the aspect_ratio_change_string (\"EVEN\", \"Xnnn\", \"Ynnn\").\r\n+ Mirrors the logic from the original POC script.\r\n+ Returns 1.0 if dimensions are invalid or string is \"EVEN\" or invalid.\r\n+ \"\"\"\r\n+ if image_height <= 0 or image_width <= 0:\r\n+ print(\" Warn: Invalid image dimensions for aspect ratio calculation. Returning 1.0.\")\r\n+ return 1.0\r\n+\r\n+ # Calculate the actual aspect ratio of the image file\r\n+ current_aspect_ratio = image_width / image_height\r\n+\r\n+ if not aspect_string or aspect_string.upper() == \"EVEN\":\r\n+ # If scaling was even, the correction factor is just the image's aspect ratio\r\n+ # to make UVs match the image proportions.\r\n+ # print(f\" Aspect string is EVEN. Correction factor = current aspect ratio: {current_aspect_ratio:.4f}\")\r\n+ return current_aspect_ratio\r\n+\r\n+ # Handle non-uniform scaling cases (\"Xnnn\", \"Ynnn\")\r\n+ # Use search instead of match to find anywhere in string (though unlikely needed based on format)\r\n+ match = re.search(r\"([XY])(\\d+)\", aspect_string, re.IGNORECASE)\r\n+ if not match:\r\n+ print(f\" Warn: Invalid Scaling string format '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f} as fallback.\")\r\n+ return current_aspect_ratio # Fallback to the image's own ratio\r\n+\r\n+ axis = match.group(1).upper()\r\n+ try:\r\n+ amount = int(match.group(2))\r\n+ if amount <= 0:\r\n+ print(f\" Warn: Zero or negative Amount in Scaling string '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n+ return current_aspect_ratio\r\n+ except ValueError:\r\n+ print(f\" Warn: Invalid Amount in Scaling string '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n+ return current_aspect_ratio\r\n+\r\n+ # Apply the non-uniform correction formula based on original script logic\r\n+ scaling_factor_percent = amount / 100.0\r\n+ correction_factor = current_aspect_ratio # Default\r\n+\r\n+ try:\r\n+ if axis == 'X':\r\n+ if scaling_factor_percent == 0: raise ZeroDivisionError(\"X scaling factor is zero\")\r\n+ # If image was stretched horizontally (X > 1), divide UV.x by factor\r\n+ correction_factor = current_aspect_ratio / scaling_factor_percent\r\n+ elif axis == 'Y':\r\n+ # If image was stretched vertically (Y > 1), multiply UV.x by factor\r\n+ correction_factor = current_aspect_ratio * scaling_factor_percent\r\n+ # No 'else' needed as regex ensures X or Y\r\n+\r\n+ except ZeroDivisionError as e:\r\n+ print(f\" Warn: Division by zero during aspect factor calculation ({e}). Returning current ratio {current_aspect_ratio:.4f}.\")\r\n+ return current_aspect_ratio\r\n+ except Exception as e:\r\n+ print(f\" Error calculating aspect correction factor: {e}. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n+ return current_aspect_ratio\r\n+\r\n+ # print(f\" Calculated aspect correction factor: {correction_factor:.4f} (from {image_width}x{image_height}, Scaling='{aspect_string}')\")\r\n+ return correction_factor\r\n+\r\n+\r\n+def reconstruct_image_path_with_fallback(asset_dir_path, asset_name, map_type, resolution, primary_format=None):\r\n+ \"\"\"\r\n+ Constructs the expected image file path.\r\n+ If primary_format is provided, tries that first.\r\n+ Then falls back to common extensions if the path doesn't exist or primary_format was None.\r\n+ Returns the found path as a string, or None if not found.\r\n+ \"\"\"\r\n+ if not all([asset_dir_path, asset_name, map_type, resolution]):\r\n+ print(f\" !!! ERROR: Missing data for path reconstruction ({asset_name}/{map_type}/{resolution}).\")\r\n+ return None\r\n+\r\n+ found_path = None\r\n+\r\n+ # 1. Try the primary format if provided\r\n+ if primary_format:\r\n+ try:\r\n+ filename = IMAGE_FILENAME_PATTERN.format(\r\n+ asset_name=asset_name,\r\n+ map_type=map_type,\r\n+ resolution=resolution,\r\n+ format=primary_format.lower() # Ensure format is lowercase\r\n+ )\r\n+ primary_path = asset_dir_path / filename\r\n+ if primary_path.is_file():\r\n+ # print(f\" Found primary path: {str(primary_path)}\") # Verbose\r\n+ return str(primary_path)\r\n+ # else: print(f\" Primary path not found: {str(primary_path)}\") # Verbose\r\n+ except KeyError as e:\r\n+ print(f\" !!! ERROR: Missing key '{e}' in IMAGE_FILENAME_PATTERN. Cannot reconstruct path.\")\r\n+ return None # Cannot proceed without valid pattern\r\n+ except Exception as e:\r\n+ print(f\" !!! ERROR reconstructing primary image path: {e}\")\r\n+ # Continue to fallback\r\n+\r\n+ # 2. Try fallback extensions\r\n+ # print(f\" Trying fallback extensions for {map_type}/{resolution}...\") # Verbose\r\n+ for ext in FALLBACK_IMAGE_EXTENSIONS:\r\n+ # Skip if we already tried this extension as primary (and it failed)\r\n+ if primary_format and ext.lower() == primary_format.lower():\r\n+ continue\r\n+ try:\r\n+ fallback_filename = IMAGE_FILENAME_PATTERN.format(\r\n+ asset_name=asset_name,\r\n+ map_type=map_type,\r\n+ resolution=resolution,\r\n+ format=ext.lower()\r\n+ )\r\n+ fallback_path = asset_dir_path / fallback_filename\r\n+ if fallback_path.is_file():\r\n+ print(f\" Found fallback path: {str(fallback_path)}\")\r\n+ return str(fallback_path) # Found it!\r\n+ except KeyError:\r\n+ # Should not happen if primary format worked, but handle defensively\r\n+ print(f\" !!! ERROR: Missing key in IMAGE_FILENAME_PATTERN during fallback. Cannot reconstruct path.\")\r\n+ return None\r\n+ except Exception as e_fallback:\r\n+ print(f\" !!! ERROR reconstructing fallback image path ({ext}): {e_fallback}\")\r\n+ continue # Try next extension\r\n+\r\n+ # If we get here, neither primary nor fallbacks worked\r\n+ if primary_format:\r\n+ print(f\" !!! ERROR: Could not find image file for {map_type}/{resolution} using primary format '{primary_format}' or fallbacks {FALLBACK_IMAGE_EXTENSIONS}.\")\r\n+ else:\r\n+ print(f\" !!! ERROR: Could not find image file for {map_type}/{resolution} using fallbacks {FALLBACK_IMAGE_EXTENSIONS}.\")\r\n+ return None # Not found after all checks\r\n+\r\n+\r\n+# --- Manifest Functions ---\r\n+\r\n+def get_manifest_path(context):\r\n+ \"\"\"Gets the expected path for the manifest JSON file.\"\"\"\r\n+ if not context or not context.blend_data or not context.blend_data.filepath:\r\n+ return None # Cannot determine path if blend file is not saved\r\n+ blend_path = Path(context.blend_data.filepath)\r\n+ manifest_filename = f\"{blend_path.stem}_manifest.json\"\r\n+ return blend_path.parent / manifest_filename\r\n+\r\n+def load_manifest(context):\r\n+ \"\"\"Loads the manifest data from the JSON file.\"\"\"\r\n+ if not ENABLE_MANIFEST:\r\n+ return {} # Manifest disabled\r\n+\r\n+ manifest_path = get_manifest_path(context)\r\n+ if not manifest_path:\r\n+ print(\" Manifest Info: Blend file not saved. Cannot load manifest.\")\r\n+ return {} # Cannot load without a path\r\n+\r\n+ if not manifest_path.exists():\r\n+ print(f\" Manifest Info: No manifest file found at '{manifest_path.name}'. Starting fresh.\")\r\n+ return {} # No manifest file exists yet\r\n+\r\n+ try:\r\n+ with open(manifest_path, 'r', encoding='utf-8') as f:\r\n+ data = json.load(f)\r\n+ print(f\" Manifest Loaded from: {manifest_path.name}\")\r\n+ # Basic validation (check if it's a dictionary)\r\n+ if not isinstance(data, dict):\r\n+ print(f\"!!! WARNING: Manifest file '{manifest_path.name}' has invalid format (not a dictionary). Starting fresh. !!!\")\r\n+ return {}\r\n+ return data\r\n+ except json.JSONDecodeError:\r\n+ print(f\"!!! WARNING: Manifest file '{manifest_path.name}' is corrupted. Starting fresh. !!!\")\r\n+ return {}\r\n+ except Exception as e:\r\n+ print(f\"!!! ERROR: Could not load manifest file '{manifest_path.name}': {e} !!!\")\r\n+ return {} # Treat as starting fresh on error\r\n+\r\n+def save_manifest(context, manifest_data):\r\n+ \"\"\"Saves the manifest data to the JSON file.\"\"\"\r\n+ if not ENABLE_MANIFEST or not manifest_data: # Don't save if disabled or empty\r\n+ return False\r\n+\r\n+ manifest_path = get_manifest_path(context)\r\n+ if not manifest_path:\r\n+ print(\" Manifest Error: Blend file not saved. Cannot save manifest.\")\r\n+ return False\r\n+\r\n+ try:\r\n+ with open(manifest_path, 'w', encoding='utf-8') as f:\r\n+ json.dump(manifest_data, f, indent=2, sort_keys=True) # Use indent and sort for readability\r\n+ print(f\" Manifest Saved to: {manifest_path.name}\")\r\n+ return True\r\n+ except Exception as e:\r\n+ print(f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\\n\"\r\n+ f\"!!! Manifest save FAILED to '{manifest_path.name}': {e} !!!\\n\"\r\n+ f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\")\r\n+ return False\r\n+\r\n+def is_asset_processed(manifest_data, asset_name):\r\n+ \"\"\"Checks if the entire asset (all its maps/resolutions) is marked as processed.\"\"\"\r\n+ if not ENABLE_MANIFEST: return False\r\n+ # Basic check if asset entry exists. Detailed check happens at map level.\r\n+ return asset_name in manifest_data\r\n+\r\n+def is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n+ \"\"\"Checks if a specific map type and resolution for an asset is processed.\"\"\"\r\n+ if not ENABLE_MANIFEST: return False\r\n+ return resolution in manifest_data.get(asset_name, {}).get(map_type, [])\r\n+\r\n+def update_manifest(manifest_data, asset_name, map_type=None, resolution=None):\r\n+ \"\"\"Updates the manifest dictionary in memory.\"\"\"\r\n+ if not ENABLE_MANIFEST: return False\r\n+\r\n+ # Ensure asset entry exists\r\n+ if asset_name not in manifest_data:\r\n+ manifest_data[asset_name] = {}\r\n+\r\n+ # If map_type and resolution are provided, update the specific map entry\r\n+ if map_type and resolution:\r\n+ if map_type not in manifest_data[asset_name]:\r\n+ manifest_data[asset_name][map_type] = []\r\n+\r\n+ if resolution not in manifest_data[asset_name][map_type]:\r\n+ manifest_data[asset_name][map_type].append(resolution)\r\n+ manifest_data[asset_name][map_type].sort() # Keep sorted\r\n+ return True # Indicate that a change was made\r\n+ return False # No change made to this specific map/res\r\n+\r\n+\r\n+# --- Core Logic ---\r\n+\r\n+def process_library(context, asset_library_root_override=None): # Add override parameter\r\n+ global ENABLE_MANIFEST # Declare intent to modify global if needed\r\n+ global PROCESSED_ASSET_LIBRARY_ROOT # Allow modification of global\r\n+ \"\"\"Scans the library, reads metadata, creates/updates node groups.\"\"\"\r\n+ start_time = time.time()\r\n+ print(f\"\\n--- Starting Node Group Processing ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---\")\r\n+ print(f\" DEBUG: Received asset_library_root_override: {asset_library_root_override}\") # DEBUG LOG (Indented)\r\n+\r\n+ # --- Determine Asset Library Root ---\r\n+ if asset_library_root_override:\r\n+ PROCESSED_ASSET_LIBRARY_ROOT = asset_library_root_override\r\n+ print(f\"Using asset library root from argument: '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n+ elif not PROCESSED_ASSET_LIBRARY_ROOT:\r\n+ print(\"!!! ERROR: Processed asset library root not set in script and not provided via argument.\")\r\n+ print(\"--- Script aborted. ---\")\r\n+ return False\r\n+ print(f\" DEBUG: Using final PROCESSED_ASSET_LIBRARY_ROOT: {PROCESSED_ASSET_LIBRARY_ROOT}\") # DEBUG LOG (Indented)\r\n+\r\n+ # --- Pre-run Checks ---\r\n+ print(\"Performing pre-run checks...\")\r\n+ valid_setup = True\r\n+ # 1. Check Library Root Path\r\n+ root_path = Path(PROCESSED_ASSET_LIBRARY_ROOT)\r\n+ if not root_path.is_dir():\r\n+ print(f\"!!! ERROR: Processed asset library root directory not found or not a directory:\")\r\n+ print(f\"!!! '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n+ valid_setup = False\r\n+ else:\r\n+ print(f\" Asset Library Root: '{root_path}'\")\r\n+ print(f\" DEBUG: Checking for templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\") # DEBUG LOG (Indented)\r\n+\r\n+ # 2. Check Templates\r\n+ template_parent = bpy.data.node_groups.get(PARENT_TEMPLATE_NAME)\r\n+ template_child = bpy.data.node_groups.get(CHILD_TEMPLATE_NAME)\r\n+ if not template_parent:\r\n+ print(f\"!!! ERROR: Parent template node group '{PARENT_TEMPLATE_NAME}' not found in this Blender file.\")\r\n+ valid_setup = False\r\n+ if not template_child:\r\n+ print(f\"!!! ERROR: Child template node group '{CHILD_TEMPLATE_NAME}' not found in this Blender file.\")\r\n+ valid_setup = False\r\n+ if template_parent and template_child:\r\n+ print(f\" Found Templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\")\r\n+ print(f\" DEBUG: Template Parent Found: {template_parent is not None}\") # DEBUG LOG (Indented)\r\n+ print(f\" DEBUG: Template Child Found: {template_child is not None}\") # DEBUG LOG (Indented)\r\n+\r\n+ # 3. Check Blend File Saved (if manifest enabled)\r\n+ if ENABLE_MANIFEST and not context.blend_data.filepath:\r\n+ print(f\"!!! WARNING: Manifest is enabled, but the current Blender file is not saved.\")\r\n+ print(f\"!!! Manifest cannot be loaded or saved. Processing will continue without manifest checks.\")\r\n+ ENABLE_MANIFEST = False # Disable manifest for this run\r\n+\r\n+ if not valid_setup:\r\n+ print(\"\\n--- Script aborted due to configuration errors. Please fix the issues above. ---\")\r\n+ return False\r\n+ print(\"Pre-run checks passed.\")\r\n+ # --- End Pre-run Checks ---\r\n+\r\n+ manifest_data = load_manifest(context)\r\n+ manifest_needs_saving = False\r\n+\r\n+ # --- Initialize Counters ---\r\n+ metadata_files_found = 0\r\n+ assets_processed = 0\r\n+ assets_skipped_manifest = 0\r\n+ parent_groups_created = 0\r\n+ parent_groups_updated = 0\r\n+ child_groups_created = 0\r\n+ child_groups_updated = 0\r\n+ images_loaded = 0\r\n+ images_assigned = 0\r\n+ maps_processed = 0\r\n+ maps_skipped_manifest = 0\r\n+ errors_encountered = 0\r\n+ previews_set = 0\r\n+ highest_res_set = 0\r\n+ aspect_ratio_set = 0\r\n+ # --- End Counters ---\r\n+\r\n+ print(f\"\\nScanning for metadata files in '{root_path}'...\")\r\n+\r\n+ # --- Scan for metadata.json ---\r\n+ # Scan one level deeper for supplier folders (e.g., Poliigon)\r\n+ # Then scan within each supplier for asset folders containing metadata.json\r\n+ metadata_paths = []\r\n+ for supplier_dir in root_path.iterdir():\r\n+ if supplier_dir.is_dir():\r\n+ # Now look for asset folders inside the supplier directory\r\n+ for asset_dir in supplier_dir.iterdir():\r\n+ if asset_dir.is_dir():\r\n+ metadata_file = asset_dir / 'metadata.json'\r\n+ if metadata_file.is_file():\r\n+ metadata_paths.append(metadata_file)\r\n+\r\n+ metadata_files_found = len(metadata_paths)\r\n+ print(f\"Found {metadata_files_found} metadata.json files.\")\r\n+ print(f\" DEBUG: Metadata paths found: {metadata_paths}\") # DEBUG LOG (Indented)\r\n+\r\n+ if metadata_files_found == 0:\r\n+ print(\"No metadata files found. Nothing to process.\")\r\n+ print(\"--- Script Finished ---\")\r\n+ return True # No work needed is considered success\r\n+\r\n+ # --- Process Each Metadata File ---\r\n+ for metadata_path in metadata_paths:\r\n+ asset_dir_path = metadata_path.parent # Get the directory containing the metadata file\r\n+ print(f\"\\n--- Processing Metadata: {metadata_path.relative_to(root_path)} ---\")\r\n+ print(f\" DEBUG: Processing file: {metadata_path}\") # DEBUG LOG (Indented)\r\n+ try:\r\n+ with open(metadata_path, 'r', encoding='utf-8') as f:\r\n+ metadata = json.load(f)\r\n+\r\n+ # --- Extract Key Info ---\r\n+ asset_name = metadata.get(\"asset_name\")\r\n+ supplier_name = metadata.get(\"supplier_name\")\r\n+ archetype = metadata.get(\"archetype\")\r\n+ # Get map info from the correct keys\r\n+ processed_resolutions = metadata.get(\"processed_map_resolutions\", {}) # Default to empty dict\r\n+ merged_resolutions = metadata.get(\"merged_map_resolutions\", {}) # Get merged maps too\r\n+ map_details = metadata.get(\"map_details\", {}) # Default to empty dict\r\n+ image_stats_1k = metadata.get(\"image_stats_1k\") # Dict: {map_type: {stats}}\r\n+ aspect_string = metadata.get(\"aspect_ratio_change_string\")\r\n+\r\n+ # Combine processed and merged maps for iteration\r\n+ all_map_resolutions = {**processed_resolutions, **merged_resolutions}\r\n+\r\n+ # Validate essential data\r\n+ if not asset_name:\r\n+ print(f\" !!! ERROR: Metadata file is missing 'asset_name'. Skipping.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ if not all_map_resolutions:\r\n+ print(f\" !!! ERROR: Metadata file has no 'processed_map_resolutions' or 'merged_map_resolutions'. Skipping asset '{asset_name}'.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ # map_details check remains a warning as merged maps won't be in it\r\n+ print(f\" DEBUG: Valid metadata loaded for asset: {asset_name}\") # DEBUG LOG (Indented)\r\n+\r\n+ print(f\" Asset Name: {asset_name}\")\r\n+\r\n+ # --- Determine Highest Resolution ---\r\n+ highest_resolution_value = 0.0\r\n+ highest_resolution_str = \"Unknown\"\r\n+ all_resolutions_present = set()\r\n+ if all_map_resolutions: # Check combined dict\r\n+ for res_list in all_map_resolutions.values():\r\n+ if isinstance(res_list, list):\r\n+ all_resolutions_present.update(res_list)\r\n+\r\n+ if all_resolutions_present:\r\n+ for res_str in RESOLUTION_ORDER_DESC:\r\n+ if res_str in all_resolutions_present:\r\n+ highest_resolution_value = RESOLUTION_VALUE_MAP.get(res_str, 0.0)\r\n+ highest_resolution_str = res_str\r\n+ if highest_resolution_value > 0.0:\r\n+ break # Found the highest valid resolution\r\n+\r\n+ print(f\" Highest resolution found: {highest_resolution_str} (Value: {highest_resolution_value})\")\r\n+\r\n+ # --- Load Reference Image for Aspect Ratio ---\r\n+ ref_image_path = None\r\n+ ref_image_width = 0\r\n+ ref_image_height = 0\r\n+ ref_image_loaded = False\r\n+ # Use combined resolutions dict to find reference map\r\n+ for ref_map_type in REFERENCE_MAP_TYPES:\r\n+ if ref_map_type in all_map_resolutions:\r\n+ available_resolutions = all_map_resolutions[ref_map_type]\r\n+ lowest_res = None\r\n+ for res_pref in REFERENCE_RESOLUTION_ORDER:\r\n+ if res_pref in available_resolutions:\r\n+ lowest_res = res_pref\r\n+ break\r\n+ if lowest_res:\r\n+ # Get format from map_details if available, otherwise None\r\n+ ref_map_details = map_details.get(ref_map_type, {})\r\n+ ref_format = ref_map_details.get(\"output_format\")\r\n+ ref_image_path = reconstruct_image_path_with_fallback(\r\n+ asset_dir_path=asset_dir_path,\r\n+ asset_name=asset_name,\r\n+ map_type=ref_map_type,\r\n+ resolution=lowest_res,\r\n+ primary_format=ref_format # Pass None if not in map_details\r\n+ )\r\n+ if ref_image_path:\r\n+ break # Found a suitable reference image path\r\n+\r\n+ if ref_image_path:\r\n+ print(f\" Loading reference image for aspect ratio: {Path(ref_image_path).name}\")\r\n+ try:\r\n+ # Load image temporarily\r\n+ ref_img = bpy.data.images.load(ref_image_path, check_existing=True)\r\n+ if ref_img:\r\n+ ref_image_width = ref_img.size[0]\r\n+ ref_image_height = ref_img.size[1]\r\n+ ref_image_loaded = True\r\n+ print(f\" Reference image dimensions: {ref_image_width}x{ref_image_height}\")\r\n+ # Remove the temporary image datablock to save memory\r\n+ bpy.data.images.remove(ref_img)\r\n+ else:\r\n+ print(f\" !!! ERROR: Failed loading reference image via bpy.data.images.load: {ref_image_path}\")\r\n+ except Exception as e_ref_load:\r\n+ print(f\" !!! ERROR loading reference image '{ref_image_path}': {e_ref_load}\")\r\n+ else:\r\n+ print(f\" !!! WARNING: Could not find suitable reference image ({REFERENCE_MAP_TYPES} at {REFERENCE_RESOLUTION_ORDER}) for aspect ratio calculation.\")\r\n+\r\n+\r\n+ # --- Manifest Check (Asset Level - Basic) ---\r\n+ if ENABLE_MANIFEST and is_asset_processed(manifest_data, asset_name):\r\n+ # Perform a quick check if *any* map needs processing for this asset\r\n+ needs_processing = False\r\n+ for map_type, resolutions in all_map_resolutions.items(): # Check combined maps\r\n+ for resolution in resolutions:\r\n+ if not is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n+ needs_processing = True\r\n+ break\r\n+ if needs_processing:\r\n+ break\r\n+ if not needs_processing:\r\n+ print(f\" Skipping asset '{asset_name}' (already fully processed according to manifest).\")\r\n+ assets_skipped_manifest += 1\r\n+ continue # Skip to next metadata file\r\n+\r\n+ # --- Parent Group Handling ---\r\n+ target_parent_name = f\"PBRSET_{asset_name}\"\r\n+ parent_group = bpy.data.node_groups.get(target_parent_name)\r\n+ is_new_parent = False\r\n+\r\n+ if parent_group is None:\r\n+ print(f\" Creating new parent group: '{target_parent_name}'\")\r\n+ print(f\" DEBUG: Copying parent template '{PARENT_TEMPLATE_NAME}'\") # DEBUG LOG (Indented)\r\n+ parent_group = template_parent.copy()\r\n+ if not parent_group:\r\n+ print(f\" !!! ERROR: Failed to copy parent template '{PARENT_TEMPLATE_NAME}'. Skipping asset '{asset_name}'.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ parent_group.name = target_parent_name\r\n+ parent_groups_created += 1\r\n+ is_new_parent = True\r\n+ else:\r\n+ print(f\" Updating existing parent group: '{target_parent_name}'\")\r\n+ print(f\" DEBUG: Found existing parent group.\") # DEBUG LOG (Indented)\r\n+ parent_groups_updated += 1\r\n+\r\n+ # Ensure marked as asset\r\n+ if not parent_group.asset_data:\r\n+ try:\r\n+ parent_group.asset_mark()\r\n+ print(f\" Marked '{parent_group.name}' as asset.\")\r\n+ except Exception as e_mark:\r\n+ print(f\" !!! ERROR: Failed to mark group '{parent_group.name}' as asset: {e_mark}\")\r\n+ # Continue processing other parts if possible\r\n+\r\n+ # Apply Asset Tags\r\n+ if parent_group.asset_data:\r\n+ if supplier_name:\r\n+ add_tag_if_new(parent_group.asset_data, supplier_name)\r\n+ if archetype:\r\n+ add_tag_if_new(parent_group.asset_data, archetype)\r\n+ # Add other tags if needed\r\n+ # else: print(f\" Warn: Cannot apply tags, asset_data not available for '{parent_group.name}'.\") # Optional warning\r\n+\r\n+ # Apply Aspect Ratio Correction\r\n+ aspect_nodes = find_nodes_by_label(parent_group, ASPECT_RATIO_NODE_LABEL, 'ShaderNodeValue')\r\n+ if aspect_nodes:\r\n+ aspect_node = aspect_nodes[0]\r\n+ correction_factor = 1.0 # Default if ref image fails\r\n+ if ref_image_loaded:\r\n+ correction_factor = calculate_aspect_correction_factor(ref_image_width, ref_image_height, aspect_string)\r\n+ print(f\" Calculated aspect correction factor: {correction_factor:.4f}\")\r\n+ else:\r\n+ print(f\" !!! WARNING: Using default aspect ratio correction (1.0) due to missing reference image.\")\r\n+\r\n+ # Check if update is needed\r\n+ current_val = aspect_node.outputs[0].default_value\r\n+ if abs(current_val - correction_factor) > 0.0001:\r\n+ aspect_node.outputs[0].default_value = correction_factor\r\n+ print(f\" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f} (was {current_val:.4f})\")\r\n+ aspect_ratio_set += 1\r\n+ # else: print(f\" Warn: Aspect ratio node '{ASPECT_RATIO_NODE_LABEL}' not found in parent group.\") # Optional\r\n+\r\n+ # Apply Highest Resolution Value\r\n+ hr_nodes = find_nodes_by_label(parent_group, HIGHEST_RESOLUTION_NODE_LABEL, 'ShaderNodeValue')\r\n+ if hr_nodes:\r\n+ hr_node = hr_nodes[0]\r\n+ current_hr_val = hr_node.outputs[0].default_value\r\n+ if highest_resolution_value > 0.0 and abs(current_hr_val - highest_resolution_value) > 0.001:\r\n+ hr_node.outputs[0].default_value = highest_resolution_value\r\n+ print(f\" Set '{HIGHEST_RESOLUTION_NODE_LABEL}' value to {highest_resolution_value} ({highest_resolution_str}) (was {current_hr_val:.1f})\")\r\n+ highest_res_set += 1 # Count successful sets\r\n+ # else: print(f\" Warn: Highest resolution node '{HIGHEST_RESOLUTION_NODE_LABEL}' not found in parent group.\") # Optional\r\n+\r\n+\r\n+ # Apply Stats (using image_stats_1k)\r\n+ if image_stats_1k and isinstance(image_stats_1k, dict):\r\n+ for map_type_to_stat in APPLY_STATS_FOR_MAP_TYPES:\r\n+ if map_type_to_stat in image_stats_1k:\r\n+ # Find the stats node in the parent group\r\n+ stats_node_label = f\"{STATS_NODE_PREFIX}{map_type_to_stat}\"\r\n+ stats_nodes = find_nodes_by_label(parent_group, stats_node_label, 'ShaderNodeCombineXYZ')\r\n+ if stats_nodes:\r\n+ stats_node = stats_nodes[0]\r\n+ stats = image_stats_1k[map_type_to_stat] # Get stats dict for this map type\r\n+\r\n+ if stats and isinstance(stats, dict):\r\n+ # Handle potential list format for RGB stats (use first value) or direct float\r\n+ def get_stat_value(stat_val):\r\n+ if isinstance(stat_val, list):\r\n+ return stat_val[0] if stat_val else None\r\n+ return stat_val\r\n+\r\n+ min_val = get_stat_value(stats.get(\"min\"))\r\n+ max_val = get_stat_value(stats.get(\"max\"))\r\n+ mean_val = get_stat_value(stats.get(\"mean\")) # Often stored as 'mean' or 'avg'\r\n+\r\n+ updated_stat = False\r\n+ # Check inputs exist before assigning\r\n+ input_x = stats_node.inputs.get(\"X\")\r\n+ input_y = stats_node.inputs.get(\"Y\")\r\n+ input_z = stats_node.inputs.get(\"Z\")\r\n+\r\n+ if input_x and min_val is not None and abs(input_x.default_value - min_val) > 0.0001:\r\n+ input_x.default_value = min_val\r\n+ updated_stat = True\r\n+ if input_y and max_val is not None and abs(input_y.default_value - max_val) > 0.0001:\r\n+ input_y.default_value = max_val\r\n+ updated_stat = True\r\n+ if input_z and mean_val is not None and abs(input_z.default_value - mean_val) > 0.0001:\r\n+ input_z.default_value = mean_val\r\n+ updated_stat = True\r\n+\r\n+ if updated_stat:\r\n+ print(f\" Set stats in '{stats_node_label}': Min={min_val:.4f}, Max={max_val:.4f}, Mean={mean_val:.4f}\")\r\n+ # else: print(f\" Info: No valid 'stats' dictionary found for map type '{map_type_to_stat}' in image_stats_1k.\") # Optional\r\n+ # else: print(f\" Warn: Stats node '{stats_node_label}' not found in parent group.\") # Optional\r\n+ # else: print(f\" Info: Map type '{map_type_to_stat}' not present in image_stats_1k for stats application.\") # Optional\r\n+ # else: print(f\" Warn: 'image_stats_1k' missing or invalid in metadata.\") # Optional\r\n+\r\n+ # --- Set Asset Preview (only for new parent groups) ---\r\n+ # Use the reference image path found earlier if available\r\n+ if is_new_parent and parent_group.asset_data:\r\n+ if ref_image_loaded and ref_image_path: # Check if ref image was successfully loaded earlier\r\n+ print(f\" Attempting to set preview from reference image: {Path(ref_image_path).name}\")\r\n+ try:\r\n+ # Ensure the ID (node group) is the active one for the operator context\r\n+ with context.temp_override(id=parent_group):\r\n+ bpy.ops.ed.lib_id_load_custom_preview(filepath=ref_image_path)\r\n+ print(f\" Successfully set custom preview.\")\r\n+ previews_set += 1\r\n+ except Exception as e_preview:\r\n+ print(f\" !!! ERROR setting custom preview: {e_preview}\")\r\n+ errors_encountered += 1\r\n+ else:\r\n+ print(f\" Info: Could not set preview for '{asset_name}' as reference image was not found or loaded.\")\r\n+\r\n+\r\n+ # --- Child Group Handling ---\r\n+ # Iterate through the COMBINED map types\r\n+ print(f\" DEBUG: Starting child group loop for asset '{asset_name}'. Map types: {list(all_map_resolutions.keys())}\") # DEBUG LOG (Indented)\r\n+ for map_type, resolutions in all_map_resolutions.items():\r\n+ print(f\" Processing Map Type: {map_type}\")\r\n+\r\n+ # Determine if this is a merged map (not in map_details)\r\n+ is_merged_map = map_type not in map_details\r\n+\r\n+ # Get details for this map type if available\r\n+ current_map_details = map_details.get(map_type, {})\r\n+ # For merged maps, primary_format will be None\r\n+ output_format = current_map_details.get(\"output_format\")\r\n+\r\n+ if not output_format and not is_merged_map:\r\n+ # This case should ideally not happen if metadata is well-formed\r\n+ # but handle defensively for processed maps.\r\n+ print(f\" !!! WARNING: Missing 'output_format' in map_details for processed map '{map_type}'. Path reconstruction might fail.\")\r\n+ # We will rely solely on fallback for this map type\r\n+\r\n+ # Find placeholder node in parent\r\n+ holder_nodes = find_nodes_by_label(parent_group, map_type, 'ShaderNodeGroup')\r\n+ if not holder_nodes:\r\n+ print(f\" !!! WARNING: No placeholder node labeled '{map_type}' found in parent group '{parent_group.name}'. Skipping this map type.\")\r\n+ continue\r\n+ holder_node = holder_nodes[0] # Assume first is correct\r\n+ print(f\" DEBUG: Found placeholder node '{holder_node.label}' for map type '{map_type}'.\") # DEBUG LOG (Indented)\r\n+\r\n+ # Determine child group name (LOGICAL and ENCODED)\r\n+ logical_child_name = f\"{asset_name}_{map_type}\"\r\n+ target_child_name_b64 = encode_name_b64(logical_child_name) # Use Base64 name\r\n+\r\n+ child_group = bpy.data.node_groups.get(target_child_name_b64) # Find using encoded name\r\n+ is_new_child = False\r\n+\r\n+ if child_group is None:\r\n+ print(f\" DEBUG: Child group '{target_child_name_b64}' not found. Creating new one.\") # DEBUG LOG (Indented)\r\n+ # print(f\" Creating new child group: '{target_child_name_b64}' (logical: '{logical_child_name}')\") # Verbose\r\n+ child_group = template_child.copy()\r\n+ if not child_group:\r\n+ print(f\" !!! ERROR: Failed to copy child template '{CHILD_TEMPLATE_NAME}'. Skipping map type '{map_type}'.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ child_group.name = target_child_name_b64 # Set encoded name\r\n+ child_groups_created += 1\r\n+ is_new_child = True\r\n+ else:\r\n+ print(f\" DEBUG: Found existing child group '{target_child_name_b64}'.\") # DEBUG LOG (Indented)\r\n+ # print(f\" Updating existing child group: '{target_child_name_b64}'\") # Verbose\r\n+ child_groups_updated += 1\r\n+\r\n+ # Assign child group to placeholder if needed\r\n+ if holder_node.node_tree != child_group:\r\n+ try:\r\n+ holder_node.node_tree = child_group\r\n+ print(f\" Assigned child group '{child_group.name}' to placeholder '{holder_node.label}'.\")\r\n+ except TypeError as e_assign: # Catch potential type errors if placeholder isn't a group node\r\n+ print(f\" !!! ERROR: Could not assign child group to placeholder '{holder_node.label}'. Is it a Group Node? Error: {e_assign}\")\r\n+ continue # Skip this map type if assignment fails\r\n+\r\n+ # Link placeholder output to parent output socket\r\n+ try:\r\n+ # Find parent's output node\r\n+ group_output_node = next((n for n in parent_group.nodes if n.type == 'GROUP_OUTPUT'), None)\r\n+ if group_output_node:\r\n+ # Get the specific output socket on the placeholder (usually index 0 or named 'Color'/'Value')\r\n+ source_socket = holder_node.outputs.get(\"Color\") or holder_node.outputs.get(\"Value\") or holder_node.outputs[0]\r\n+ # Get the specific input socket on the parent output node (matching map_type)\r\n+ target_socket = group_output_node.inputs.get(map_type)\r\n+\r\n+ if source_socket and target_socket:\r\n+ # Check if link already exists\r\n+ link_exists = any(link.from_socket == source_socket and link.to_socket == target_socket for link in parent_group.links)\r\n+ if not link_exists:\r\n+ parent_group.links.new(source_socket, target_socket)\r\n+ print(f\" Linked '{holder_node.label}' output to parent output socket '{map_type}'.\")\r\n+ # else: # Optional warnings\r\n+ # if not source_socket: print(f\" Warn: Could not find suitable output socket on placeholder '{holder_node.label}'.\")\r\n+ # if not target_socket: print(f\" Warn: Could not find input socket '{map_type}' on parent output node.\")\r\n+ # else: print(f\" Warn: Parent group '{parent_group.name}' has no Group Output node.\")\r\n+\r\n+ except Exception as e_link:\r\n+ print(f\" !!! ERROR linking sockets for '{map_type}': {e_link}\")\r\n+\r\n+ # Ensure parent output socket type is Color (if it exists)\r\n+ try:\r\n+ # Use the interface API for modern Blender versions\r\n+ item = parent_group.interface.items_tree.get(map_type)\r\n+ if item and item.item_type == 'SOCKET' and item.in_out == 'OUTPUT':\r\n+ # Common useful types: 'NodeSocketColor', 'NodeSocketVector', 'NodeSocketFloat'\r\n+ # Defaulting to Color seems reasonable for most PBR outputs\r\n+ if item.socket_type != 'NodeSocketColor':\r\n+ item.socket_type = 'NodeSocketColor'\r\n+ # print(f\" Set parent output socket '{map_type}' type to Color.\") # Optional info\r\n+ except Exception as e_sock_type:\r\n+ print(f\" Warn: Could not verify/set socket type for '{map_type}': {e_sock_type}\")\r\n+\r\n+\r\n+ # --- Image Node Handling (Inside Child Group) ---\r\n+ if not isinstance(resolutions, list):\r\n+ print(f\" !!! ERROR: Invalid format for resolutions list for map type '{map_type}'. Skipping.\")\r\n+ continue\r\n+\r\n+ for resolution in resolutions:\r\n+ # --- Manifest Check (Map/Resolution Level) ---\r\n+ if ENABLE_MANIFEST and is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n+ # print(f\" Skipping {resolution} (Manifest)\") # Verbose\r\n+ maps_skipped_manifest += 1\r\n+ continue\r\n+ print(f\" DEBUG: Processing map '{map_type}' resolution '{resolution}'. Manifest skip check passed.\") # DEBUG LOG (Indented)\r\n+\r\n+ print(f\" Processing Resolution: {resolution}\")\r\n+\r\n+ # Reconstruct the image path using fallback logic\r\n+ # Pass output_format (which might be None for merged maps)\r\n+ image_path_str = reconstruct_image_path_with_fallback(\r\n+ asset_dir_path=asset_dir_path,\r\n+ asset_name=asset_name,\r\n+ map_type=map_type,\r\n+ resolution=resolution,\r\n+ primary_format=output_format\r\n+ )\r\n+ print(f\" DEBUG: Reconstructed image path for {map_type}/{resolution}: {image_path_str}\") # DEBUG LOG (Indented)\r\n+\r\n+ if not image_path_str:\r\n+ # Error already printed by reconstruct function\r\n+ errors_encountered += 1\r\n+ continue # Skip this resolution if path not found\r\n+\r\n+ # Find image texture node within the CHILD group (labeled by resolution, e.g., \"4K\")\r\n+ image_nodes = find_nodes_by_label(child_group, resolution, 'ShaderNodeTexImage')\r\n+ if not image_nodes:\r\n+ print(f\" !!! WARNING: No Image Texture node labeled '{resolution}' found in child group '{child_group.name}'. Cannot assign image.\")\r\n+ continue # Skip this resolution if node not found\r\n+ print(f\" DEBUG: Found {len(image_nodes)} image node(s) labeled '{resolution}' in child group '{child_group.name}'.\") # DEBUG LOG (Indented)\r\n+\r\n+ # --- Load Image ---\r\n+ img = None\r\n+ image_load_failed = False\r\n+ try:\r\n+ image_path = Path(image_path_str) # Path object created from already found path string\r\n+ # Use check_existing=True to reuse existing datablocks if path matches\r\n+ img = bpy.data.images.load(str(image_path), check_existing=True)\r\n+ if not img:\r\n+ print(f\" !!! ERROR: Failed loading image via bpy.data.images.load: {image_path_str}\")\r\n+ image_load_failed = True\r\n+ else:\r\n+ # Only count as loaded if bpy.data.images.load succeeded\r\n+ # Check if it's newly loaded or reused\r\n+ is_newly_loaded = img.library is None # Newly loaded images don't have a library initially\r\n+ if is_newly_loaded: images_loaded += 1\r\n+\r\n+ except RuntimeError as e_runtime_load:\r\n+ # Catch specific Blender runtime errors (e.g., unsupported format)\r\n+ print(f\" !!! ERROR loading image '{image_path_str}': {e_runtime_load}\")\r\n+ image_load_failed = True\r\n+ except Exception as e_gen_load:\r\n+ print(f\" !!! UNEXPECTED ERROR loading image '{image_path_str}': {e_gen_load}\")\r\n+ image_load_failed = True\r\n+ errors_encountered += 1\r\n+\r\n+ # --- Assign Image & Set Color Space ---\r\n+ if not image_load_failed and img:\r\n+ assigned_count_this_res = 0\r\n+ for image_node in image_nodes:\r\n+ if image_node.image != img:\r\n+ image_node.image = img\r\n+ assigned_count_this_res += 1\r\n+\r\n+ if assigned_count_this_res > 0:\r\n+ images_assigned += assigned_count_this_res\r\n+ print(f\" Assigned image '{img.name}' to {assigned_count_this_res} node(s).\")\r\n+\r\n+ # Set Color Space\r\n+ correct_color_space = get_color_space(map_type)\r\n+ try:\r\n+ if img.colorspace_settings.name != correct_color_space:\r\n+ img.colorspace_settings.name = correct_color_space\r\n+ print(f\" Set '{img.name}' color space -> {correct_color_space}\")\r\n+ except TypeError as e_cs: # Handle case where colorspace name is invalid\r\n+ print(f\" !!! WARNING: Could not set color space '{correct_color_space}' for image '{img.name}'. Is the color space available in Blender? Error: {e_cs}\")\r\n+ except Exception as e_cs_gen:\r\n+ print(f\" !!! ERROR setting color space for image '{img.name}': {e_cs_gen}\")\r\n+\r\n+\r\n+ # --- Update Manifest (Map/Resolution Level) ---\r\n+ if update_manifest(manifest_data, asset_name, map_type, resolution):\r\n+ manifest_needs_saving = True\r\n+ # print(f\" Marked {map_type}/{resolution} processed in manifest.\") # Verbose\r\n+ maps_processed += 1\r\n+\r\n+ else:\r\n+ # Increment error count if loading failed\r\n+ if image_load_failed: errors_encountered += 1\r\n+\r\n+ # --- End Resolution Loop ---\r\n+ # --- End Map Type Loop ---\r\n+\r\n+ assets_processed += 1\r\n+\r\n+ except FileNotFoundError:\r\n+ print(f\" !!! ERROR: Metadata file not found (should not happen if scan worked): {metadata_path}\")\r\n+ errors_encountered += 1\r\n+ except json.JSONDecodeError:\r\n+ print(f\" !!! ERROR: Invalid JSON in metadata file: {metadata_path}\")\r\n+ errors_encountered += 1\r\n+ except Exception as e_main_loop:\r\n+ print(f\" !!! UNEXPECTED ERROR processing asset from {metadata_path}: {e_main_loop}\")\r\n+ import traceback\r\n+ traceback.print_exc() # Print detailed traceback for debugging\r\n+ errors_encountered += 1\r\n+ # Continue to the next asset\r\n+\r\n+ # --- End Metadata File Loop ---\r\n+\r\n+ # --- Final Manifest Save ---\r\n+ if ENABLE_MANIFEST and manifest_needs_saving:\r\n+ print(\"\\nAttempting final manifest save...\")\r\n+ save_manifest(context, manifest_data)\r\n+ elif ENABLE_MANIFEST:\r\n+ print(\"\\nManifest is enabled, but no changes require saving.\")\r\n+ # --- End Final Manifest Save ---\r\n+\r\n+ # --- Final Summary ---\r\n+ end_time = time.time()\r\n+ duration = end_time - start_time\r\n+ print(\"\\n--- Script Run Finished ---\")\r\n+ print(f\"Duration: {duration:.2f} seconds\")\r\n+ print(f\"Metadata Files Found: {metadata_files_found}\")\r\n+ print(f\"Assets Processed/Attempted: {assets_processed}\")\r\n+ if ENABLE_MANIFEST:\r\n+ print(f\"Assets Skipped (Manifest): {assets_skipped_manifest}\")\r\n+ print(f\"Maps Skipped (Manifest): {maps_skipped_manifest}\")\r\n+ print(f\"Parent Groups Created: {parent_groups_created}\")\r\n+ print(f\"Parent Groups Updated: {parent_groups_updated}\")\r\n+ print(f\"Child Groups Created: {child_groups_created}\")\r\n+ print(f\"Child Groups Updated: {child_groups_updated}\")\r\n+ print(f\"Images Loaded: {images_loaded}\")\r\n+ print(f\"Image Nodes Assigned: {images_assigned}\")\r\n+ print(f\"Individual Maps Processed: {maps_processed}\")\r\n+ print(f\"Asset Previews Set: {previews_set}\")\r\n+ print(f\"Highest Resolution Nodes Set: {highest_res_set}\")\r\n+ print(f\"Aspect Ratio Nodes Set: {aspect_ratio_set}\") # Added counter\r\n+ if errors_encountered > 0:\r\n+ print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n+ print(\"---------------------------\")\r\n+\r\n+ # --- Explicit Save ---\r\n+ print(f\" DEBUG: Attempting explicit save for file: {bpy.data.filepath}\") # DEBUG LOG (Indented)\r\n+ try:\r\n+ bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)\r\n+ print(\"\\n--- Explicitly saved the .blend file. ---\")\r\n+ except Exception as e_save:\r\n+ print(f\"\\n!!! ERROR explicitly saving .blend file: {e_save} !!!\")\r\n+ errors_encountered += 1 # Count save errors\r\n+\r\n+ return True\r\n+\r\n+\r\n+# --- Execution Block ---\r\n+\r\n+if __name__ == \"__main__\":\r\n+ # Ensure we are running within Blender\r\n+ try:\r\n+ import bpy\r\n+ import base64 # Ensure base64 is imported here too if needed globally\r\n+ import sys\r\n+ except ImportError:\r\n+ print(\"!!! ERROR: This script must be run from within Blender. !!!\")\r\n+ else:\r\n+ # --- Argument Parsing for Asset Library Root ---\r\n+ asset_root_arg = None\r\n+ try:\r\n+ # Blender arguments passed after '--' appear in sys.argv\r\n+ if \"--\" in sys.argv:\r\n+ args_after_dash = sys.argv[sys.argv.index(\"--\") + 1:]\r\n+ if len(args_after_dash) >= 1:\r\n+ asset_root_arg = args_after_dash[0]\r\n+ print(f\"Found asset library root argument: {asset_root_arg}\")\r\n+ else:\r\n+ print(\"Info: '--' found but no arguments after it.\")\r\n+ # else: print(\"Info: No '--' found in arguments.\") # Optional debug\r\n+ except Exception as e:\r\n+ print(f\"Error parsing command line arguments: {e}\")\r\n+ # --- End Argument Parsing ---\r\n+\r\n+ process_library(bpy.context, asset_library_root_override=asset_root_arg)\r\n" }, { "date": 1745266802108, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -48,9 +48,9 @@\n HIGHEST_RESOLUTION_NODE_LABEL = \"HighestResolution\" # Value node to store highest res index\r\n \r\n # Enable/disable the manifest system to track processed assets/maps\r\n # If enabled, requires the blend file to be saved.\r\n-ENABLE_MANIFEST = False # Disabled based on user feedback in previous run\r\n+ENABLE_MANIFEST = True # Disabled based on user feedback in previous run\r\n \r\n # Assumed filename pattern for processed images.\r\n # {asset_name}, {map_type}, {resolution}, {format} will be replaced.\r\n # Check Asset Processor Tool's config.py (TARGET_FILENAME_PATTERN) if this is wrong.\r\n@@ -1016,1021 +1016,4 @@\n print(f\"Error parsing command line arguments: {e}\")\r\n # --- End Argument Parsing ---\r\n \r\n process_library(bpy.context, asset_library_root_override=asset_root_arg)\r\n-# Blender Script: Create/Update Node Groups from Asset Processor Output\r\n-# Version: 1.5\r\n-# Description: Scans a library processed by the Asset Processor Tool,\r\n-# reads metadata.json files, and creates/updates corresponding\r\n-# PBR node groups in the active Blender file.\r\n-# Changes v1.5:\r\n-# - Corrected aspect ratio calculation (`calculate_aspect_correction_factor`)\r\n-# to use actual image dimensions from a loaded reference image and the\r\n-# `aspect_ratio_change_string`, mirroring original script logic for\r\n-# \"EVEN\", \"Xnnn\", \"Ynnn\" formats.\r\n-# - Added logic in main loop to load reference image for dimensions.\r\n-# Changes v1.3:\r\n-# - Added logic to find the highest resolution present for an asset.\r\n-# - Added logic to set a \"HighestResolution\" Value node in the parent group\r\n-# (maps 1K->1.0, 2K->2.0, 4K->3.0, 8K->4.0).\r\n-# Changes v1.2:\r\n-# - Added Base64 encoding for child node group names (PBRTYPE_...).\r\n-# - Added fallback logic for reconstructing image paths with different extensions.\r\n-# - Added logic to set custom asset preview for new parent groups (using lowest res COL map).\r\n-# Changes v1.1:\r\n-# - Updated metadata parsing to match actual structure (using processed_map_resolutions, image_stats_1k, map_details).\r\n-# - Added logic to reconstruct image file paths based on metadata and assumed naming convention.\r\n-\r\n-import bpy\r\n-import os\r\n-import json\r\n-from pathlib import Path\r\n-import time\r\n-import re # For parsing aspect ratio string\r\n-import base64 # For encoding node group names\r\n-\r\n-# --- USER CONFIGURATION ---\r\n-\r\n-# Path to the root output directory of the Asset Processor Tool\r\n-# Example: r\"G:\\Assets\\Processed\"\r\n-# IMPORTANT: This should point to the base directory containing supplier folders (e.g., Poliigon)\r\n-# This will be overridden by command-line arguments if provided.\r\n-PROCESSED_ASSET_LIBRARY_ROOT = None # Set to None initially\r\n-\r\n-# Names of the required node group templates in the Blender file\r\n-PARENT_TEMPLATE_NAME = \"Template_PBRSET\"\r\n-CHILD_TEMPLATE_NAME = \"Template_PBRTYPE\"\r\n-\r\n-# Labels of specific nodes within the PARENT template\r\n-ASPECT_RATIO_NODE_LABEL = \"AspectRatioCorrection\" # Value node for UV X-scaling factor\r\n-STATS_NODE_PREFIX = \"Histogram-\" # Prefix for Combine XYZ nodes storing stats (e.g., \"Histogram-ROUGH\")\r\n-HIGHEST_RESOLUTION_NODE_LABEL = \"HighestResolution\" # Value node to store highest res index\r\n-\r\n-# Enable/disable the manifest system to track processed assets/maps\r\n-# If enabled, requires the blend file to be saved.\r\n-ENABLE_MANIFEST = False # Disabled based on user feedback in previous run\r\n-\r\n-# Assumed filename pattern for processed images.\r\n-# {asset_name}, {map_type}, {resolution}, {format} will be replaced.\r\n-# Check Asset Processor Tool's config.py (TARGET_FILENAME_PATTERN) if this is wrong.\r\n-IMAGE_FILENAME_PATTERN = \"{asset_name}_{map_type}_{resolution}.{format}\"\r\n-\r\n-# Fallback extensions to try if the primary format from metadata is not found\r\n-# Order matters - first found will be used.\r\n-FALLBACK_IMAGE_EXTENSIONS = ['png', 'jpg', 'exr', 'tif']\r\n-\r\n-# Map type(s) to use for generating the asset preview AND for aspect ratio calculation reference\r\n-# The script will look for these in order and use the first one found.\r\n-REFERENCE_MAP_TYPES = [\"COL\", \"COL-1\", \"COL-2\"] # Used for preview and aspect calc\r\n-# Preferred resolution order for reference image (lowest first is often faster)\r\n-REFERENCE_RESOLUTION_ORDER = [\"1K\", \"512\", \"2K\", \"4K\"] # Adjust as needed\r\n-\r\n-# Mapping from resolution string to numerical value for the HighestResolution node\r\n-RESOLUTION_VALUE_MAP = {\"1K\": 1.0, \"2K\": 2.0, \"4K\": 3.0, \"8K\": 4.0}\r\n-# Order to check resolutions to find the highest present (highest value first)\r\n-RESOLUTION_ORDER_DESC = [\"8K\", \"4K\", \"2K\", \"1K\"] # Add others like \"512\" if needed and map them in RESOLUTION_VALUE_MAP\r\n-\r\n-# Map PBR type strings (from metadata) to Blender color spaces\r\n-# Add more mappings as needed based on your metadata types\r\n-PBR_COLOR_SPACE_MAP = {\r\n- \"AO\": \"Non-Color\", # Usually Non-Color, but depends on workflow\r\n- \"COL\": \"sRGB\",\r\n- \"COL-1\": \"sRGB\", # Handle variants if present in metadata\r\n- \"COL-2\": \"sRGB\",\r\n- \"COL-3\": \"sRGB\",\r\n- \"DISP\": \"Non-Color\",\r\n- \"NRM\": \"Non-Color\",\r\n- \"REFL\": \"Non-Color\", # Reflection/Specular\r\n- \"ROUGH\": \"Non-Color\",\r\n- \"METAL\": \"Non-Color\",\r\n- \"OPC\": \"Non-Color\", # Opacity/Alpha\r\n- \"TRN\": \"Non-Color\", # Transmission\r\n- \"SSS\": \"sRGB\", # Subsurface Color\r\n- \"EMISS\": \"sRGB\", # Emission Color\r\n- \"NRMRGH\": \"Non-Color\", # Added for merged map\r\n- \"FUZZ\": \"Non-Color\",\r\n- # Add other types like GLOSS, HEIGHT, etc. if needed\r\n-}\r\n-DEFAULT_COLOR_SPACE = \"sRGB\" # Fallback if map type not in the dictionary\r\n-\r\n-# Map types for which stats should be applied (if found in metadata and node exists)\r\n-# Reads stats from the 'image_stats_1k' section of metadata.json\r\n-APPLY_STATS_FOR_MAP_TYPES = [\"ROUGH\", \"DISP\", \"METAL\", \"AO\", \"REFL\"] # Add others if needed\r\n-\r\n-# --- END USER CONFIGURATION ---\r\n-\r\n-\r\n-# --- Helper Functions ---\r\n-\r\n-def encode_name_b64(name_str):\r\n- \"\"\"Encodes a string using URL-safe Base64 for node group names.\"\"\"\r\n- try:\r\n- # Ensure the input is a string\r\n- name_str = str(name_str)\r\n- return base64.urlsafe_b64encode(name_str.encode('utf-8')).decode('ascii')\r\n- except Exception as e:\r\n- print(f\" Error base64 encoding '{name_str}': {e}\")\r\n- return name_str # Fallback to original name on error\r\n-\r\n-def find_nodes_by_label(node_tree, label, node_type=None):\r\n- \"\"\"Finds ALL nodes in a node tree matching the label and optionally type.\"\"\"\r\n- if not node_tree:\r\n- return []\r\n- matching_nodes = []\r\n- for node in node_tree.nodes:\r\n- # Use node.label for labeled nodes, node.name for non-labeled (like Group Input/Output)\r\n- node_identifier = node.label if node.label else node.name\r\n- if node_identifier and node_identifier == label:\r\n- if node_type is None or node.bl_idname == node_type or node.type == node_type: # Check bl_idname and type for flexibility\r\n- matching_nodes.append(node)\r\n- return matching_nodes\r\n-\r\n-def add_tag_if_new(asset_data, tag_name):\r\n- \"\"\"Adds a tag to the asset data if it's not None/empty and doesn't already exist.\"\"\"\r\n- if not asset_data or not tag_name or not isinstance(tag_name, str):\r\n- return False\r\n- cleaned_tag_name = tag_name.strip()\r\n- if not cleaned_tag_name:\r\n- return False\r\n-\r\n- # Check if tag already exists (case-insensitive check might be better sometimes)\r\n- if cleaned_tag_name not in [t.name for t in asset_data.tags]:\r\n- try:\r\n- asset_data.tags.new(cleaned_tag_name)\r\n- print(f\" + Added Asset Tag: '{cleaned_tag_name}'\")\r\n- return True\r\n- except Exception as e:\r\n- print(f\" Error adding tag '{cleaned_tag_name}': {e}\")\r\n- return False\r\n- return False # Tag already existed\r\n-\r\n-def get_color_space(map_type):\r\n- \"\"\"Returns the appropriate Blender color space name for a given map type string.\"\"\"\r\n- # Handle potential numbered variants like COL-1, COL-2\r\n- base_map_type = map_type.split('-')[0]\r\n- return PBR_COLOR_SPACE_MAP.get(map_type.upper(), # Check full name first (e.g., NRMRGH)\r\n- PBR_COLOR_SPACE_MAP.get(base_map_type.upper(), DEFAULT_COLOR_SPACE)) # Fallback to base type\r\n-\r\n-def calculate_aspect_correction_factor(image_width, image_height, aspect_string):\r\n- \"\"\"\r\n- Calculates the UV X-axis scaling factor needed to correct distortion,\r\n- based on image dimensions and the aspect_ratio_change_string (\"EVEN\", \"Xnnn\", \"Ynnn\").\r\n- Mirrors the logic from the original POC script.\r\n- Returns 1.0 if dimensions are invalid or string is \"EVEN\" or invalid.\r\n- \"\"\"\r\n- if image_height <= 0 or image_width <= 0:\r\n- print(\" Warn: Invalid image dimensions for aspect ratio calculation. Returning 1.0.\")\r\n- return 1.0\r\n-\r\n- # Calculate the actual aspect ratio of the image file\r\n- current_aspect_ratio = image_width / image_height\r\n-\r\n- if not aspect_string or aspect_string.upper() == \"EVEN\":\r\n- # If scaling was even, the correction factor is just the image's aspect ratio\r\n- # to make UVs match the image proportions.\r\n- # print(f\" Aspect string is EVEN. Correction factor = current aspect ratio: {current_aspect_ratio:.4f}\")\r\n- return current_aspect_ratio\r\n-\r\n- # Handle non-uniform scaling cases (\"Xnnn\", \"Ynnn\")\r\n- # Use search instead of match to find anywhere in string (though unlikely needed based on format)\r\n- match = re.search(r\"([XY])(\\d+)\", aspect_string, re.IGNORECASE)\r\n- if not match:\r\n- print(f\" Warn: Invalid Scaling string format '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f} as fallback.\")\r\n- return current_aspect_ratio # Fallback to the image's own ratio\r\n-\r\n- axis = match.group(1).upper()\r\n- try:\r\n- amount = int(match.group(2))\r\n- if amount <= 0:\r\n- print(f\" Warn: Zero or negative Amount in Scaling string '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n- return current_aspect_ratio\r\n- except ValueError:\r\n- print(f\" Warn: Invalid Amount in Scaling string '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n- return current_aspect_ratio\r\n-\r\n- # Apply the non-uniform correction formula based on original script logic\r\n- scaling_factor_percent = amount / 100.0\r\n- correction_factor = current_aspect_ratio # Default\r\n-\r\n- try:\r\n- if axis == 'X':\r\n- if scaling_factor_percent == 0: raise ZeroDivisionError(\"X scaling factor is zero\")\r\n- # If image was stretched horizontally (X > 1), divide UV.x by factor\r\n- correction_factor = current_aspect_ratio / scaling_factor_percent\r\n- elif axis == 'Y':\r\n- # If image was stretched vertically (Y > 1), multiply UV.x by factor\r\n- correction_factor = current_aspect_ratio * scaling_factor_percent\r\n- # No 'else' needed as regex ensures X or Y\r\n-\r\n- except ZeroDivisionError as e:\r\n- print(f\" Warn: Division by zero during aspect factor calculation ({e}). Returning current ratio {current_aspect_ratio:.4f}.\")\r\n- return current_aspect_ratio\r\n- except Exception as e:\r\n- print(f\" Error calculating aspect correction factor: {e}. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n- return current_aspect_ratio\r\n-\r\n- # print(f\" Calculated aspect correction factor: {correction_factor:.4f} (from {image_width}x{image_height}, Scaling='{aspect_string}')\")\r\n- return correction_factor\r\n-\r\n-\r\n-def reconstruct_image_path_with_fallback(asset_dir_path, asset_name, map_type, resolution, primary_format=None):\r\n- \"\"\"\r\n- Constructs the expected image file path.\r\n- If primary_format is provided, tries that first.\r\n- Then falls back to common extensions if the path doesn't exist or primary_format was None.\r\n- Returns the found path as a string, or None if not found.\r\n- \"\"\"\r\n- if not all([asset_dir_path, asset_name, map_type, resolution]):\r\n- print(f\" !!! ERROR: Missing data for path reconstruction ({asset_name}/{map_type}/{resolution}).\")\r\n- return None\r\n-\r\n- found_path = None\r\n-\r\n- # 1. Try the primary format if provided\r\n- if primary_format:\r\n- try:\r\n- filename = IMAGE_FILENAME_PATTERN.format(\r\n- asset_name=asset_name,\r\n- map_type=map_type,\r\n- resolution=resolution,\r\n- format=primary_format.lower() # Ensure format is lowercase\r\n- )\r\n- primary_path = asset_dir_path / filename\r\n- if primary_path.is_file():\r\n- # print(f\" Found primary path: {str(primary_path)}\") # Verbose\r\n- return str(primary_path)\r\n- # else: print(f\" Primary path not found: {str(primary_path)}\") # Verbose\r\n- except KeyError as e:\r\n- print(f\" !!! ERROR: Missing key '{e}' in IMAGE_FILENAME_PATTERN. Cannot reconstruct path.\")\r\n- return None # Cannot proceed without valid pattern\r\n- except Exception as e:\r\n- print(f\" !!! ERROR reconstructing primary image path: {e}\")\r\n- # Continue to fallback\r\n-\r\n- # 2. Try fallback extensions\r\n- # print(f\" Trying fallback extensions for {map_type}/{resolution}...\") # Verbose\r\n- for ext in FALLBACK_IMAGE_EXTENSIONS:\r\n- # Skip if we already tried this extension as primary (and it failed)\r\n- if primary_format and ext.lower() == primary_format.lower():\r\n- continue\r\n- try:\r\n- fallback_filename = IMAGE_FILENAME_PATTERN.format(\r\n- asset_name=asset_name,\r\n- map_type=map_type,\r\n- resolution=resolution,\r\n- format=ext.lower()\r\n- )\r\n- fallback_path = asset_dir_path / fallback_filename\r\n- if fallback_path.is_file():\r\n- print(f\" Found fallback path: {str(fallback_path)}\")\r\n- return str(fallback_path) # Found it!\r\n- except KeyError:\r\n- # Should not happen if primary format worked, but handle defensively\r\n- print(f\" !!! ERROR: Missing key in IMAGE_FILENAME_PATTERN during fallback. Cannot reconstruct path.\")\r\n- return None\r\n- except Exception as e_fallback:\r\n- print(f\" !!! ERROR reconstructing fallback image path ({ext}): {e_fallback}\")\r\n- continue # Try next extension\r\n-\r\n- # If we get here, neither primary nor fallbacks worked\r\n- if primary_format:\r\n- print(f\" !!! ERROR: Could not find image file for {map_type}/{resolution} using primary format '{primary_format}' or fallbacks {FALLBACK_IMAGE_EXTENSIONS}.\")\r\n- else:\r\n- print(f\" !!! ERROR: Could not find image file for {map_type}/{resolution} using fallbacks {FALLBACK_IMAGE_EXTENSIONS}.\")\r\n- return None # Not found after all checks\r\n-\r\n-\r\n-# --- Manifest Functions ---\r\n-\r\n-def get_manifest_path(context):\r\n- \"\"\"Gets the expected path for the manifest JSON file.\"\"\"\r\n- if not context or not context.blend_data or not context.blend_data.filepath:\r\n- return None # Cannot determine path if blend file is not saved\r\n- blend_path = Path(context.blend_data.filepath)\r\n- manifest_filename = f\"{blend_path.stem}_manifest.json\"\r\n- return blend_path.parent / manifest_filename\r\n-\r\n-def load_manifest(context):\r\n- \"\"\"Loads the manifest data from the JSON file.\"\"\"\r\n- if not ENABLE_MANIFEST:\r\n- return {} # Manifest disabled\r\n-\r\n- manifest_path = get_manifest_path(context)\r\n- if not manifest_path:\r\n- print(\" Manifest Info: Blend file not saved. Cannot load manifest.\")\r\n- return {} # Cannot load without a path\r\n-\r\n- if not manifest_path.exists():\r\n- print(f\" Manifest Info: No manifest file found at '{manifest_path.name}'. Starting fresh.\")\r\n- return {} # No manifest file exists yet\r\n-\r\n- try:\r\n- with open(manifest_path, 'r', encoding='utf-8') as f:\r\n- data = json.load(f)\r\n- print(f\" Manifest Loaded from: {manifest_path.name}\")\r\n- # Basic validation (check if it's a dictionary)\r\n- if not isinstance(data, dict):\r\n- print(f\"!!! WARNING: Manifest file '{manifest_path.name}' has invalid format (not a dictionary). Starting fresh. !!!\")\r\n- return {}\r\n- return data\r\n- except json.JSONDecodeError:\r\n- print(f\"!!! WARNING: Manifest file '{manifest_path.name}' is corrupted. Starting fresh. !!!\")\r\n- return {}\r\n- except Exception as e:\r\n- print(f\"!!! ERROR: Could not load manifest file '{manifest_path.name}': {e} !!!\")\r\n- return {} # Treat as starting fresh on error\r\n-\r\n-def save_manifest(context, manifest_data):\r\n- \"\"\"Saves the manifest data to the JSON file.\"\"\"\r\n- if not ENABLE_MANIFEST or not manifest_data: # Don't save if disabled or empty\r\n- return False\r\n-\r\n- manifest_path = get_manifest_path(context)\r\n- if not manifest_path:\r\n- print(\" Manifest Error: Blend file not saved. Cannot save manifest.\")\r\n- return False\r\n-\r\n- try:\r\n- with open(manifest_path, 'w', encoding='utf-8') as f:\r\n- json.dump(manifest_data, f, indent=2, sort_keys=True) # Use indent and sort for readability\r\n- print(f\" Manifest Saved to: {manifest_path.name}\")\r\n- return True\r\n- except Exception as e:\r\n- print(f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\\n\"\r\n- f\"!!! Manifest save FAILED to '{manifest_path.name}': {e} !!!\\n\"\r\n- f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\")\r\n- return False\r\n-\r\n-def is_asset_processed(manifest_data, asset_name):\r\n- \"\"\"Checks if the entire asset (all its maps/resolutions) is marked as processed.\"\"\"\r\n- if not ENABLE_MANIFEST: return False\r\n- # Basic check if asset entry exists. Detailed check happens at map level.\r\n- return asset_name in manifest_data\r\n-\r\n-def is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- \"\"\"Checks if a specific map type and resolution for an asset is processed.\"\"\"\r\n- if not ENABLE_MANIFEST: return False\r\n- return resolution in manifest_data.get(asset_name, {}).get(map_type, [])\r\n-\r\n-def update_manifest(manifest_data, asset_name, map_type=None, resolution=None):\r\n- \"\"\"Updates the manifest dictionary in memory.\"\"\"\r\n- if not ENABLE_MANIFEST: return False\r\n-\r\n- # Ensure asset entry exists\r\n- if asset_name not in manifest_data:\r\n- manifest_data[asset_name] = {}\r\n-\r\n- # If map_type and resolution are provided, update the specific map entry\r\n- if map_type and resolution:\r\n- if map_type not in manifest_data[asset_name]:\r\n- manifest_data[asset_name][map_type] = []\r\n-\r\n- if resolution not in manifest_data[asset_name][map_type]:\r\n- manifest_data[asset_name][map_type].append(resolution)\r\n- manifest_data[asset_name][map_type].sort() # Keep sorted\r\n- return True # Indicate that a change was made\r\n- return False # No change made to this specific map/res\r\n-\r\n-\r\n-# --- Core Logic ---\r\n-\r\n-def process_library(context, asset_library_root_override=None): # Add override parameter\r\n- global ENABLE_MANIFEST # Declare intent to modify global if needed\r\n- global PROCESSED_ASSET_LIBRARY_ROOT # Allow modification of global\r\n- \"\"\"Scans the library, reads metadata, creates/updates node groups.\"\"\"\r\n- start_time = time.time()\r\n- print(f\"\\n--- Starting Node Group Processing ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---\")\r\n- print(f\" DEBUG: Received asset_library_root_override: {asset_library_root_override}\") # DEBUG LOG (Indented)\r\n-\r\n- # --- Determine Asset Library Root ---\r\n- if asset_library_root_override:\r\n- PROCESSED_ASSET_LIBRARY_ROOT = asset_library_root_override\r\n- print(f\"Using asset library root from argument: '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n- elif not PROCESSED_ASSET_LIBRARY_ROOT:\r\n- print(\"!!! ERROR: Processed asset library root not set in script and not provided via argument.\")\r\n- print(\"--- Script aborted. ---\")\r\n- return False\r\n- print(f\" DEBUG: Using final PROCESSED_ASSET_LIBRARY_ROOT: {PROCESSED_ASSET_LIBRARY_ROOT}\") # DEBUG LOG (Indented)\r\n-\r\n- # --- Pre-run Checks ---\r\n- print(\"Performing pre-run checks...\")\r\n- valid_setup = True\r\n- # 1. Check Library Root Path\r\n- root_path = Path(PROCESSED_ASSET_LIBRARY_ROOT)\r\n- if not root_path.is_dir():\r\n- print(f\"!!! ERROR: Processed asset library root directory not found or not a directory:\")\r\n- print(f\"!!! '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n- valid_setup = False\r\n- else:\r\n- print(f\" Asset Library Root: '{root_path}'\")\r\n- print(f\" DEBUG: Checking for templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\") # DEBUG LOG (Indented)\r\n-\r\n- # 2. Check Templates\r\n- template_parent = bpy.data.node_groups.get(PARENT_TEMPLATE_NAME)\r\n- template_child = bpy.data.node_groups.get(CHILD_TEMPLATE_NAME)\r\n- if not template_parent:\r\n- print(f\"!!! ERROR: Parent template node group '{PARENT_TEMPLATE_NAME}' not found in this Blender file.\")\r\n- valid_setup = False\r\n- if not template_child:\r\n- print(f\"!!! ERROR: Child template node group '{CHILD_TEMPLATE_NAME}' not found in this Blender file.\")\r\n- valid_setup = False\r\n- if template_parent and template_child:\r\n- print(f\" Found Templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\")\r\n- print(f\" DEBUG: Template Parent Found: {template_parent is not None}\") # DEBUG LOG (Indented)\r\n- print(f\" DEBUG: Template Child Found: {template_child is not None}\") # DEBUG LOG (Indented)\r\n-\r\n- # 3. Check Blend File Saved (if manifest enabled)\r\n- if ENABLE_MANIFEST and not context.blend_data.filepath:\r\n- print(f\"!!! WARNING: Manifest is enabled, but the current Blender file is not saved.\")\r\n- print(f\"!!! Manifest cannot be loaded or saved. Processing will continue without manifest checks.\")\r\n- ENABLE_MANIFEST = False # Disable manifest for this run\r\n-\r\n- if not valid_setup:\r\n- print(\"\\n--- Script aborted due to configuration errors. Please fix the issues above. ---\")\r\n- return False\r\n- print(\"Pre-run checks passed.\")\r\n- # --- End Pre-run Checks ---\r\n-\r\n- manifest_data = load_manifest(context)\r\n- manifest_needs_saving = False\r\n-\r\n- # --- Initialize Counters ---\r\n- metadata_files_found = 0\r\n- assets_processed = 0\r\n- assets_skipped_manifest = 0\r\n- parent_groups_created = 0\r\n- parent_groups_updated = 0\r\n- child_groups_created = 0\r\n- child_groups_updated = 0\r\n- images_loaded = 0\r\n- images_assigned = 0\r\n- maps_processed = 0\r\n- maps_skipped_manifest = 0\r\n- errors_encountered = 0\r\n- previews_set = 0\r\n- highest_res_set = 0\r\n- aspect_ratio_set = 0\r\n- # --- End Counters ---\r\n-\r\n- print(f\"\\nScanning for metadata files in '{root_path}'...\")\r\n-\r\n- # --- Scan for metadata.json ---\r\n- # Scan one level deeper for supplier folders (e.g., Poliigon)\r\n- # Then scan within each supplier for asset folders containing metadata.json\r\n- metadata_paths = []\r\n- for supplier_dir in root_path.iterdir():\r\n- if supplier_dir.is_dir():\r\n- # Now look for asset folders inside the supplier directory\r\n- for asset_dir in supplier_dir.iterdir():\r\n- if asset_dir.is_dir():\r\n- metadata_file = asset_dir / 'metadata.json'\r\n- if metadata_file.is_file():\r\n- metadata_paths.append(metadata_file)\r\n-\r\n- metadata_files_found = len(metadata_paths)\r\n- print(f\"Found {metadata_files_found} metadata.json files.\")\r\n- print(f\" DEBUG: Metadata paths found: {metadata_paths}\") # DEBUG LOG (Indented)\r\n-\r\n- if metadata_files_found == 0:\r\n- print(\"No metadata files found. Nothing to process.\")\r\n- print(\"--- Script Finished ---\")\r\n- return True # No work needed is considered success\r\n-\r\n- # --- Process Each Metadata File ---\r\n- for metadata_path in metadata_paths:\r\n- asset_dir_path = metadata_path.parent # Get the directory containing the metadata file\r\n- print(f\"\\n--- Processing Metadata: {metadata_path.relative_to(root_path)} ---\")\r\n- print(f\" DEBUG: Processing file: {metadata_path}\") # DEBUG LOG (Indented)\r\n- try:\r\n- with open(metadata_path, 'r', encoding='utf-8') as f:\r\n- metadata = json.load(f)\r\n-\r\n- # --- Extract Key Info ---\r\n- asset_name = metadata.get(\"asset_name\")\r\n- supplier_name = metadata.get(\"supplier_name\")\r\n- archetype = metadata.get(\"archetype\")\r\n- # Get map info from the correct keys\r\n- processed_resolutions = metadata.get(\"processed_map_resolutions\", {}) # Default to empty dict\r\n- merged_resolutions = metadata.get(\"merged_map_resolutions\", {}) # Get merged maps too\r\n- map_details = metadata.get(\"map_details\", {}) # Default to empty dict\r\n- image_stats_1k = metadata.get(\"image_stats_1k\") # Dict: {map_type: {stats}}\r\n- aspect_string = metadata.get(\"aspect_ratio_change_string\")\r\n-\r\n- # Combine processed and merged maps for iteration\r\n- all_map_resolutions = {**processed_resolutions, **merged_resolutions}\r\n-\r\n- # Validate essential data\r\n- if not asset_name:\r\n- print(f\" !!! ERROR: Metadata file is missing 'asset_name'. Skipping.\")\r\n- errors_encountered += 1\r\n- continue\r\n- if not all_map_resolutions:\r\n- print(f\" !!! ERROR: Metadata file has no 'processed_map_resolutions' or 'merged_map_resolutions'. Skipping asset '{asset_name}'.\")\r\n- errors_encountered += 1\r\n- continue\r\n- # map_details check remains a warning as merged maps won't be in it\r\n- print(f\" DEBUG: Valid metadata loaded for asset: {asset_name}\") # DEBUG LOG (Indented)\r\n-\r\n- print(f\" Asset Name: {asset_name}\")\r\n-\r\n- # --- Determine Highest Resolution ---\r\n- highest_resolution_value = 0.0\r\n- highest_resolution_str = \"Unknown\"\r\n- all_resolutions_present = set()\r\n- if all_map_resolutions: # Check combined dict\r\n- for res_list in all_map_resolutions.values():\r\n- if isinstance(res_list, list):\r\n- all_resolutions_present.update(res_list)\r\n-\r\n- if all_resolutions_present:\r\n- for res_str in RESOLUTION_ORDER_DESC:\r\n- if res_str in all_resolutions_present:\r\n- highest_resolution_value = RESOLUTION_VALUE_MAP.get(res_str, 0.0)\r\n- highest_resolution_str = res_str\r\n- if highest_resolution_value > 0.0:\r\n- break # Found the highest valid resolution\r\n-\r\n- print(f\" Highest resolution found: {highest_resolution_str} (Value: {highest_resolution_value})\")\r\n-\r\n- # --- Load Reference Image for Aspect Ratio ---\r\n- ref_image_path = None\r\n- ref_image_width = 0\r\n- ref_image_height = 0\r\n- ref_image_loaded = False\r\n- # Use combined resolutions dict to find reference map\r\n- for ref_map_type in REFERENCE_MAP_TYPES:\r\n- if ref_map_type in all_map_resolutions:\r\n- available_resolutions = all_map_resolutions[ref_map_type]\r\n- lowest_res = None\r\n- for res_pref in REFERENCE_RESOLUTION_ORDER:\r\n- if res_pref in available_resolutions:\r\n- lowest_res = res_pref\r\n- break\r\n- if lowest_res:\r\n- # Get format from map_details if available, otherwise None\r\n- ref_map_details = map_details.get(ref_map_type, {})\r\n- ref_format = ref_map_details.get(\"output_format\")\r\n- ref_image_path = reconstruct_image_path_with_fallback(\r\n- asset_dir_path=asset_dir_path,\r\n- asset_name=asset_name,\r\n- map_type=ref_map_type,\r\n- resolution=lowest_res,\r\n- primary_format=ref_format # Pass None if not in map_details\r\n- )\r\n- if ref_image_path:\r\n- break # Found a suitable reference image path\r\n-\r\n- if ref_image_path:\r\n- print(f\" Loading reference image for aspect ratio: {Path(ref_image_path).name}\")\r\n- try:\r\n- # Load image temporarily\r\n- ref_img = bpy.data.images.load(ref_image_path, check_existing=True)\r\n- if ref_img:\r\n- ref_image_width = ref_img.size[0]\r\n- ref_image_height = ref_img.size[1]\r\n- ref_image_loaded = True\r\n- print(f\" Reference image dimensions: {ref_image_width}x{ref_image_height}\")\r\n- # Remove the temporary image datablock to save memory\r\n- bpy.data.images.remove(ref_img)\r\n- else:\r\n- print(f\" !!! ERROR: Failed loading reference image via bpy.data.images.load: {ref_image_path}\")\r\n- except Exception as e_ref_load:\r\n- print(f\" !!! ERROR loading reference image '{ref_image_path}': {e_ref_load}\")\r\n- else:\r\n- print(f\" !!! WARNING: Could not find suitable reference image ({REFERENCE_MAP_TYPES} at {REFERENCE_RESOLUTION_ORDER}) for aspect ratio calculation.\")\r\n-\r\n-\r\n- # --- Manifest Check (Asset Level - Basic) ---\r\n- if ENABLE_MANIFEST and is_asset_processed(manifest_data, asset_name):\r\n- # Perform a quick check if *any* map needs processing for this asset\r\n- needs_processing = False\r\n- for map_type, resolutions in all_map_resolutions.items(): # Check combined maps\r\n- for resolution in resolutions:\r\n- if not is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- needs_processing = True\r\n- break\r\n- if needs_processing:\r\n- break\r\n- if not needs_processing:\r\n- print(f\" Skipping asset '{asset_name}' (already fully processed according to manifest).\")\r\n- assets_skipped_manifest += 1\r\n- continue # Skip to next metadata file\r\n-\r\n- # --- Parent Group Handling ---\r\n- target_parent_name = f\"PBRSET_{asset_name}\"\r\n- parent_group = bpy.data.node_groups.get(target_parent_name)\r\n- is_new_parent = False\r\n-\r\n- if parent_group is None:\r\n- print(f\" Creating new parent group: '{target_parent_name}'\")\r\n- print(f\" DEBUG: Copying parent template '{PARENT_TEMPLATE_NAME}'\") # DEBUG LOG (Indented)\r\n- parent_group = template_parent.copy()\r\n- if not parent_group:\r\n- print(f\" !!! ERROR: Failed to copy parent template '{PARENT_TEMPLATE_NAME}'. Skipping asset '{asset_name}'.\")\r\n- errors_encountered += 1\r\n- continue\r\n- parent_group.name = target_parent_name\r\n- parent_groups_created += 1\r\n- is_new_parent = True\r\n- else:\r\n- print(f\" Updating existing parent group: '{target_parent_name}'\")\r\n- print(f\" DEBUG: Found existing parent group.\") # DEBUG LOG (Indented)\r\n- parent_groups_updated += 1\r\n-\r\n- # Ensure marked as asset\r\n- if not parent_group.asset_data:\r\n- try:\r\n- parent_group.asset_mark()\r\n- print(f\" Marked '{parent_group.name}' as asset.\")\r\n- except Exception as e_mark:\r\n- print(f\" !!! ERROR: Failed to mark group '{parent_group.name}' as asset: {e_mark}\")\r\n- # Continue processing other parts if possible\r\n-\r\n- # Apply Asset Tags\r\n- if parent_group.asset_data:\r\n- if supplier_name:\r\n- add_tag_if_new(parent_group.asset_data, supplier_name)\r\n- if archetype:\r\n- add_tag_if_new(parent_group.asset_data, archetype)\r\n- # Add other tags if needed\r\n- # else: print(f\" Warn: Cannot apply tags, asset_data not available for '{parent_group.name}'.\") # Optional warning\r\n-\r\n- # Apply Aspect Ratio Correction\r\n- aspect_nodes = find_nodes_by_label(parent_group, ASPECT_RATIO_NODE_LABEL, 'ShaderNodeValue')\r\n- if aspect_nodes:\r\n- aspect_node = aspect_nodes[0]\r\n- correction_factor = 1.0 # Default if ref image fails\r\n- if ref_image_loaded:\r\n- correction_factor = calculate_aspect_correction_factor(ref_image_width, ref_image_height, aspect_string)\r\n- print(f\" Calculated aspect correction factor: {correction_factor:.4f}\")\r\n- else:\r\n- print(f\" !!! WARNING: Using default aspect ratio correction (1.0) due to missing reference image.\")\r\n-\r\n- # Check if update is needed\r\n- current_val = aspect_node.outputs[0].default_value\r\n- if abs(current_val - correction_factor) > 0.0001:\r\n- aspect_node.outputs[0].default_value = correction_factor\r\n- print(f\" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f} (was {current_val:.4f})\")\r\n- aspect_ratio_set += 1\r\n- # else: print(f\" Warn: Aspect ratio node '{ASPECT_RATIO_NODE_LABEL}' not found in parent group.\") # Optional\r\n-\r\n- # Apply Highest Resolution Value\r\n- hr_nodes = find_nodes_by_label(parent_group, HIGHEST_RESOLUTION_NODE_LABEL, 'ShaderNodeValue')\r\n- if hr_nodes:\r\n- hr_node = hr_nodes[0]\r\n- current_hr_val = hr_node.outputs[0].default_value\r\n- if highest_resolution_value > 0.0 and abs(current_hr_val - highest_resolution_value) > 0.001:\r\n- hr_node.outputs[0].default_value = highest_resolution_value\r\n- print(f\" Set '{HIGHEST_RESOLUTION_NODE_LABEL}' value to {highest_resolution_value} ({highest_resolution_str}) (was {current_hr_val:.1f})\")\r\n- highest_res_set += 1 # Count successful sets\r\n- # else: print(f\" Warn: Highest resolution node '{HIGHEST_RESOLUTION_NODE_LABEL}' not found in parent group.\") # Optional\r\n-\r\n-\r\n- # Apply Stats (using image_stats_1k)\r\n- if image_stats_1k and isinstance(image_stats_1k, dict):\r\n- for map_type_to_stat in APPLY_STATS_FOR_MAP_TYPES:\r\n- if map_type_to_stat in image_stats_1k:\r\n- # Find the stats node in the parent group\r\n- stats_node_label = f\"{STATS_NODE_PREFIX}{map_type_to_stat}\"\r\n- stats_nodes = find_nodes_by_label(parent_group, stats_node_label, 'ShaderNodeCombineXYZ')\r\n- if stats_nodes:\r\n- stats_node = stats_nodes[0]\r\n- stats = image_stats_1k[map_type_to_stat] # Get stats dict for this map type\r\n-\r\n- if stats and isinstance(stats, dict):\r\n- # Handle potential list format for RGB stats (use first value) or direct float\r\n- def get_stat_value(stat_val):\r\n- if isinstance(stat_val, list):\r\n- return stat_val[0] if stat_val else None\r\n- return stat_val\r\n-\r\n- min_val = get_stat_value(stats.get(\"min\"))\r\n- max_val = get_stat_value(stats.get(\"max\"))\r\n- mean_val = get_stat_value(stats.get(\"mean\")) # Often stored as 'mean' or 'avg'\r\n-\r\n- updated_stat = False\r\n- # Check inputs exist before assigning\r\n- input_x = stats_node.inputs.get(\"X\")\r\n- input_y = stats_node.inputs.get(\"Y\")\r\n- input_z = stats_node.inputs.get(\"Z\")\r\n-\r\n- if input_x and min_val is not None and abs(input_x.default_value - min_val) > 0.0001:\r\n- input_x.default_value = min_val\r\n- updated_stat = True\r\n- if input_y and max_val is not None and abs(input_y.default_value - max_val) > 0.0001:\r\n- input_y.default_value = max_val\r\n- updated_stat = True\r\n- if input_z and mean_val is not None and abs(input_z.default_value - mean_val) > 0.0001:\r\n- input_z.default_value = mean_val\r\n- updated_stat = True\r\n-\r\n- if updated_stat:\r\n- print(f\" Set stats in '{stats_node_label}': Min={min_val:.4f}, Max={max_val:.4f}, Mean={mean_val:.4f}\")\r\n- # else: print(f\" Info: No valid 'stats' dictionary found for map type '{map_type_to_stat}' in image_stats_1k.\") # Optional\r\n- # else: print(f\" Warn: Stats node '{stats_node_label}' not found in parent group.\") # Optional\r\n- # else: print(f\" Info: Map type '{map_type_to_stat}' not present in image_stats_1k for stats application.\") # Optional\r\n- # else: print(f\" Warn: 'image_stats_1k' missing or invalid in metadata.\") # Optional\r\n-\r\n- # --- Set Asset Preview (only for new parent groups) ---\r\n- # Use the reference image path found earlier if available\r\n- if is_new_parent and parent_group.asset_data:\r\n- if ref_image_loaded and ref_image_path: # Check if ref image was successfully loaded earlier\r\n- print(f\" Attempting to set preview from reference image: {Path(ref_image_path).name}\")\r\n- try:\r\n- # Ensure the ID (node group) is the active one for the operator context\r\n- with context.temp_override(id=parent_group):\r\n- bpy.ops.ed.lib_id_load_custom_preview(filepath=ref_image_path)\r\n- print(f\" Successfully set custom preview.\")\r\n- previews_set += 1\r\n- except Exception as e_preview:\r\n- print(f\" !!! ERROR setting custom preview: {e_preview}\")\r\n- errors_encountered += 1\r\n- else:\r\n- print(f\" Info: Could not set preview for '{asset_name}' as reference image was not found or loaded.\")\r\n-\r\n-\r\n- # --- Child Group Handling ---\r\n- # Iterate through the COMBINED map types\r\n- print(f\" DEBUG: Starting child group loop for asset '{asset_name}'. Map types: {list(all_map_resolutions.keys())}\") # DEBUG LOG (Indented)\r\n- for map_type, resolutions in all_map_resolutions.items():\r\n- print(f\" Processing Map Type: {map_type}\")\r\n-\r\n- # Determine if this is a merged map (not in map_details)\r\n- is_merged_map = map_type not in map_details\r\n-\r\n- # Get details for this map type if available\r\n- current_map_details = map_details.get(map_type, {})\r\n- # For merged maps, primary_format will be None\r\n- output_format = current_map_details.get(\"output_format\")\r\n-\r\n- if not output_format and not is_merged_map:\r\n- # This case should ideally not happen if metadata is well-formed\r\n- # but handle defensively for processed maps.\r\n- print(f\" !!! WARNING: Missing 'output_format' in map_details for processed map '{map_type}'. Path reconstruction might fail.\")\r\n- # We will rely solely on fallback for this map type\r\n-\r\n- # Find placeholder node in parent\r\n- holder_nodes = find_nodes_by_label(parent_group, map_type, 'ShaderNodeGroup')\r\n- if not holder_nodes:\r\n- print(f\" !!! WARNING: No placeholder node labeled '{map_type}' found in parent group '{parent_group.name}'. Skipping this map type.\")\r\n- continue\r\n- holder_node = holder_nodes[0] # Assume first is correct\r\n- print(f\" DEBUG: Found placeholder node '{holder_node.label}' for map type '{map_type}'.\") # DEBUG LOG (Indented)\r\n-\r\n- # Determine child group name (LOGICAL and ENCODED)\r\n- logical_child_name = f\"{asset_name}_{map_type}\"\r\n- target_child_name_b64 = encode_name_b64(logical_child_name) # Use Base64 name\r\n-\r\n- child_group = bpy.data.node_groups.get(target_child_name_b64) # Find using encoded name\r\n- is_new_child = False\r\n-\r\n- if child_group is None:\r\n- print(f\" DEBUG: Child group '{target_child_name_b64}' not found. Creating new one.\") # DEBUG LOG (Indented)\r\n- # print(f\" Creating new child group: '{target_child_name_b64}' (logical: '{logical_child_name}')\") # Verbose\r\n- child_group = template_child.copy()\r\n- if not child_group:\r\n- print(f\" !!! ERROR: Failed to copy child template '{CHILD_TEMPLATE_NAME}'. Skipping map type '{map_type}'.\")\r\n- errors_encountered += 1\r\n- continue\r\n- child_group.name = target_child_name_b64 # Set encoded name\r\n- child_groups_created += 1\r\n- is_new_child = True\r\n- else:\r\n- print(f\" DEBUG: Found existing child group '{target_child_name_b64}'.\") # DEBUG LOG (Indented)\r\n- # print(f\" Updating existing child group: '{target_child_name_b64}'\") # Verbose\r\n- child_groups_updated += 1\r\n-\r\n- # Assign child group to placeholder if needed\r\n- if holder_node.node_tree != child_group:\r\n- try:\r\n- holder_node.node_tree = child_group\r\n- print(f\" Assigned child group '{child_group.name}' to placeholder '{holder_node.label}'.\")\r\n- except TypeError as e_assign: # Catch potential type errors if placeholder isn't a group node\r\n- print(f\" !!! ERROR: Could not assign child group to placeholder '{holder_node.label}'. Is it a Group Node? Error: {e_assign}\")\r\n- continue # Skip this map type if assignment fails\r\n-\r\n- # Link placeholder output to parent output socket\r\n- try:\r\n- # Find parent's output node\r\n- group_output_node = next((n for n in parent_group.nodes if n.type == 'GROUP_OUTPUT'), None)\r\n- if group_output_node:\r\n- # Get the specific output socket on the placeholder (usually index 0 or named 'Color'/'Value')\r\n- source_socket = holder_node.outputs.get(\"Color\") or holder_node.outputs.get(\"Value\") or holder_node.outputs[0]\r\n- # Get the specific input socket on the parent output node (matching map_type)\r\n- target_socket = group_output_node.inputs.get(map_type)\r\n-\r\n- if source_socket and target_socket:\r\n- # Check if link already exists\r\n- link_exists = any(link.from_socket == source_socket and link.to_socket == target_socket for link in parent_group.links)\r\n- if not link_exists:\r\n- parent_group.links.new(source_socket, target_socket)\r\n- print(f\" Linked '{holder_node.label}' output to parent output socket '{map_type}'.\")\r\n- # else: # Optional warnings\r\n- # if not source_socket: print(f\" Warn: Could not find suitable output socket on placeholder '{holder_node.label}'.\")\r\n- # if not target_socket: print(f\" Warn: Could not find input socket '{map_type}' on parent output node.\")\r\n- # else: print(f\" Warn: Parent group '{parent_group.name}' has no Group Output node.\")\r\n-\r\n- except Exception as e_link:\r\n- print(f\" !!! ERROR linking sockets for '{map_type}': {e_link}\")\r\n-\r\n- # Ensure parent output socket type is Color (if it exists)\r\n- try:\r\n- # Use the interface API for modern Blender versions\r\n- item = parent_group.interface.items_tree.get(map_type)\r\n- if item and item.item_type == 'SOCKET' and item.in_out == 'OUTPUT':\r\n- # Common useful types: 'NodeSocketColor', 'NodeSocketVector', 'NodeSocketFloat'\r\n- # Defaulting to Color seems reasonable for most PBR outputs\r\n- if item.socket_type != 'NodeSocketColor':\r\n- item.socket_type = 'NodeSocketColor'\r\n- # print(f\" Set parent output socket '{map_type}' type to Color.\") # Optional info\r\n- except Exception as e_sock_type:\r\n- print(f\" Warn: Could not verify/set socket type for '{map_type}': {e_sock_type}\")\r\n-\r\n-\r\n- # --- Image Node Handling (Inside Child Group) ---\r\n- if not isinstance(resolutions, list):\r\n- print(f\" !!! ERROR: Invalid format for resolutions list for map type '{map_type}'. Skipping.\")\r\n- continue\r\n-\r\n- for resolution in resolutions:\r\n- # --- Manifest Check (Map/Resolution Level) ---\r\n- if ENABLE_MANIFEST and is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- # print(f\" Skipping {resolution} (Manifest)\") # Verbose\r\n- maps_skipped_manifest += 1\r\n- continue\r\n- print(f\" DEBUG: Processing map '{map_type}' resolution '{resolution}'. Manifest skip check passed.\") # DEBUG LOG (Indented)\r\n-\r\n- print(f\" Processing Resolution: {resolution}\")\r\n-\r\n- # Reconstruct the image path using fallback logic\r\n- # Pass output_format (which might be None for merged maps)\r\n- image_path_str = reconstruct_image_path_with_fallback(\r\n- asset_dir_path=asset_dir_path,\r\n- asset_name=asset_name,\r\n- map_type=map_type,\r\n- resolution=resolution,\r\n- primary_format=output_format\r\n- )\r\n- print(f\" DEBUG: Reconstructed image path for {map_type}/{resolution}: {image_path_str}\") # DEBUG LOG (Indented)\r\n-\r\n- if not image_path_str:\r\n- # Error already printed by reconstruct function\r\n- errors_encountered += 1\r\n- continue # Skip this resolution if path not found\r\n-\r\n- # Find image texture node within the CHILD group (labeled by resolution, e.g., \"4K\")\r\n- image_nodes = find_nodes_by_label(child_group, resolution, 'ShaderNodeTexImage')\r\n- if not image_nodes:\r\n- print(f\" !!! WARNING: No Image Texture node labeled '{resolution}' found in child group '{child_group.name}'. Cannot assign image.\")\r\n- continue # Skip this resolution if node not found\r\n- print(f\" DEBUG: Found {len(image_nodes)} image node(s) labeled '{resolution}' in child group '{child_group.name}'.\") # DEBUG LOG (Indented)\r\n-\r\n- # --- Load Image ---\r\n- img = None\r\n- image_load_failed = False\r\n- try:\r\n- image_path = Path(image_path_str) # Path object created from already found path string\r\n- # Use check_existing=True to reuse existing datablocks if path matches\r\n- img = bpy.data.images.load(str(image_path), check_existing=True)\r\n- if not img:\r\n- print(f\" !!! ERROR: Failed loading image via bpy.data.images.load: {image_path_str}\")\r\n- image_load_failed = True\r\n- else:\r\n- # Only count as loaded if bpy.data.images.load succeeded\r\n- # Check if it's newly loaded or reused\r\n- is_newly_loaded = img.library is None # Newly loaded images don't have a library initially\r\n- if is_newly_loaded: images_loaded += 1\r\n-\r\n- except RuntimeError as e_runtime_load:\r\n- # Catch specific Blender runtime errors (e.g., unsupported format)\r\n- print(f\" !!! ERROR loading image '{image_path_str}': {e_runtime_load}\")\r\n- image_load_failed = True\r\n- except Exception as e_gen_load:\r\n- print(f\" !!! UNEXPECTED ERROR loading image '{image_path_str}': {e_gen_load}\")\r\n- image_load_failed = True\r\n- errors_encountered += 1\r\n-\r\n- # --- Assign Image & Set Color Space ---\r\n- if not image_load_failed and img:\r\n- assigned_count_this_res = 0\r\n- for image_node in image_nodes:\r\n- if image_node.image != img:\r\n- image_node.image = img\r\n- assigned_count_this_res += 1\r\n-\r\n- if assigned_count_this_res > 0:\r\n- images_assigned += assigned_count_this_res\r\n- print(f\" Assigned image '{img.name}' to {assigned_count_this_res} node(s).\")\r\n-\r\n- # Set Color Space\r\n- correct_color_space = get_color_space(map_type)\r\n- try:\r\n- if img.colorspace_settings.name != correct_color_space:\r\n- img.colorspace_settings.name = correct_color_space\r\n- print(f\" Set '{img.name}' color space -> {correct_color_space}\")\r\n- except TypeError as e_cs: # Handle case where colorspace name is invalid\r\n- print(f\" !!! WARNING: Could not set color space '{correct_color_space}' for image '{img.name}'. Is the color space available in Blender? Error: {e_cs}\")\r\n- except Exception as e_cs_gen:\r\n- print(f\" !!! ERROR setting color space for image '{img.name}': {e_cs_gen}\")\r\n-\r\n-\r\n- # --- Update Manifest (Map/Resolution Level) ---\r\n- if update_manifest(manifest_data, asset_name, map_type, resolution):\r\n- manifest_needs_saving = True\r\n- # print(f\" Marked {map_type}/{resolution} processed in manifest.\") # Verbose\r\n- maps_processed += 1\r\n-\r\n- else:\r\n- # Increment error count if loading failed\r\n- if image_load_failed: errors_encountered += 1\r\n-\r\n- # --- End Resolution Loop ---\r\n- # --- End Map Type Loop ---\r\n-\r\n- assets_processed += 1\r\n-\r\n- except FileNotFoundError:\r\n- print(f\" !!! ERROR: Metadata file not found (should not happen if scan worked): {metadata_path}\")\r\n- errors_encountered += 1\r\n- except json.JSONDecodeError:\r\n- print(f\" !!! ERROR: Invalid JSON in metadata file: {metadata_path}\")\r\n- errors_encountered += 1\r\n- except Exception as e_main_loop:\r\n- print(f\" !!! UNEXPECTED ERROR processing asset from {metadata_path}: {e_main_loop}\")\r\n- import traceback\r\n- traceback.print_exc() # Print detailed traceback for debugging\r\n- errors_encountered += 1\r\n- # Continue to the next asset\r\n-\r\n- # --- End Metadata File Loop ---\r\n-\r\n- # --- Final Manifest Save ---\r\n- if ENABLE_MANIFEST and manifest_needs_saving:\r\n- print(\"\\nAttempting final manifest save...\")\r\n- save_manifest(context, manifest_data)\r\n- elif ENABLE_MANIFEST:\r\n- print(\"\\nManifest is enabled, but no changes require saving.\")\r\n- # --- End Final Manifest Save ---\r\n-\r\n- # --- Final Summary ---\r\n- end_time = time.time()\r\n- duration = end_time - start_time\r\n- print(\"\\n--- Script Run Finished ---\")\r\n- print(f\"Duration: {duration:.2f} seconds\")\r\n- print(f\"Metadata Files Found: {metadata_files_found}\")\r\n- print(f\"Assets Processed/Attempted: {assets_processed}\")\r\n- if ENABLE_MANIFEST:\r\n- print(f\"Assets Skipped (Manifest): {assets_skipped_manifest}\")\r\n- print(f\"Maps Skipped (Manifest): {maps_skipped_manifest}\")\r\n- print(f\"Parent Groups Created: {parent_groups_created}\")\r\n- print(f\"Parent Groups Updated: {parent_groups_updated}\")\r\n- print(f\"Child Groups Created: {child_groups_created}\")\r\n- print(f\"Child Groups Updated: {child_groups_updated}\")\r\n- print(f\"Images Loaded: {images_loaded}\")\r\n- print(f\"Image Nodes Assigned: {images_assigned}\")\r\n- print(f\"Individual Maps Processed: {maps_processed}\")\r\n- print(f\"Asset Previews Set: {previews_set}\")\r\n- print(f\"Highest Resolution Nodes Set: {highest_res_set}\")\r\n- print(f\"Aspect Ratio Nodes Set: {aspect_ratio_set}\") # Added counter\r\n- if errors_encountered > 0:\r\n- print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n- print(\"---------------------------\")\r\n-\r\n- # --- Explicit Save ---\r\n- print(f\" DEBUG: Attempting explicit save for file: {bpy.data.filepath}\") # DEBUG LOG (Indented)\r\n- try:\r\n- bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)\r\n- print(\"\\n--- Explicitly saved the .blend file. ---\")\r\n- except Exception as e_save:\r\n- print(f\"\\n!!! ERROR explicitly saving .blend file: {e_save} !!!\")\r\n- errors_encountered += 1 # Count save errors\r\n-\r\n- return True\r\n-\r\n-\r\n-# --- Execution Block ---\r\n-\r\n-if __name__ == \"__main__\":\r\n- # Ensure we are running within Blender\r\n- try:\r\n- import bpy\r\n- import base64 # Ensure base64 is imported here too if needed globally\r\n- import sys\r\n- except ImportError:\r\n- print(\"!!! ERROR: This script must be run from within Blender. !!!\")\r\n- else:\r\n- # --- Argument Parsing for Asset Library Root ---\r\n- asset_root_arg = None\r\n- try:\r\n- # Blender arguments passed after '--' appear in sys.argv\r\n- if \"--\" in sys.argv:\r\n- args_after_dash = sys.argv[sys.argv.index(\"--\") + 1:]\r\n- if len(args_after_dash) >= 1:\r\n- asset_root_arg = args_after_dash[0]\r\n- print(f\"Found asset library root argument: {asset_root_arg}\")\r\n- else:\r\n- print(\"Info: '--' found but no arguments after it.\")\r\n- # else: print(\"Info: No '--' found in arguments.\") # Optional debug\r\n- except Exception as e:\r\n- print(f\"Error parsing command line arguments: {e}\")\r\n- # --- End Argument Parsing ---\r\n-\r\n" }, { "date": 1745266814187, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -0,0 +1,1019 @@\n+# Blender Script: Create/Update Node Groups from Asset Processor Output\r\n+# Version: 1.5\r\n+# Description: Scans a library processed by the Asset Processor Tool,\r\n+# reads metadata.json files, and creates/updates corresponding\r\n+# PBR node groups in the active Blender file.\r\n+# Changes v1.5:\r\n+# - Corrected aspect ratio calculation (`calculate_aspect_correction_factor`)\r\n+# to use actual image dimensions from a loaded reference image and the\r\n+# `aspect_ratio_change_string`, mirroring original script logic for\r\n+# \"EVEN\", \"Xnnn\", \"Ynnn\" formats.\r\n+# - Added logic in main loop to load reference image for dimensions.\r\n+# Changes v1.3:\r\n+# - Added logic to find the highest resolution present for an asset.\r\n+# - Added logic to set a \"HighestResolution\" Value node in the parent group\r\n+# (maps 1K->1.0, 2K->2.0, 4K->3.0, 8K->4.0).\r\n+# Changes v1.2:\r\n+# - Added Base64 encoding for child node group names (PBRTYPE_...).\r\n+# - Added fallback logic for reconstructing image paths with different extensions.\r\n+# - Added logic to set custom asset preview for new parent groups (using lowest res COL map).\r\n+# Changes v1.1:\r\n+# - Updated metadata parsing to match actual structure (using processed_map_resolutions, image_stats_1k, map_details).\r\n+# - Added logic to reconstruct image file paths based on metadata and assumed naming convention.\r\n+\r\n+import bpy\r\n+import os\r\n+import json\r\n+from pathlib import Path\r\n+import time\r\n+import re # For parsing aspect ratio string\r\n+import base64 # For encoding node group names\r\n+import sys # <<< ADDED IMPORT\r\n+\r\n+# --- USER CONFIGURATION ---\r\n+\r\n+# Path to the root output directory of the Asset Processor Tool\r\n+# Example: r\"G:\\Assets\\Processed\"\r\n+# IMPORTANT: This should point to the base directory containing supplier folders (e.g., Poliigon)\r\n+# This will be overridden by command-line arguments if provided.\r\n+PROCESSED_ASSET_LIBRARY_ROOT = None # Set to None initially\r\n+\r\n+# Names of the required node group templates in the Blender file\r\n+PARENT_TEMPLATE_NAME = \"Template_PBRSET\"\r\n+CHILD_TEMPLATE_NAME = \"Template_PBRTYPE\"\r\n+\r\n+# Labels of specific nodes within the PARENT template\r\n+ASPECT_RATIO_NODE_LABEL = \"AspectRatioCorrection\" # Value node for UV X-scaling factor\r\n+STATS_NODE_PREFIX = \"Histogram-\" # Prefix for Combine XYZ nodes storing stats (e.g., \"Histogram-ROUGH\")\r\n+HIGHEST_RESOLUTION_NODE_LABEL = \"HighestResolution\" # Value node to store highest res index\r\n+\r\n+# Enable/disable the manifest system to track processed assets/maps\r\n+# If enabled, requires the blend file to be saved.\r\n+ENABLE_MANIFEST = True # Disabled based on user feedback in previous run\r\n+\r\n+# Assumed filename pattern for processed images.\r\n+# {asset_name}, {map_type}, {resolution}, {format} will be replaced.\r\n+# Check Asset Processor Tool's config.py (TARGET_FILENAME_PATTERN) if this is wrong.\r\n+IMAGE_FILENAME_PATTERN = \"{asset_name}_{map_type}_{resolution}.{format}\"\r\n+\r\n+# Fallback extensions to try if the primary format from metadata is not found\r\n+# Order matters - first found will be used.\r\n+FALLBACK_IMAGE_EXTENSIONS = ['png', 'jpg', 'exr', 'tif']\r\n+\r\n+# Map type(s) to use for generating the asset preview AND for aspect ratio calculation reference\r\n+# The script will look for these in order and use the first one found.\r\n+REFERENCE_MAP_TYPES = [\"COL\", \"COL-1\", \"COL-2\"] # Used for preview and aspect calc\r\n+# Preferred resolution order for reference image (lowest first is often faster)\r\n+REFERENCE_RESOLUTION_ORDER = [\"1K\", \"512\", \"2K\", \"4K\"] # Adjust as needed\r\n+\r\n+# Mapping from resolution string to numerical value for the HighestResolution node\r\n+RESOLUTION_VALUE_MAP = {\"1K\": 1.0, \"2K\": 2.0, \"4K\": 3.0, \"8K\": 4.0}\r\n+# Order to check resolutions to find the highest present (highest value first)\r\n+RESOLUTION_ORDER_DESC = [\"8K\", \"4K\", \"2K\", \"1K\"] # Add others like \"512\" if needed and map them in RESOLUTION_VALUE_MAP\r\n+\r\n+# Map PBR type strings (from metadata) to Blender color spaces\r\n+# Add more mappings as needed based on your metadata types\r\n+PBR_COLOR_SPACE_MAP = {\r\n+ \"AO\": \"Non-Color\", # Usually Non-Color, but depends on workflow\r\n+ \"COL\": \"sRGB\",\r\n+ \"COL-1\": \"sRGB\", # Handle variants if present in metadata\r\n+ \"COL-2\": \"sRGB\",\r\n+ \"COL-3\": \"sRGB\",\r\n+ \"DISP\": \"Non-Color\",\r\n+ \"NRM\": \"Non-Color\",\r\n+ \"REFL\": \"Non-Color\", # Reflection/Specular\r\n+ \"ROUGH\": \"Non-Color\",\r\n+ \"METAL\": \"Non-Color\",\r\n+ \"OPC\": \"Non-Color\", # Opacity/Alpha\r\n+ \"TRN\": \"Non-Color\", # Transmission\r\n+ \"SSS\": \"sRGB\", # Subsurface Color\r\n+ \"EMISS\": \"sRGB\", # Emission Color\r\n+ \"NRMRGH\": \"Non-Color\", # Added for merged map\r\n+ \"FUZZ\": \"Non-Color\",\r\n+ # Add other types like GLOSS, HEIGHT, etc. if needed\r\n+}\r\n+DEFAULT_COLOR_SPACE = \"sRGB\" # Fallback if map type not in the dictionary\r\n+\r\n+# Map types for which stats should be applied (if found in metadata and node exists)\r\n+# Reads stats from the 'image_stats_1k' section of metadata.json\r\n+APPLY_STATS_FOR_MAP_TYPES = [\"ROUGH\", \"DISP\", \"METAL\", \"AO\", \"REFL\"] # Add others if needed\r\n+\r\n+# --- END USER CONFIGURATION ---\r\n+\r\n+\r\n+# --- Helper Functions ---\r\n+\r\n+def encode_name_b64(name_str):\r\n+ \"\"\"Encodes a string using URL-safe Base64 for node group names.\"\"\"\r\n+ try:\r\n+ # Ensure the input is a string\r\n+ name_str = str(name_str)\r\n+ return base64.urlsafe_b64encode(name_str.encode('utf-8')).decode('ascii')\r\n+ except Exception as e:\r\n+ print(f\" Error base64 encoding '{name_str}': {e}\")\r\n+ return name_str # Fallback to original name on error\r\n+\r\n+def find_nodes_by_label(node_tree, label, node_type=None):\r\n+ \"\"\"Finds ALL nodes in a node tree matching the label and optionally type.\"\"\"\r\n+ if not node_tree:\r\n+ return []\r\n+ matching_nodes = []\r\n+ for node in node_tree.nodes:\r\n+ # Use node.label for labeled nodes, node.name for non-labeled (like Group Input/Output)\r\n+ node_identifier = node.label if node.label else node.name\r\n+ if node_identifier and node_identifier == label:\r\n+ if node_type is None or node.bl_idname == node_type or node.type == node_type: # Check bl_idname and type for flexibility\r\n+ matching_nodes.append(node)\r\n+ return matching_nodes\r\n+\r\n+def add_tag_if_new(asset_data, tag_name):\r\n+ \"\"\"Adds a tag to the asset data if it's not None/empty and doesn't already exist.\"\"\"\r\n+ if not asset_data or not tag_name or not isinstance(tag_name, str):\r\n+ return False\r\n+ cleaned_tag_name = tag_name.strip()\r\n+ if not cleaned_tag_name:\r\n+ return False\r\n+\r\n+ # Check if tag already exists (case-insensitive check might be better sometimes)\r\n+ if cleaned_tag_name not in [t.name for t in asset_data.tags]:\r\n+ try:\r\n+ asset_data.tags.new(cleaned_tag_name)\r\n+ print(f\" + Added Asset Tag: '{cleaned_tag_name}'\")\r\n+ return True\r\n+ except Exception as e:\r\n+ print(f\" Error adding tag '{cleaned_tag_name}': {e}\")\r\n+ return False\r\n+ return False # Tag already existed\r\n+\r\n+def get_color_space(map_type):\r\n+ \"\"\"Returns the appropriate Blender color space name for a given map type string.\"\"\"\r\n+ # Handle potential numbered variants like COL-1, COL-2\r\n+ base_map_type = map_type.split('-')[0]\r\n+ return PBR_COLOR_SPACE_MAP.get(map_type.upper(), # Check full name first (e.g., NRMRGH)\r\n+ PBR_COLOR_SPACE_MAP.get(base_map_type.upper(), DEFAULT_COLOR_SPACE)) # Fallback to base type\r\n+\r\n+def calculate_aspect_correction_factor(image_width, image_height, aspect_string):\r\n+ \"\"\"\r\n+ Calculates the UV X-axis scaling factor needed to correct distortion,\r\n+ based on image dimensions and the aspect_ratio_change_string (\"EVEN\", \"Xnnn\", \"Ynnn\").\r\n+ Mirrors the logic from the original POC script.\r\n+ Returns 1.0 if dimensions are invalid or string is \"EVEN\" or invalid.\r\n+ \"\"\"\r\n+ if image_height <= 0 or image_width <= 0:\r\n+ print(\" Warn: Invalid image dimensions for aspect ratio calculation. Returning 1.0.\")\r\n+ return 1.0\r\n+\r\n+ # Calculate the actual aspect ratio of the image file\r\n+ current_aspect_ratio = image_width / image_height\r\n+\r\n+ if not aspect_string or aspect_string.upper() == \"EVEN\":\r\n+ # If scaling was even, the correction factor is just the image's aspect ratio\r\n+ # to make UVs match the image proportions.\r\n+ # print(f\" Aspect string is EVEN. Correction factor = current aspect ratio: {current_aspect_ratio:.4f}\")\r\n+ return current_aspect_ratio\r\n+\r\n+ # Handle non-uniform scaling cases (\"Xnnn\", \"Ynnn\")\r\n+ # Use search instead of match to find anywhere in string (though unlikely needed based on format)\r\n+ match = re.search(r\"([XY])(\\d+)\", aspect_string, re.IGNORECASE)\r\n+ if not match:\r\n+ print(f\" Warn: Invalid Scaling string format '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f} as fallback.\")\r\n+ return current_aspect_ratio # Fallback to the image's own ratio\r\n+\r\n+ axis = match.group(1).upper()\r\n+ try:\r\n+ amount = int(match.group(2))\r\n+ if amount <= 0:\r\n+ print(f\" Warn: Zero or negative Amount in Scaling string '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n+ return current_aspect_ratio\r\n+ except ValueError:\r\n+ print(f\" Warn: Invalid Amount in Scaling string '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n+ return current_aspect_ratio\r\n+\r\n+ # Apply the non-uniform correction formula based on original script logic\r\n+ scaling_factor_percent = amount / 100.0\r\n+ correction_factor = current_aspect_ratio # Default\r\n+\r\n+ try:\r\n+ if axis == 'X':\r\n+ if scaling_factor_percent == 0: raise ZeroDivisionError(\"X scaling factor is zero\")\r\n+ # If image was stretched horizontally (X > 1), divide UV.x by factor\r\n+ correction_factor = current_aspect_ratio / scaling_factor_percent\r\n+ elif axis == 'Y':\r\n+ # If image was stretched vertically (Y > 1), multiply UV.x by factor\r\n+ correction_factor = current_aspect_ratio * scaling_factor_percent\r\n+ # No 'else' needed as regex ensures X or Y\r\n+\r\n+ except ZeroDivisionError as e:\r\n+ print(f\" Warn: Division by zero during aspect factor calculation ({e}). Returning current ratio {current_aspect_ratio:.4f}.\")\r\n+ return current_aspect_ratio\r\n+ except Exception as e:\r\n+ print(f\" Error calculating aspect correction factor: {e}. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n+ return current_aspect_ratio\r\n+\r\n+ # print(f\" Calculated aspect correction factor: {correction_factor:.4f} (from {image_width}x{image_height}, Scaling='{aspect_string}')\")\r\n+ return correction_factor\r\n+\r\n+\r\n+def reconstruct_image_path_with_fallback(asset_dir_path, asset_name, map_type, resolution, primary_format=None):\r\n+ \"\"\"\r\n+ Constructs the expected image file path.\r\n+ If primary_format is provided, tries that first.\r\n+ Then falls back to common extensions if the path doesn't exist or primary_format was None.\r\n+ Returns the found path as a string, or None if not found.\r\n+ \"\"\"\r\n+ if not all([asset_dir_path, asset_name, map_type, resolution]):\r\n+ print(f\" !!! ERROR: Missing data for path reconstruction ({asset_name}/{map_type}/{resolution}).\")\r\n+ return None\r\n+\r\n+ found_path = None\r\n+\r\n+ # 1. Try the primary format if provided\r\n+ if primary_format:\r\n+ try:\r\n+ filename = IMAGE_FILENAME_PATTERN.format(\r\n+ asset_name=asset_name,\r\n+ map_type=map_type,\r\n+ resolution=resolution,\r\n+ format=primary_format.lower() # Ensure format is lowercase\r\n+ )\r\n+ primary_path = asset_dir_path / filename\r\n+ if primary_path.is_file():\r\n+ # print(f\" Found primary path: {str(primary_path)}\") # Verbose\r\n+ return str(primary_path)\r\n+ # else: print(f\" Primary path not found: {str(primary_path)}\") # Verbose\r\n+ except KeyError as e:\r\n+ print(f\" !!! ERROR: Missing key '{e}' in IMAGE_FILENAME_PATTERN. Cannot reconstruct path.\")\r\n+ return None # Cannot proceed without valid pattern\r\n+ except Exception as e:\r\n+ print(f\" !!! ERROR reconstructing primary image path: {e}\")\r\n+ # Continue to fallback\r\n+\r\n+ # 2. Try fallback extensions\r\n+ # print(f\" Trying fallback extensions for {map_type}/{resolution}...\") # Verbose\r\n+ for ext in FALLBACK_IMAGE_EXTENSIONS:\r\n+ # Skip if we already tried this extension as primary (and it failed)\r\n+ if primary_format and ext.lower() == primary_format.lower():\r\n+ continue\r\n+ try:\r\n+ fallback_filename = IMAGE_FILENAME_PATTERN.format(\r\n+ asset_name=asset_name,\r\n+ map_type=map_type,\r\n+ resolution=resolution,\r\n+ format=ext.lower()\r\n+ )\r\n+ fallback_path = asset_dir_path / fallback_filename\r\n+ if fallback_path.is_file():\r\n+ print(f\" Found fallback path: {str(fallback_path)}\")\r\n+ return str(fallback_path) # Found it!\r\n+ except KeyError:\r\n+ # Should not happen if primary format worked, but handle defensively\r\n+ print(f\" !!! ERROR: Missing key in IMAGE_FILENAME_PATTERN during fallback. Cannot reconstruct path.\")\r\n+ return None\r\n+ except Exception as e_fallback:\r\n+ print(f\" !!! ERROR reconstructing fallback image path ({ext}): {e_fallback}\")\r\n+ continue # Try next extension\r\n+\r\n+ # If we get here, neither primary nor fallbacks worked\r\n+ if primary_format:\r\n+ print(f\" !!! ERROR: Could not find image file for {map_type}/{resolution} using primary format '{primary_format}' or fallbacks {FALLBACK_IMAGE_EXTENSIONS}.\")\r\n+ else:\r\n+ print(f\" !!! ERROR: Could not find image file for {map_type}/{resolution} using fallbacks {FALLBACK_IMAGE_EXTENSIONS}.\")\r\n+ return None # Not found after all checks\r\n+\r\n+\r\n+# --- Manifest Functions ---\r\n+\r\n+def get_manifest_path(context):\r\n+ \"\"\"Gets the expected path for the manifest JSON file.\"\"\"\r\n+ if not context or not context.blend_data or not context.blend_data.filepath:\r\n+ return None # Cannot determine path if blend file is not saved\r\n+ blend_path = Path(context.blend_data.filepath)\r\n+ manifest_filename = f\"{blend_path.stem}_manifest.json\"\r\n+ return blend_path.parent / manifest_filename\r\n+\r\n+def load_manifest(context):\r\n+ \"\"\"Loads the manifest data from the JSON file.\"\"\"\r\n+ if not ENABLE_MANIFEST:\r\n+ return {} # Manifest disabled\r\n+\r\n+ manifest_path = get_manifest_path(context)\r\n+ if not manifest_path:\r\n+ print(\" Manifest Info: Blend file not saved. Cannot load manifest.\")\r\n+ return {} # Cannot load without a path\r\n+\r\n+ if not manifest_path.exists():\r\n+ print(f\" Manifest Info: No manifest file found at '{manifest_path.name}'. Starting fresh.\")\r\n+ return {} # No manifest file exists yet\r\n+\r\n+ try:\r\n+ with open(manifest_path, 'r', encoding='utf-8') as f:\r\n+ data = json.load(f)\r\n+ print(f\" Manifest Loaded from: {manifest_path.name}\")\r\n+ # Basic validation (check if it's a dictionary)\r\n+ if not isinstance(data, dict):\r\n+ print(f\"!!! WARNING: Manifest file '{manifest_path.name}' has invalid format (not a dictionary). Starting fresh. !!!\")\r\n+ return {}\r\n+ return data\r\n+ except json.JSONDecodeError:\r\n+ print(f\"!!! WARNING: Manifest file '{manifest_path.name}' is corrupted. Starting fresh. !!!\")\r\n+ return {}\r\n+ except Exception as e:\r\n+ print(f\"!!! ERROR: Could not load manifest file '{manifest_path.name}': {e} !!!\")\r\n+ return {} # Treat as starting fresh on error\r\n+\r\n+def save_manifest(context, manifest_data):\r\n+ \"\"\"Saves the manifest data to the JSON file.\"\"\"\r\n+ if not ENABLE_MANIFEST or not manifest_data: # Don't save if disabled or empty\r\n+ return False\r\n+\r\n+ manifest_path = get_manifest_path(context)\r\n+ if not manifest_path:\r\n+ print(\" Manifest Error: Blend file not saved. Cannot save manifest.\")\r\n+ return False\r\n+\r\n+ try:\r\n+ with open(manifest_path, 'w', encoding='utf-8') as f:\r\n+ json.dump(manifest_data, f, indent=2, sort_keys=True) # Use indent and sort for readability\r\n+ print(f\" Manifest Saved to: {manifest_path.name}\")\r\n+ return True\r\n+ except Exception as e:\r\n+ print(f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\\n\"\r\n+ f\"!!! Manifest save FAILED to '{manifest_path.name}': {e} !!!\\n\"\r\n+ f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\")\r\n+ return False\r\n+\r\n+def is_asset_processed(manifest_data, asset_name):\r\n+ \"\"\"Checks if the entire asset (all its maps/resolutions) is marked as processed.\"\"\"\r\n+ if not ENABLE_MANIFEST: return False\r\n+ # Basic check if asset entry exists. Detailed check happens at map level.\r\n+ return asset_name in manifest_data\r\n+\r\n+def is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n+ \"\"\"Checks if a specific map type and resolution for an asset is processed.\"\"\"\r\n+ if not ENABLE_MANIFEST: return False\r\n+ return resolution in manifest_data.get(asset_name, {}).get(map_type, [])\r\n+\r\n+def update_manifest(manifest_data, asset_name, map_type=None, resolution=None):\r\n+ \"\"\"Updates the manifest dictionary in memory.\"\"\"\r\n+ if not ENABLE_MANIFEST: return False\r\n+\r\n+ # Ensure asset entry exists\r\n+ if asset_name not in manifest_data:\r\n+ manifest_data[asset_name] = {}\r\n+\r\n+ # If map_type and resolution are provided, update the specific map entry\r\n+ if map_type and resolution:\r\n+ if map_type not in manifest_data[asset_name]:\r\n+ manifest_data[asset_name][map_type] = []\r\n+\r\n+ if resolution not in manifest_data[asset_name][map_type]:\r\n+ manifest_data[asset_name][map_type].append(resolution)\r\n+ manifest_data[asset_name][map_type].sort() # Keep sorted\r\n+ return True # Indicate that a change was made\r\n+ return False # No change made to this specific map/res\r\n+\r\n+\r\n+# --- Core Logic ---\r\n+\r\n+def process_library(context, asset_library_root_override=None): # Add override parameter\r\n+ global ENABLE_MANIFEST # Declare intent to modify global if needed\r\n+ global PROCESSED_ASSET_LIBRARY_ROOT # Allow modification of global\r\n+ \"\"\"Scans the library, reads metadata, creates/updates node groups.\"\"\"\r\n+ start_time = time.time()\r\n+ print(f\"\\n--- Starting Node Group Processing ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---\")\r\n+ print(f\" DEBUG: Received asset_library_root_override: {asset_library_root_override}\") # DEBUG LOG (Indented)\r\n+\r\n+ # --- Determine Asset Library Root ---\r\n+ if asset_library_root_override:\r\n+ PROCESSED_ASSET_LIBRARY_ROOT = asset_library_root_override\r\n+ print(f\"Using asset library root from argument: '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n+ elif not PROCESSED_ASSET_LIBRARY_ROOT:\r\n+ print(\"!!! ERROR: Processed asset library root not set in script and not provided via argument.\")\r\n+ print(\"--- Script aborted. ---\")\r\n+ return False\r\n+ print(f\" DEBUG: Using final PROCESSED_ASSET_LIBRARY_ROOT: {PROCESSED_ASSET_LIBRARY_ROOT}\") # DEBUG LOG (Indented)\r\n+\r\n+ # --- Pre-run Checks ---\r\n+ print(\"Performing pre-run checks...\")\r\n+ valid_setup = True\r\n+ # 1. Check Library Root Path\r\n+ root_path = Path(PROCESSED_ASSET_LIBRARY_ROOT)\r\n+ if not root_path.is_dir():\r\n+ print(f\"!!! ERROR: Processed asset library root directory not found or not a directory:\")\r\n+ print(f\"!!! '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n+ valid_setup = False\r\n+ else:\r\n+ print(f\" Asset Library Root: '{root_path}'\")\r\n+ print(f\" DEBUG: Checking for templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\") # DEBUG LOG (Indented)\r\n+\r\n+ # 2. Check Templates\r\n+ template_parent = bpy.data.node_groups.get(PARENT_TEMPLATE_NAME)\r\n+ template_child = bpy.data.node_groups.get(CHILD_TEMPLATE_NAME)\r\n+ if not template_parent:\r\n+ print(f\"!!! ERROR: Parent template node group '{PARENT_TEMPLATE_NAME}' not found in this Blender file.\")\r\n+ valid_setup = False\r\n+ if not template_child:\r\n+ print(f\"!!! ERROR: Child template node group '{CHILD_TEMPLATE_NAME}' not found in this Blender file.\")\r\n+ valid_setup = False\r\n+ if template_parent and template_child:\r\n+ print(f\" Found Templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\")\r\n+ print(f\" DEBUG: Template Parent Found: {template_parent is not None}\") # DEBUG LOG (Indented)\r\n+ print(f\" DEBUG: Template Child Found: {template_child is not None}\") # DEBUG LOG (Indented)\r\n+\r\n+ # 3. Check Blend File Saved (if manifest enabled)\r\n+ if ENABLE_MANIFEST and not context.blend_data.filepath:\r\n+ print(f\"!!! WARNING: Manifest is enabled, but the current Blender file is not saved.\")\r\n+ print(f\"!!! Manifest cannot be loaded or saved. Processing will continue without manifest checks.\")\r\n+ ENABLE_MANIFEST = False # Disable manifest for this run\r\n+\r\n+ if not valid_setup:\r\n+ print(\"\\n--- Script aborted due to configuration errors. Please fix the issues above. ---\")\r\n+ return False\r\n+ print(\"Pre-run checks passed.\")\r\n+ # --- End Pre-run Checks ---\r\n+\r\n+ manifest_data = load_manifest(context)\r\n+ manifest_needs_saving = False\r\n+\r\n+ # --- Initialize Counters ---\r\n+ metadata_files_found = 0\r\n+ assets_processed = 0\r\n+ assets_skipped_manifest = 0\r\n+ parent_groups_created = 0\r\n+ parent_groups_updated = 0\r\n+ child_groups_created = 0\r\n+ child_groups_updated = 0\r\n+ images_loaded = 0\r\n+ images_assigned = 0\r\n+ maps_processed = 0\r\n+ maps_skipped_manifest = 0\r\n+ errors_encountered = 0\r\n+ previews_set = 0\r\n+ highest_res_set = 0\r\n+ aspect_ratio_set = 0\r\n+ # --- End Counters ---\r\n+\r\n+ print(f\"\\nScanning for metadata files in '{root_path}'...\")\r\n+\r\n+ # --- Scan for metadata.json ---\r\n+ # Scan one level deeper for supplier folders (e.g., Poliigon)\r\n+ # Then scan within each supplier for asset folders containing metadata.json\r\n+ metadata_paths = []\r\n+ for supplier_dir in root_path.iterdir():\r\n+ if supplier_dir.is_dir():\r\n+ # Now look for asset folders inside the supplier directory\r\n+ for asset_dir in supplier_dir.iterdir():\r\n+ if asset_dir.is_dir():\r\n+ metadata_file = asset_dir / 'metadata.json'\r\n+ if metadata_file.is_file():\r\n+ metadata_paths.append(metadata_file)\r\n+\r\n+ metadata_files_found = len(metadata_paths)\r\n+ print(f\"Found {metadata_files_found} metadata.json files.\")\r\n+ print(f\" DEBUG: Metadata paths found: {metadata_paths}\") # DEBUG LOG (Indented)\r\n+\r\n+ if metadata_files_found == 0:\r\n+ print(\"No metadata files found. Nothing to process.\")\r\n+ print(\"--- Script Finished ---\")\r\n+ return True # No work needed is considered success\r\n+\r\n+ # --- Process Each Metadata File ---\r\n+ for metadata_path in metadata_paths:\r\n+ asset_dir_path = metadata_path.parent # Get the directory containing the metadata file\r\n+ print(f\"\\n--- Processing Metadata: {metadata_path.relative_to(root_path)} ---\")\r\n+ print(f\" DEBUG: Processing file: {metadata_path}\") # DEBUG LOG (Indented)\r\n+ try:\r\n+ with open(metadata_path, 'r', encoding='utf-8') as f:\r\n+ metadata = json.load(f)\r\n+\r\n+ # --- Extract Key Info ---\r\n+ asset_name = metadata.get(\"asset_name\")\r\n+ supplier_name = metadata.get(\"supplier_name\")\r\n+ archetype = metadata.get(\"archetype\")\r\n+ # Get map info from the correct keys\r\n+ processed_resolutions = metadata.get(\"processed_map_resolutions\", {}) # Default to empty dict\r\n+ merged_resolutions = metadata.get(\"merged_map_resolutions\", {}) # Get merged maps too\r\n+ map_details = metadata.get(\"map_details\", {}) # Default to empty dict\r\n+ image_stats_1k = metadata.get(\"image_stats_1k\") # Dict: {map_type: {stats}}\r\n+ aspect_string = metadata.get(\"aspect_ratio_change_string\")\r\n+\r\n+ # Combine processed and merged maps for iteration\r\n+ all_map_resolutions = {**processed_resolutions, **merged_resolutions}\r\n+\r\n+ # Validate essential data\r\n+ if not asset_name:\r\n+ print(f\" !!! ERROR: Metadata file is missing 'asset_name'. Skipping.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ if not all_map_resolutions:\r\n+ print(f\" !!! ERROR: Metadata file has no 'processed_map_resolutions' or 'merged_map_resolutions'. Skipping asset '{asset_name}'.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ # map_details check remains a warning as merged maps won't be in it\r\n+ print(f\" DEBUG: Valid metadata loaded for asset: {asset_name}\") # DEBUG LOG (Indented)\r\n+\r\n+ print(f\" Asset Name: {asset_name}\")\r\n+\r\n+ # --- Determine Highest Resolution ---\r\n+ highest_resolution_value = 0.0\r\n+ highest_resolution_str = \"Unknown\"\r\n+ all_resolutions_present = set()\r\n+ if all_map_resolutions: # Check combined dict\r\n+ for res_list in all_map_resolutions.values():\r\n+ if isinstance(res_list, list):\r\n+ all_resolutions_present.update(res_list)\r\n+\r\n+ if all_resolutions_present:\r\n+ for res_str in RESOLUTION_ORDER_DESC:\r\n+ if res_str in all_resolutions_present:\r\n+ highest_resolution_value = RESOLUTION_VALUE_MAP.get(res_str, 0.0)\r\n+ highest_resolution_str = res_str\r\n+ if highest_resolution_value > 0.0:\r\n+ break # Found the highest valid resolution\r\n+\r\n+ print(f\" Highest resolution found: {highest_resolution_str} (Value: {highest_resolution_value})\")\r\n+\r\n+ # --- Load Reference Image for Aspect Ratio ---\r\n+ ref_image_path = None\r\n+ ref_image_width = 0\r\n+ ref_image_height = 0\r\n+ ref_image_loaded = False\r\n+ # Use combined resolutions dict to find reference map\r\n+ for ref_map_type in REFERENCE_MAP_TYPES:\r\n+ if ref_map_type in all_map_resolutions:\r\n+ available_resolutions = all_map_resolutions[ref_map_type]\r\n+ lowest_res = None\r\n+ for res_pref in REFERENCE_RESOLUTION_ORDER:\r\n+ if res_pref in available_resolutions:\r\n+ lowest_res = res_pref\r\n+ break\r\n+ if lowest_res:\r\n+ # Get format from map_details if available, otherwise None\r\n+ ref_map_details = map_details.get(ref_map_type, {})\r\n+ ref_format = ref_map_details.get(\"output_format\")\r\n+ ref_image_path = reconstruct_image_path_with_fallback(\r\n+ asset_dir_path=asset_dir_path,\r\n+ asset_name=asset_name,\r\n+ map_type=ref_map_type,\r\n+ resolution=lowest_res,\r\n+ primary_format=ref_format # Pass None if not in map_details\r\n+ )\r\n+ if ref_image_path:\r\n+ break # Found a suitable reference image path\r\n+\r\n+ if ref_image_path:\r\n+ print(f\" Loading reference image for aspect ratio: {Path(ref_image_path).name}\")\r\n+ try:\r\n+ # Load image temporarily\r\n+ ref_img = bpy.data.images.load(ref_image_path, check_existing=True)\r\n+ if ref_img:\r\n+ ref_image_width = ref_img.size[0]\r\n+ ref_image_height = ref_img.size[1]\r\n+ ref_image_loaded = True\r\n+ print(f\" Reference image dimensions: {ref_image_width}x{ref_image_height}\")\r\n+ # Remove the temporary image datablock to save memory\r\n+ bpy.data.images.remove(ref_img)\r\n+ else:\r\n+ print(f\" !!! ERROR: Failed loading reference image via bpy.data.images.load: {ref_image_path}\")\r\n+ except Exception as e_ref_load:\r\n+ print(f\" !!! ERROR loading reference image '{ref_image_path}': {e_ref_load}\")\r\n+ else:\r\n+ print(f\" !!! WARNING: Could not find suitable reference image ({REFERENCE_MAP_TYPES} at {REFERENCE_RESOLUTION_ORDER}) for aspect ratio calculation.\")\r\n+\r\n+\r\n+ # --- Manifest Check (Asset Level - Basic) ---\r\n+ if ENABLE_MANIFEST and is_asset_processed(manifest_data, asset_name):\r\n+ # Perform a quick check if *any* map needs processing for this asset\r\n+ needs_processing = False\r\n+ for map_type, resolutions in all_map_resolutions.items(): # Check combined maps\r\n+ for resolution in resolutions:\r\n+ if not is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n+ needs_processing = True\r\n+ break\r\n+ if needs_processing:\r\n+ break\r\n+ if not needs_processing:\r\n+ print(f\" Skipping asset '{asset_name}' (already fully processed according to manifest).\")\r\n+ assets_skipped_manifest += 1\r\n+ continue # Skip to next metadata file\r\n+\r\n+ # --- Parent Group Handling ---\r\n+ target_parent_name = f\"PBRSET_{asset_name}\"\r\n+ parent_group = bpy.data.node_groups.get(target_parent_name)\r\n+ is_new_parent = False\r\n+\r\n+ if parent_group is None:\r\n+ print(f\" Creating new parent group: '{target_parent_name}'\")\r\n+ print(f\" DEBUG: Copying parent template '{PARENT_TEMPLATE_NAME}'\") # DEBUG LOG (Indented)\r\n+ parent_group = template_parent.copy()\r\n+ if not parent_group:\r\n+ print(f\" !!! ERROR: Failed to copy parent template '{PARENT_TEMPLATE_NAME}'. Skipping asset '{asset_name}'.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ parent_group.name = target_parent_name\r\n+ parent_groups_created += 1\r\n+ is_new_parent = True\r\n+ else:\r\n+ print(f\" Updating existing parent group: '{target_parent_name}'\")\r\n+ print(f\" DEBUG: Found existing parent group.\") # DEBUG LOG (Indented)\r\n+ parent_groups_updated += 1\r\n+\r\n+ # Ensure marked as asset\r\n+ if not parent_group.asset_data:\r\n+ try:\r\n+ parent_group.asset_mark()\r\n+ print(f\" Marked '{parent_group.name}' as asset.\")\r\n+ except Exception as e_mark:\r\n+ print(f\" !!! ERROR: Failed to mark group '{parent_group.name}' as asset: {e_mark}\")\r\n+ # Continue processing other parts if possible\r\n+\r\n+ # Apply Asset Tags\r\n+ if parent_group.asset_data:\r\n+ if supplier_name:\r\n+ add_tag_if_new(parent_group.asset_data, supplier_name)\r\n+ if archetype:\r\n+ add_tag_if_new(parent_group.asset_data, archetype)\r\n+ # Add other tags if needed\r\n+ # else: print(f\" Warn: Cannot apply tags, asset_data not available for '{parent_group.name}'.\") # Optional warning\r\n+\r\n+ # Apply Aspect Ratio Correction\r\n+ aspect_nodes = find_nodes_by_label(parent_group, ASPECT_RATIO_NODE_LABEL, 'ShaderNodeValue')\r\n+ if aspect_nodes:\r\n+ aspect_node = aspect_nodes[0]\r\n+ correction_factor = 1.0 # Default if ref image fails\r\n+ if ref_image_loaded:\r\n+ correction_factor = calculate_aspect_correction_factor(ref_image_width, ref_image_height, aspect_string)\r\n+ print(f\" Calculated aspect correction factor: {correction_factor:.4f}\")\r\n+ else:\r\n+ print(f\" !!! WARNING: Using default aspect ratio correction (1.0) due to missing reference image.\")\r\n+\r\n+ # Check if update is needed\r\n+ current_val = aspect_node.outputs[0].default_value\r\n+ if abs(current_val - correction_factor) > 0.0001:\r\n+ aspect_node.outputs[0].default_value = correction_factor\r\n+ print(f\" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f} (was {current_val:.4f})\")\r\n+ aspect_ratio_set += 1\r\n+ # else: print(f\" Warn: Aspect ratio node '{ASPECT_RATIO_NODE_LABEL}' not found in parent group.\") # Optional\r\n+\r\n+ # Apply Highest Resolution Value\r\n+ hr_nodes = find_nodes_by_label(parent_group, HIGHEST_RESOLUTION_NODE_LABEL, 'ShaderNodeValue')\r\n+ if hr_nodes:\r\n+ hr_node = hr_nodes[0]\r\n+ current_hr_val = hr_node.outputs[0].default_value\r\n+ if highest_resolution_value > 0.0 and abs(current_hr_val - highest_resolution_value) > 0.001:\r\n+ hr_node.outputs[0].default_value = highest_resolution_value\r\n+ print(f\" Set '{HIGHEST_RESOLUTION_NODE_LABEL}' value to {highest_resolution_value} ({highest_resolution_str}) (was {current_hr_val:.1f})\")\r\n+ highest_res_set += 1 # Count successful sets\r\n+ # else: print(f\" Warn: Highest resolution node '{HIGHEST_RESOLUTION_NODE_LABEL}' not found in parent group.\") # Optional\r\n+\r\n+\r\n+ # Apply Stats (using image_stats_1k)\r\n+ if image_stats_1k and isinstance(image_stats_1k, dict):\r\n+ for map_type_to_stat in APPLY_STATS_FOR_MAP_TYPES:\r\n+ if map_type_to_stat in image_stats_1k:\r\n+ # Find the stats node in the parent group\r\n+ stats_node_label = f\"{STATS_NODE_PREFIX}{map_type_to_stat}\"\r\n+ stats_nodes = find_nodes_by_label(parent_group, stats_node_label, 'ShaderNodeCombineXYZ')\r\n+ if stats_nodes:\r\n+ stats_node = stats_nodes[0]\r\n+ stats = image_stats_1k[map_type_to_stat] # Get stats dict for this map type\r\n+\r\n+ if stats and isinstance(stats, dict):\r\n+ # Handle potential list format for RGB stats (use first value) or direct float\r\n+ def get_stat_value(stat_val):\r\n+ if isinstance(stat_val, list):\r\n+ return stat_val[0] if stat_val else None\r\n+ return stat_val\r\n+\r\n+ min_val = get_stat_value(stats.get(\"min\"))\r\n+ max_val = get_stat_value(stats.get(\"max\"))\r\n+ mean_val = get_stat_value(stats.get(\"mean\")) # Often stored as 'mean' or 'avg'\r\n+\r\n+ updated_stat = False\r\n+ # Check inputs exist before assigning\r\n+ input_x = stats_node.inputs.get(\"X\")\r\n+ input_y = stats_node.inputs.get(\"Y\")\r\n+ input_z = stats_node.inputs.get(\"Z\")\r\n+\r\n+ if input_x and min_val is not None and abs(input_x.default_value - min_val) > 0.0001:\r\n+ input_x.default_value = min_val\r\n+ updated_stat = True\r\n+ if input_y and max_val is not None and abs(input_y.default_value - max_val) > 0.0001:\r\n+ input_y.default_value = max_val\r\n+ updated_stat = True\r\n+ if input_z and mean_val is not None and abs(input_z.default_value - mean_val) > 0.0001:\r\n+ input_z.default_value = mean_val\r\n+ updated_stat = True\r\n+\r\n+ if updated_stat:\r\n+ print(f\" Set stats in '{stats_node_label}': Min={min_val:.4f}, Max={max_val:.4f}, Mean={mean_val:.4f}\")\r\n+ # else: print(f\" Info: No valid 'stats' dictionary found for map type '{map_type_to_stat}' in image_stats_1k.\") # Optional\r\n+ # else: print(f\" Warn: Stats node '{stats_node_label}' not found in parent group.\") # Optional\r\n+ # else: print(f\" Info: Map type '{map_type_to_stat}' not present in image_stats_1k for stats application.\") # Optional\r\n+ # else: print(f\" Warn: 'image_stats_1k' missing or invalid in metadata.\") # Optional\r\n+\r\n+ # --- Set Asset Preview (only for new parent groups) ---\r\n+ # Use the reference image path found earlier if available\r\n+ if is_new_parent and parent_group.asset_data:\r\n+ if ref_image_loaded and ref_image_path: # Check if ref image was successfully loaded earlier\r\n+ print(f\" Attempting to set preview from reference image: {Path(ref_image_path).name}\")\r\n+ try:\r\n+ # Ensure the ID (node group) is the active one for the operator context\r\n+ with context.temp_override(id=parent_group):\r\n+ bpy.ops.ed.lib_id_load_custom_preview(filepath=ref_image_path)\r\n+ print(f\" Successfully set custom preview.\")\r\n+ previews_set += 1\r\n+ except Exception as e_preview:\r\n+ print(f\" !!! ERROR setting custom preview: {e_preview}\")\r\n+ errors_encountered += 1\r\n+ else:\r\n+ print(f\" Info: Could not set preview for '{asset_name}' as reference image was not found or loaded.\")\r\n+\r\n+\r\n+ # --- Child Group Handling ---\r\n+ # Iterate through the COMBINED map types\r\n+ print(f\" DEBUG: Starting child group loop for asset '{asset_name}'. Map types: {list(all_map_resolutions.keys())}\") # DEBUG LOG (Indented)\r\n+ for map_type, resolutions in all_map_resolutions.items():\r\n+ print(f\" Processing Map Type: {map_type}\")\r\n+\r\n+ # Determine if this is a merged map (not in map_details)\r\n+ is_merged_map = map_type not in map_details\r\n+\r\n+ # Get details for this map type if available\r\n+ current_map_details = map_details.get(map_type, {})\r\n+ # For merged maps, primary_format will be None\r\n+ output_format = current_map_details.get(\"output_format\")\r\n+\r\n+ if not output_format and not is_merged_map:\r\n+ # This case should ideally not happen if metadata is well-formed\r\n+ # but handle defensively for processed maps.\r\n+ print(f\" !!! WARNING: Missing 'output_format' in map_details for processed map '{map_type}'. Path reconstruction might fail.\")\r\n+ # We will rely solely on fallback for this map type\r\n+\r\n+ # Find placeholder node in parent\r\n+ holder_nodes = find_nodes_by_label(parent_group, map_type, 'ShaderNodeGroup')\r\n+ if not holder_nodes:\r\n+ print(f\" !!! WARNING: No placeholder node labeled '{map_type}' found in parent group '{parent_group.name}'. Skipping this map type.\")\r\n+ continue\r\n+ holder_node = holder_nodes[0] # Assume first is correct\r\n+ print(f\" DEBUG: Found placeholder node '{holder_node.label}' for map type '{map_type}'.\") # DEBUG LOG (Indented)\r\n+\r\n+ # Determine child group name (LOGICAL and ENCODED)\r\n+ logical_child_name = f\"{asset_name}_{map_type}\"\r\n+ target_child_name_b64 = encode_name_b64(logical_child_name) # Use Base64 name\r\n+\r\n+ child_group = bpy.data.node_groups.get(target_child_name_b64) # Find using encoded name\r\n+ is_new_child = False\r\n+\r\n+ if child_group is None:\r\n+ print(f\" DEBUG: Child group '{target_child_name_b64}' not found. Creating new one.\") # DEBUG LOG (Indented)\r\n+ # print(f\" Creating new child group: '{target_child_name_b64}' (logical: '{logical_child_name}')\") # Verbose\r\n+ child_group = template_child.copy()\r\n+ if not child_group:\r\n+ print(f\" !!! ERROR: Failed to copy child template '{CHILD_TEMPLATE_NAME}'. Skipping map type '{map_type}'.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ child_group.name = target_child_name_b64 # Set encoded name\r\n+ child_groups_created += 1\r\n+ is_new_child = True\r\n+ else:\r\n+ print(f\" DEBUG: Found existing child group '{target_child_name_b64}'.\") # DEBUG LOG (Indented)\r\n+ # print(f\" Updating existing child group: '{target_child_name_b64}'\") # Verbose\r\n+ child_groups_updated += 1\r\n+\r\n+ # Assign child group to placeholder if needed\r\n+ if holder_node.node_tree != child_group:\r\n+ try:\r\n+ holder_node.node_tree = child_group\r\n+ print(f\" Assigned child group '{child_group.name}' to placeholder '{holder_node.label}'.\")\r\n+ except TypeError as e_assign: # Catch potential type errors if placeholder isn't a group node\r\n+ print(f\" !!! ERROR: Could not assign child group to placeholder '{holder_node.label}'. Is it a Group Node? Error: {e_assign}\")\r\n+ continue # Skip this map type if assignment fails\r\n+\r\n+ # Link placeholder output to parent output socket\r\n+ try:\r\n+ # Find parent's output node\r\n+ group_output_node = next((n for n in parent_group.nodes if n.type == 'GROUP_OUTPUT'), None)\r\n+ if group_output_node:\r\n+ # Get the specific output socket on the placeholder (usually index 0 or named 'Color'/'Value')\r\n+ source_socket = holder_node.outputs.get(\"Color\") or holder_node.outputs.get(\"Value\") or holder_node.outputs[0]\r\n+ # Get the specific input socket on the parent output node (matching map_type)\r\n+ target_socket = group_output_node.inputs.get(map_type)\r\n+\r\n+ if source_socket and target_socket:\r\n+ # Check if link already exists\r\n+ link_exists = any(link.from_socket == source_socket and link.to_socket == target_socket for link in parent_group.links)\r\n+ if not link_exists:\r\n+ parent_group.links.new(source_socket, target_socket)\r\n+ print(f\" Linked '{holder_node.label}' output to parent output socket '{map_type}'.\")\r\n+ # else: # Optional warnings\r\n+ # if not source_socket: print(f\" Warn: Could not find suitable output socket on placeholder '{holder_node.label}'.\")\r\n+ # if not target_socket: print(f\" Warn: Could not find input socket '{map_type}' on parent output node.\")\r\n+ # else: print(f\" Warn: Parent group '{parent_group.name}' has no Group Output node.\")\r\n+\r\n+ except Exception as e_link:\r\n+ print(f\" !!! ERROR linking sockets for '{map_type}': {e_link}\")\r\n+\r\n+ # Ensure parent output socket type is Color (if it exists)\r\n+ try:\r\n+ # Use the interface API for modern Blender versions\r\n+ item = parent_group.interface.items_tree.get(map_type)\r\n+ if item and item.item_type == 'SOCKET' and item.in_out == 'OUTPUT':\r\n+ # Common useful types: 'NodeSocketColor', 'NodeSocketVector', 'NodeSocketFloat'\r\n+ # Defaulting to Color seems reasonable for most PBR outputs\r\n+ if item.socket_type != 'NodeSocketColor':\r\n+ item.socket_type = 'NodeSocketColor'\r\n+ # print(f\" Set parent output socket '{map_type}' type to Color.\") # Optional info\r\n+ except Exception as e_sock_type:\r\n+ print(f\" Warn: Could not verify/set socket type for '{map_type}': {e_sock_type}\")\r\n+\r\n+\r\n+ # --- Image Node Handling (Inside Child Group) ---\r\n+ if not isinstance(resolutions, list):\r\n+ print(f\" !!! ERROR: Invalid format for resolutions list for map type '{map_type}'. Skipping.\")\r\n+ continue\r\n+\r\n+ for resolution in resolutions:\r\n+ # --- Manifest Check (Map/Resolution Level) ---\r\n+ if ENABLE_MANIFEST and is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n+ # print(f\" Skipping {resolution} (Manifest)\") # Verbose\r\n+ maps_skipped_manifest += 1\r\n+ continue\r\n+ print(f\" DEBUG: Processing map '{map_type}' resolution '{resolution}'. Manifest skip check passed.\") # DEBUG LOG (Indented)\r\n+\r\n+ print(f\" Processing Resolution: {resolution}\")\r\n+\r\n+ # Reconstruct the image path using fallback logic\r\n+ # Pass output_format (which might be None for merged maps)\r\n+ image_path_str = reconstruct_image_path_with_fallback(\r\n+ asset_dir_path=asset_dir_path,\r\n+ asset_name=asset_name,\r\n+ map_type=map_type,\r\n+ resolution=resolution,\r\n+ primary_format=output_format\r\n+ )\r\n+ print(f\" DEBUG: Reconstructed image path for {map_type}/{resolution}: {image_path_str}\") # DEBUG LOG (Indented)\r\n+\r\n+ if not image_path_str:\r\n+ # Error already printed by reconstruct function\r\n+ errors_encountered += 1\r\n+ continue # Skip this resolution if path not found\r\n+\r\n+ # Find image texture node within the CHILD group (labeled by resolution, e.g., \"4K\")\r\n+ image_nodes = find_nodes_by_label(child_group, resolution, 'ShaderNodeTexImage')\r\n+ if not image_nodes:\r\n+ print(f\" !!! WARNING: No Image Texture node labeled '{resolution}' found in child group '{child_group.name}'. Cannot assign image.\")\r\n+ continue # Skip this resolution if node not found\r\n+ print(f\" DEBUG: Found {len(image_nodes)} image node(s) labeled '{resolution}' in child group '{child_group.name}'.\") # DEBUG LOG (Indented)\r\n+\r\n+ # --- Load Image ---\r\n+ img = None\r\n+ image_load_failed = False\r\n+ try:\r\n+ image_path = Path(image_path_str) # Path object created from already found path string\r\n+ # Use check_existing=True to reuse existing datablocks if path matches\r\n+ img = bpy.data.images.load(str(image_path), check_existing=True)\r\n+ if not img:\r\n+ print(f\" !!! ERROR: Failed loading image via bpy.data.images.load: {image_path_str}\")\r\n+ image_load_failed = True\r\n+ else:\r\n+ # Only count as loaded if bpy.data.images.load succeeded\r\n+ # Check if it's newly loaded or reused\r\n+ is_newly_loaded = img.library is None # Newly loaded images don't have a library initially\r\n+ if is_newly_loaded: images_loaded += 1\r\n+\r\n+ except RuntimeError as e_runtime_load:\r\n+ # Catch specific Blender runtime errors (e.g., unsupported format)\r\n+ print(f\" !!! ERROR loading image '{image_path_str}': {e_runtime_load}\")\r\n+ image_load_failed = True\r\n+ except Exception as e_gen_load:\r\n+ print(f\" !!! UNEXPECTED ERROR loading image '{image_path_str}': {e_gen_load}\")\r\n+ image_load_failed = True\r\n+ errors_encountered += 1\r\n+\r\n+ # --- Assign Image & Set Color Space ---\r\n+ if not image_load_failed and img:\r\n+ assigned_count_this_res = 0\r\n+ for image_node in image_nodes:\r\n+ if image_node.image != img:\r\n+ image_node.image = img\r\n+ assigned_count_this_res += 1\r\n+\r\n+ if assigned_count_this_res > 0:\r\n+ images_assigned += assigned_count_this_res\r\n+ print(f\" Assigned image '{img.name}' to {assigned_count_this_res} node(s).\")\r\n+\r\n+ # Set Color Space\r\n+ correct_color_space = get_color_space(map_type)\r\n+ try:\r\n+ if img.colorspace_settings.name != correct_color_space:\r\n+ img.colorspace_settings.name = correct_color_space\r\n+ print(f\" Set '{img.name}' color space -> {correct_color_space}\")\r\n+ except TypeError as e_cs: # Handle case where colorspace name is invalid\r\n+ print(f\" !!! WARNING: Could not set color space '{correct_color_space}' for image '{img.name}'. Is the color space available in Blender? Error: {e_cs}\")\r\n+ except Exception as e_cs_gen:\r\n+ print(f\" !!! ERROR setting color space for image '{img.name}': {e_cs_gen}\")\r\n+\r\n+\r\n+ # --- Update Manifest (Map/Resolution Level) ---\r\n+ if update_manifest(manifest_data, asset_name, map_type, resolution):\r\n+ manifest_needs_saving = True\r\n+ # print(f\" Marked {map_type}/{resolution} processed in manifest.\") # Verbose\r\n+ maps_processed += 1\r\n+\r\n+ else:\r\n+ # Increment error count if loading failed\r\n+ if image_load_failed: errors_encountered += 1\r\n+\r\n+ # --- End Resolution Loop ---\r\n+ # --- End Map Type Loop ---\r\n+\r\n+ assets_processed += 1\r\n+\r\n+ except FileNotFoundError:\r\n+ print(f\" !!! ERROR: Metadata file not found (should not happen if scan worked): {metadata_path}\")\r\n+ errors_encountered += 1\r\n+ except json.JSONDecodeError:\r\n+ print(f\" !!! ERROR: Invalid JSON in metadata file: {metadata_path}\")\r\n+ errors_encountered += 1\r\n+ except Exception as e_main_loop:\r\n+ print(f\" !!! UNEXPECTED ERROR processing asset from {metadata_path}: {e_main_loop}\")\r\n+ import traceback\r\n+ traceback.print_exc() # Print detailed traceback for debugging\r\n+ errors_encountered += 1\r\n+ # Continue to the next asset\r\n+\r\n+ # --- End Metadata File Loop ---\r\n+\r\n+ # --- Final Manifest Save ---\r\n+ if ENABLE_MANIFEST and manifest_needs_saving:\r\n+ print(\"\\nAttempting final manifest save...\")\r\n+ save_manifest(context, manifest_data)\r\n+ elif ENABLE_MANIFEST:\r\n+ print(\"\\nManifest is enabled, but no changes require saving.\")\r\n+ # --- End Final Manifest Save ---\r\n+\r\n+ # --- Final Summary ---\r\n+ end_time = time.time()\r\n+ duration = end_time - start_time\r\n+ print(\"\\n--- Script Run Finished ---\")\r\n+ print(f\"Duration: {duration:.2f} seconds\")\r\n+ print(f\"Metadata Files Found: {metadata_files_found}\")\r\n+ print(f\"Assets Processed/Attempted: {assets_processed}\")\r\n+ if ENABLE_MANIFEST:\r\n+ print(f\"Assets Skipped (Manifest): {assets_skipped_manifest}\")\r\n+ print(f\"Maps Skipped (Manifest): {maps_skipped_manifest}\")\r\n+ print(f\"Parent Groups Created: {parent_groups_created}\")\r\n+ print(f\"Parent Groups Updated: {parent_groups_updated}\")\r\n+ print(f\"Child Groups Created: {child_groups_created}\")\r\n+ print(f\"Child Groups Updated: {child_groups_updated}\")\r\n+ print(f\"Images Loaded: {images_loaded}\")\r\n+ print(f\"Image Nodes Assigned: {images_assigned}\")\r\n+ print(f\"Individual Maps Processed: {maps_processed}\")\r\n+ print(f\"Asset Previews Set: {previews_set}\")\r\n+ print(f\"Highest Resolution Nodes Set: {highest_res_set}\")\r\n+ print(f\"Aspect Ratio Nodes Set: {aspect_ratio_set}\") # Added counter\r\n+ if errors_encountered > 0:\r\n+ print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n+ print(\"---------------------------\")\r\n+\r\n+ # --- Explicit Save ---\r\n+ print(f\" DEBUG: Attempting explicit save for file: {bpy.data.filepath}\") # DEBUG LOG (Indented)\r\n+ try:\r\n+ bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)\r\n+ print(\"\\n--- Explicitly saved the .blend file. ---\")\r\n+ except Exception as e_save:\r\n+ print(f\"\\n!!! ERROR explicitly saving .blend file: {e_save} !!!\")\r\n+ errors_encountered += 1 # Count save errors\r\n+\r\n+ return True\r\n+\r\n+\r\n+# --- Execution Block ---\r\n+\r\n+if __name__ == \"__main__\":\r\n+ # Ensure we are running within Blender\r\n+ try:\r\n+ import bpy\r\n+ import base64 # Ensure base64 is imported here too if needed globally\r\n+ import sys\r\n+ except ImportError:\r\n+ print(\"!!! ERROR: This script must be run from within Blender. !!!\")\r\n+ else:\r\n+ # --- Argument Parsing for Asset Library Root ---\r\n+ asset_root_arg = None\r\n+ try:\r\n+ # Blender arguments passed after '--' appear in sys.argv\r\n+ if \"--\" in sys.argv:\r\n+ args_after_dash = sys.argv[sys.argv.index(\"--\") + 1:]\r\n+ if len(args_after_dash) >= 1:\r\n+ asset_root_arg = args_after_dash[0]\r\n+ print(f\"Found asset library root argument: {asset_root_arg}\")\r\n+ else:\r\n+ print(\"Info: '--' found but no arguments after it.\")\r\n+ # else: print(\"Info: No '--' found in arguments.\") # Optional debug\r\n+ except Exception as e:\r\n+ print(f\"Error parsing command line arguments: {e}\")\r\n+ # --- End Argument Parsing ---\r\n+\r\n+ process_library(bpy.context, asset_library_root_override=asset_root_arg)\r\n" }, { "date": 1745267735035, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -0,0 +1,1019 @@\n+# Blender Script: Create/Update Node Groups from Asset Processor Output\r\n+# Version: 1.5\r\n+# Description: Scans a library processed by the Asset Processor Tool,\r\n+# reads metadata.json files, and creates/updates corresponding\r\n+# PBR node groups in the active Blender file.\r\n+# Changes v1.5:\r\n+# - Corrected aspect ratio calculation (`calculate_aspect_correction_factor`)\r\n+# to use actual image dimensions from a loaded reference image and the\r\n+# `aspect_ratio_change_string`, mirroring original script logic for\r\n+# \"EVEN\", \"Xnnn\", \"Ynnn\" formats.\r\n+# - Added logic in main loop to load reference image for dimensions.\r\n+# Changes v1.3:\r\n+# - Added logic to find the highest resolution present for an asset.\r\n+# - Added logic to set a \"HighestResolution\" Value node in the parent group\r\n+# (maps 1K->1.0, 2K->2.0, 4K->3.0, 8K->4.0).\r\n+# Changes v1.2:\r\n+# - Added Base64 encoding for child node group names (PBRTYPE_...).\r\n+# - Added fallback logic for reconstructing image paths with different extensions.\r\n+# - Added logic to set custom asset preview for new parent groups (using lowest res COL map).\r\n+# Changes v1.1:\r\n+# - Updated metadata parsing to match actual structure (using processed_map_resolutions, image_stats_1k, map_details).\r\n+# - Added logic to reconstruct image file paths based on metadata and assumed naming convention.\r\n+\r\n+import bpy\r\n+import os\r\n+import json\r\n+from pathlib import Path\r\n+import time\r\n+import re # For parsing aspect ratio string\r\n+import base64 # For encoding node group names\r\n+import sys # <<< ADDED IMPORT\r\n+\r\n+# --- USER CONFIGURATION ---\r\n+\r\n+# Path to the root output directory of the Asset Processor Tool\r\n+# Example: r\"G:\\Assets\\Processed\"\r\n+# IMPORTANT: This should point to the base directory containing supplier folders (e.g., Poliigon)\r\n+# This will be overridden by command-line arguments if provided.\r\n+PROCESSED_ASSET_LIBRARY_ROOT = None # Set to None initially\r\n+\r\n+# Names of the required node group templates in the Blender file\r\n+PARENT_TEMPLATE_NAME = \"Template_PBRSET\"\r\n+CHILD_TEMPLATE_NAME = \"Template_PBRTYPE\"\r\n+\r\n+# Labels of specific nodes within the PARENT template\r\n+ASPECT_RATIO_NODE_LABEL = \"AspectRatioCorrection\" # Value node for UV X-scaling factor\r\n+STATS_NODE_PREFIX = \"Histogram-\" # Prefix for Combine XYZ nodes storing stats (e.g., \"Histogram-ROUGH\")\r\n+HIGHEST_RESOLUTION_NODE_LABEL = \"HighestResolution\" # Value node to store highest res index\r\n+\r\n+# Enable/disable the manifest system to track processed assets/maps\r\n+# If enabled, requires the blend file to be saved.\r\n+ENABLE_MANIFEST = False # Disabled based on user feedback in previous run\r\n+\r\n+# Assumed filename pattern for processed images.\r\n+# {asset_name}, {map_type}, {resolution}, {format} will be replaced.\r\n+# Check Asset Processor Tool's config.py (TARGET_FILENAME_PATTERN) if this is wrong.\r\n+IMAGE_FILENAME_PATTERN = \"{asset_name}_{map_type}_{resolution}.{format}\"\r\n+\r\n+# Fallback extensions to try if the primary format from metadata is not found\r\n+# Order matters - first found will be used.\r\n+FALLBACK_IMAGE_EXTENSIONS = ['png', 'jpg', 'exr', 'tif']\r\n+\r\n+# Map type(s) to use for generating the asset preview AND for aspect ratio calculation reference\r\n+# The script will look for these in order and use the first one found.\r\n+REFERENCE_MAP_TYPES = [\"COL\", \"COL-1\", \"COL-2\"] # Used for preview and aspect calc\r\n+# Preferred resolution order for reference image (lowest first is often faster)\r\n+REFERENCE_RESOLUTION_ORDER = [\"1K\", \"512\", \"2K\", \"4K\"] # Adjust as needed\r\n+\r\n+# Mapping from resolution string to numerical value for the HighestResolution node\r\n+RESOLUTION_VALUE_MAP = {\"1K\": 1.0, \"2K\": 2.0, \"4K\": 3.0, \"8K\": 4.0}\r\n+# Order to check resolutions to find the highest present (highest value first)\r\n+RESOLUTION_ORDER_DESC = [\"8K\", \"4K\", \"2K\", \"1K\"] # Add others like \"512\" if needed and map them in RESOLUTION_VALUE_MAP\r\n+\r\n+# Map PBR type strings (from metadata) to Blender color spaces\r\n+# Add more mappings as needed based on your metadata types\r\n+PBR_COLOR_SPACE_MAP = {\r\n+ \"AO\": \"Non-Color\", # Usually Non-Color, but depends on workflow\r\n+ \"COL\": \"sRGB\",\r\n+ \"COL-1\": \"sRGB\", # Handle variants if present in metadata\r\n+ \"COL-2\": \"sRGB\",\r\n+ \"COL-3\": \"sRGB\",\r\n+ \"DISP\": \"Non-Color\",\r\n+ \"NRM\": \"Non-Color\",\r\n+ \"REFL\": \"Non-Color\", # Reflection/Specular\r\n+ \"ROUGH\": \"Non-Color\",\r\n+ \"METAL\": \"Non-Color\",\r\n+ \"OPC\": \"Non-Color\", # Opacity/Alpha\r\n+ \"TRN\": \"Non-Color\", # Transmission\r\n+ \"SSS\": \"sRGB\", # Subsurface Color\r\n+ \"EMISS\": \"sRGB\", # Emission Color\r\n+ \"NRMRGH\": \"Non-Color\", # Added for merged map\r\n+ \"FUZZ\": \"Non-Color\",\r\n+ # Add other types like GLOSS, HEIGHT, etc. if needed\r\n+}\r\n+DEFAULT_COLOR_SPACE = \"sRGB\" # Fallback if map type not in the dictionary\r\n+\r\n+# Map types for which stats should be applied (if found in metadata and node exists)\r\n+# Reads stats from the 'image_stats_1k' section of metadata.json\r\n+APPLY_STATS_FOR_MAP_TYPES = [\"ROUGH\", \"DISP\", \"METAL\", \"AO\", \"REFL\"] # Add others if needed\r\n+\r\n+# --- END USER CONFIGURATION ---\r\n+\r\n+\r\n+# --- Helper Functions ---\r\n+\r\n+def encode_name_b64(name_str):\r\n+ \"\"\"Encodes a string using URL-safe Base64 for node group names.\"\"\"\r\n+ try:\r\n+ # Ensure the input is a string\r\n+ name_str = str(name_str)\r\n+ return base64.urlsafe_b64encode(name_str.encode('utf-8')).decode('ascii')\r\n+ except Exception as e:\r\n+ print(f\" Error base64 encoding '{name_str}': {e}\")\r\n+ return name_str # Fallback to original name on error\r\n+\r\n+def find_nodes_by_label(node_tree, label, node_type=None):\r\n+ \"\"\"Finds ALL nodes in a node tree matching the label and optionally type.\"\"\"\r\n+ if not node_tree:\r\n+ return []\r\n+ matching_nodes = []\r\n+ for node in node_tree.nodes:\r\n+ # Use node.label for labeled nodes, node.name for non-labeled (like Group Input/Output)\r\n+ node_identifier = node.label if node.label else node.name\r\n+ if node_identifier and node_identifier == label:\r\n+ if node_type is None or node.bl_idname == node_type or node.type == node_type: # Check bl_idname and type for flexibility\r\n+ matching_nodes.append(node)\r\n+ return matching_nodes\r\n+\r\n+def add_tag_if_new(asset_data, tag_name):\r\n+ \"\"\"Adds a tag to the asset data if it's not None/empty and doesn't already exist.\"\"\"\r\n+ if not asset_data or not tag_name or not isinstance(tag_name, str):\r\n+ return False\r\n+ cleaned_tag_name = tag_name.strip()\r\n+ if not cleaned_tag_name:\r\n+ return False\r\n+\r\n+ # Check if tag already exists (case-insensitive check might be better sometimes)\r\n+ if cleaned_tag_name not in [t.name for t in asset_data.tags]:\r\n+ try:\r\n+ asset_data.tags.new(cleaned_tag_name)\r\n+ print(f\" + Added Asset Tag: '{cleaned_tag_name}'\")\r\n+ return True\r\n+ except Exception as e:\r\n+ print(f\" Error adding tag '{cleaned_tag_name}': {e}\")\r\n+ return False\r\n+ return False # Tag already existed\r\n+\r\n+def get_color_space(map_type):\r\n+ \"\"\"Returns the appropriate Blender color space name for a given map type string.\"\"\"\r\n+ # Handle potential numbered variants like COL-1, COL-2\r\n+ base_map_type = map_type.split('-')[0]\r\n+ return PBR_COLOR_SPACE_MAP.get(map_type.upper(), # Check full name first (e.g., NRMRGH)\r\n+ PBR_COLOR_SPACE_MAP.get(base_map_type.upper(), DEFAULT_COLOR_SPACE)) # Fallback to base type\r\n+\r\n+def calculate_aspect_correction_factor(image_width, image_height, aspect_string):\r\n+ \"\"\"\r\n+ Calculates the UV X-axis scaling factor needed to correct distortion,\r\n+ based on image dimensions and the aspect_ratio_change_string (\"EVEN\", \"Xnnn\", \"Ynnn\").\r\n+ Mirrors the logic from the original POC script.\r\n+ Returns 1.0 if dimensions are invalid or string is \"EVEN\" or invalid.\r\n+ \"\"\"\r\n+ if image_height <= 0 or image_width <= 0:\r\n+ print(\" Warn: Invalid image dimensions for aspect ratio calculation. Returning 1.0.\")\r\n+ return 1.0\r\n+\r\n+ # Calculate the actual aspect ratio of the image file\r\n+ current_aspect_ratio = image_width / image_height\r\n+\r\n+ if not aspect_string or aspect_string.upper() == \"EVEN\":\r\n+ # If scaling was even, the correction factor is just the image's aspect ratio\r\n+ # to make UVs match the image proportions.\r\n+ # print(f\" Aspect string is EVEN. Correction factor = current aspect ratio: {current_aspect_ratio:.4f}\")\r\n+ return current_aspect_ratio\r\n+\r\n+ # Handle non-uniform scaling cases (\"Xnnn\", \"Ynnn\")\r\n+ # Use search instead of match to find anywhere in string (though unlikely needed based on format)\r\n+ match = re.search(r\"([XY])(\\d+)\", aspect_string, re.IGNORECASE)\r\n+ if not match:\r\n+ print(f\" Warn: Invalid Scaling string format '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f} as fallback.\")\r\n+ return current_aspect_ratio # Fallback to the image's own ratio\r\n+\r\n+ axis = match.group(1).upper()\r\n+ try:\r\n+ amount = int(match.group(2))\r\n+ if amount <= 0:\r\n+ print(f\" Warn: Zero or negative Amount in Scaling string '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n+ return current_aspect_ratio\r\n+ except ValueError:\r\n+ print(f\" Warn: Invalid Amount in Scaling string '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n+ return current_aspect_ratio\r\n+\r\n+ # Apply the non-uniform correction formula based on original script logic\r\n+ scaling_factor_percent = amount / 100.0\r\n+ correction_factor = current_aspect_ratio # Default\r\n+\r\n+ try:\r\n+ if axis == 'X':\r\n+ if scaling_factor_percent == 0: raise ZeroDivisionError(\"X scaling factor is zero\")\r\n+ # If image was stretched horizontally (X > 1), divide UV.x by factor\r\n+ correction_factor = current_aspect_ratio / scaling_factor_percent\r\n+ elif axis == 'Y':\r\n+ # If image was stretched vertically (Y > 1), multiply UV.x by factor\r\n+ correction_factor = current_aspect_ratio * scaling_factor_percent\r\n+ # No 'else' needed as regex ensures X or Y\r\n+\r\n+ except ZeroDivisionError as e:\r\n+ print(f\" Warn: Division by zero during aspect factor calculation ({e}). Returning current ratio {current_aspect_ratio:.4f}.\")\r\n+ return current_aspect_ratio\r\n+ except Exception as e:\r\n+ print(f\" Error calculating aspect correction factor: {e}. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n+ return current_aspect_ratio\r\n+\r\n+ # print(f\" Calculated aspect correction factor: {correction_factor:.4f} (from {image_width}x{image_height}, Scaling='{aspect_string}')\")\r\n+ return correction_factor\r\n+\r\n+\r\n+def reconstruct_image_path_with_fallback(asset_dir_path, asset_name, map_type, resolution, primary_format=None):\r\n+ \"\"\"\r\n+ Constructs the expected image file path.\r\n+ If primary_format is provided, tries that first.\r\n+ Then falls back to common extensions if the path doesn't exist or primary_format was None.\r\n+ Returns the found path as a string, or None if not found.\r\n+ \"\"\"\r\n+ if not all([asset_dir_path, asset_name, map_type, resolution]):\r\n+ print(f\" !!! ERROR: Missing data for path reconstruction ({asset_name}/{map_type}/{resolution}).\")\r\n+ return None\r\n+\r\n+ found_path = None\r\n+\r\n+ # 1. Try the primary format if provided\r\n+ if primary_format:\r\n+ try:\r\n+ filename = IMAGE_FILENAME_PATTERN.format(\r\n+ asset_name=asset_name,\r\n+ map_type=map_type,\r\n+ resolution=resolution,\r\n+ format=primary_format.lower() # Ensure format is lowercase\r\n+ )\r\n+ primary_path = asset_dir_path / filename\r\n+ if primary_path.is_file():\r\n+ # print(f\" Found primary path: {str(primary_path)}\") # Verbose\r\n+ return str(primary_path)\r\n+ # else: print(f\" Primary path not found: {str(primary_path)}\") # Verbose\r\n+ except KeyError as e:\r\n+ print(f\" !!! ERROR: Missing key '{e}' in IMAGE_FILENAME_PATTERN. Cannot reconstruct path.\")\r\n+ return None # Cannot proceed without valid pattern\r\n+ except Exception as e:\r\n+ print(f\" !!! ERROR reconstructing primary image path: {e}\")\r\n+ # Continue to fallback\r\n+\r\n+ # 2. Try fallback extensions\r\n+ # print(f\" Trying fallback extensions for {map_type}/{resolution}...\") # Verbose\r\n+ for ext in FALLBACK_IMAGE_EXTENSIONS:\r\n+ # Skip if we already tried this extension as primary (and it failed)\r\n+ if primary_format and ext.lower() == primary_format.lower():\r\n+ continue\r\n+ try:\r\n+ fallback_filename = IMAGE_FILENAME_PATTERN.format(\r\n+ asset_name=asset_name,\r\n+ map_type=map_type,\r\n+ resolution=resolution,\r\n+ format=ext.lower()\r\n+ )\r\n+ fallback_path = asset_dir_path / fallback_filename\r\n+ if fallback_path.is_file():\r\n+ print(f\" Found fallback path: {str(fallback_path)}\")\r\n+ return str(fallback_path) # Found it!\r\n+ except KeyError:\r\n+ # Should not happen if primary format worked, but handle defensively\r\n+ print(f\" !!! ERROR: Missing key in IMAGE_FILENAME_PATTERN during fallback. Cannot reconstruct path.\")\r\n+ return None\r\n+ except Exception as e_fallback:\r\n+ print(f\" !!! ERROR reconstructing fallback image path ({ext}): {e_fallback}\")\r\n+ continue # Try next extension\r\n+\r\n+ # If we get here, neither primary nor fallbacks worked\r\n+ if primary_format:\r\n+ print(f\" !!! ERROR: Could not find image file for {map_type}/{resolution} using primary format '{primary_format}' or fallbacks {FALLBACK_IMAGE_EXTENSIONS}.\")\r\n+ else:\r\n+ print(f\" !!! ERROR: Could not find image file for {map_type}/{resolution} using fallbacks {FALLBACK_IMAGE_EXTENSIONS}.\")\r\n+ return None # Not found after all checks\r\n+\r\n+\r\n+# --- Manifest Functions ---\r\n+\r\n+def get_manifest_path(context):\r\n+ \"\"\"Gets the expected path for the manifest JSON file.\"\"\"\r\n+ if not context or not context.blend_data or not context.blend_data.filepath:\r\n+ return None # Cannot determine path if blend file is not saved\r\n+ blend_path = Path(context.blend_data.filepath)\r\n+ manifest_filename = f\"{blend_path.stem}_manifest.json\"\r\n+ return blend_path.parent / manifest_filename\r\n+\r\n+def load_manifest(context):\r\n+ \"\"\"Loads the manifest data from the JSON file.\"\"\"\r\n+ if not ENABLE_MANIFEST:\r\n+ return {} # Manifest disabled\r\n+\r\n+ manifest_path = get_manifest_path(context)\r\n+ if not manifest_path:\r\n+ print(\" Manifest Info: Blend file not saved. Cannot load manifest.\")\r\n+ return {} # Cannot load without a path\r\n+\r\n+ if not manifest_path.exists():\r\n+ print(f\" Manifest Info: No manifest file found at '{manifest_path.name}'. Starting fresh.\")\r\n+ return {} # No manifest file exists yet\r\n+\r\n+ try:\r\n+ with open(manifest_path, 'r', encoding='utf-8') as f:\r\n+ data = json.load(f)\r\n+ print(f\" Manifest Loaded from: {manifest_path.name}\")\r\n+ # Basic validation (check if it's a dictionary)\r\n+ if not isinstance(data, dict):\r\n+ print(f\"!!! WARNING: Manifest file '{manifest_path.name}' has invalid format (not a dictionary). Starting fresh. !!!\")\r\n+ return {}\r\n+ return data\r\n+ except json.JSONDecodeError:\r\n+ print(f\"!!! WARNING: Manifest file '{manifest_path.name}' is corrupted. Starting fresh. !!!\")\r\n+ return {}\r\n+ except Exception as e:\r\n+ print(f\"!!! ERROR: Could not load manifest file '{manifest_path.name}': {e} !!!\")\r\n+ return {} # Treat as starting fresh on error\r\n+\r\n+def save_manifest(context, manifest_data):\r\n+ \"\"\"Saves the manifest data to the JSON file.\"\"\"\r\n+ if not ENABLE_MANIFEST or not manifest_data: # Don't save if disabled or empty\r\n+ return False\r\n+\r\n+ manifest_path = get_manifest_path(context)\r\n+ if not manifest_path:\r\n+ print(\" Manifest Error: Blend file not saved. Cannot save manifest.\")\r\n+ return False\r\n+\r\n+ try:\r\n+ with open(manifest_path, 'w', encoding='utf-8') as f:\r\n+ json.dump(manifest_data, f, indent=2, sort_keys=True) # Use indent and sort for readability\r\n+ print(f\" Manifest Saved to: {manifest_path.name}\")\r\n+ return True\r\n+ except Exception as e:\r\n+ print(f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\\n\"\r\n+ f\"!!! Manifest save FAILED to '{manifest_path.name}': {e} !!!\\n\"\r\n+ f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\")\r\n+ return False\r\n+\r\n+def is_asset_processed(manifest_data, asset_name):\r\n+ \"\"\"Checks if the entire asset (all its maps/resolutions) is marked as processed.\"\"\"\r\n+ if not ENABLE_MANIFEST: return False\r\n+ # Basic check if asset entry exists. Detailed check happens at map level.\r\n+ return asset_name in manifest_data\r\n+\r\n+def is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n+ \"\"\"Checks if a specific map type and resolution for an asset is processed.\"\"\"\r\n+ if not ENABLE_MANIFEST: return False\r\n+ return resolution in manifest_data.get(asset_name, {}).get(map_type, [])\r\n+\r\n+def update_manifest(manifest_data, asset_name, map_type=None, resolution=None):\r\n+ \"\"\"Updates the manifest dictionary in memory.\"\"\"\r\n+ if not ENABLE_MANIFEST: return False\r\n+\r\n+ # Ensure asset entry exists\r\n+ if asset_name not in manifest_data:\r\n+ manifest_data[asset_name] = {}\r\n+\r\n+ # If map_type and resolution are provided, update the specific map entry\r\n+ if map_type and resolution:\r\n+ if map_type not in manifest_data[asset_name]:\r\n+ manifest_data[asset_name][map_type] = []\r\n+\r\n+ if resolution not in manifest_data[asset_name][map_type]:\r\n+ manifest_data[asset_name][map_type].append(resolution)\r\n+ manifest_data[asset_name][map_type].sort() # Keep sorted\r\n+ return True # Indicate that a change was made\r\n+ return False # No change made to this specific map/res\r\n+\r\n+\r\n+# --- Core Logic ---\r\n+\r\n+def process_library(context, asset_library_root_override=None): # Add override parameter\r\n+ global ENABLE_MANIFEST # Declare intent to modify global if needed\r\n+ global PROCESSED_ASSET_LIBRARY_ROOT # Allow modification of global\r\n+ \"\"\"Scans the library, reads metadata, creates/updates node groups.\"\"\"\r\n+ start_time = time.time()\r\n+ print(f\"\\n--- Starting Node Group Processing ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---\")\r\n+ print(f\" DEBUG: Received asset_library_root_override: {asset_library_root_override}\") # DEBUG LOG (Indented)\r\n+\r\n+ # --- Determine Asset Library Root ---\r\n+ if asset_library_root_override:\r\n+ PROCESSED_ASSET_LIBRARY_ROOT = asset_library_root_override\r\n+ print(f\"Using asset library root from argument: '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n+ elif not PROCESSED_ASSET_LIBRARY_ROOT:\r\n+ print(\"!!! ERROR: Processed asset library root not set in script and not provided via argument.\")\r\n+ print(\"--- Script aborted. ---\")\r\n+ return False\r\n+ print(f\" DEBUG: Using final PROCESSED_ASSET_LIBRARY_ROOT: {PROCESSED_ASSET_LIBRARY_ROOT}\") # DEBUG LOG (Indented)\r\n+\r\n+ # --- Pre-run Checks ---\r\n+ print(\"Performing pre-run checks...\")\r\n+ valid_setup = True\r\n+ # 1. Check Library Root Path\r\n+ root_path = Path(PROCESSED_ASSET_LIBRARY_ROOT)\r\n+ if not root_path.is_dir():\r\n+ print(f\"!!! ERROR: Processed asset library root directory not found or not a directory:\")\r\n+ print(f\"!!! '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n+ valid_setup = False\r\n+ else:\r\n+ print(f\" Asset Library Root: '{root_path}'\")\r\n+ print(f\" DEBUG: Checking for templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\") # DEBUG LOG (Indented)\r\n+\r\n+ # 2. Check Templates\r\n+ template_parent = bpy.data.node_groups.get(PARENT_TEMPLATE_NAME)\r\n+ template_child = bpy.data.node_groups.get(CHILD_TEMPLATE_NAME)\r\n+ if not template_parent:\r\n+ print(f\"!!! ERROR: Parent template node group '{PARENT_TEMPLATE_NAME}' not found in this Blender file.\")\r\n+ valid_setup = False\r\n+ if not template_child:\r\n+ print(f\"!!! ERROR: Child template node group '{CHILD_TEMPLATE_NAME}' not found in this Blender file.\")\r\n+ valid_setup = False\r\n+ if template_parent and template_child:\r\n+ print(f\" Found Templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\")\r\n+ print(f\" DEBUG: Template Parent Found: {template_parent is not None}\") # DEBUG LOG (Indented)\r\n+ print(f\" DEBUG: Template Child Found: {template_child is not None}\") # DEBUG LOG (Indented)\r\n+\r\n+ # 3. Check Blend File Saved (if manifest enabled)\r\n+ if ENABLE_MANIFEST and not context.blend_data.filepath:\r\n+ print(f\"!!! WARNING: Manifest is enabled, but the current Blender file is not saved.\")\r\n+ print(f\"!!! Manifest cannot be loaded or saved. Processing will continue without manifest checks.\")\r\n+ ENABLE_MANIFEST = False # Disable manifest for this run\r\n+\r\n+ if not valid_setup:\r\n+ print(\"\\n--- Script aborted due to configuration errors. Please fix the issues above. ---\")\r\n+ return False\r\n+ print(\"Pre-run checks passed.\")\r\n+ # --- End Pre-run Checks ---\r\n+\r\n+ manifest_data = load_manifest(context)\r\n+ manifest_needs_saving = False\r\n+\r\n+ # --- Initialize Counters ---\r\n+ metadata_files_found = 0\r\n+ assets_processed = 0\r\n+ assets_skipped_manifest = 0\r\n+ parent_groups_created = 0\r\n+ parent_groups_updated = 0\r\n+ child_groups_created = 0\r\n+ child_groups_updated = 0\r\n+ images_loaded = 0\r\n+ images_assigned = 0\r\n+ maps_processed = 0\r\n+ maps_skipped_manifest = 0\r\n+ errors_encountered = 0\r\n+ previews_set = 0\r\n+ highest_res_set = 0\r\n+ aspect_ratio_set = 0\r\n+ # --- End Counters ---\r\n+\r\n+ print(f\"\\nScanning for metadata files in '{root_path}'...\")\r\n+\r\n+ # --- Scan for metadata.json ---\r\n+ # Scan one level deeper for supplier folders (e.g., Poliigon)\r\n+ # Then scan within each supplier for asset folders containing metadata.json\r\n+ metadata_paths = []\r\n+ for supplier_dir in root_path.iterdir():\r\n+ if supplier_dir.is_dir():\r\n+ # Now look for asset folders inside the supplier directory\r\n+ for asset_dir in supplier_dir.iterdir():\r\n+ if asset_dir.is_dir():\r\n+ metadata_file = asset_dir / 'metadata.json'\r\n+ if metadata_file.is_file():\r\n+ metadata_paths.append(metadata_file)\r\n+\r\n+ metadata_files_found = len(metadata_paths)\r\n+ print(f\"Found {metadata_files_found} metadata.json files.\")\r\n+ print(f\" DEBUG: Metadata paths found: {metadata_paths}\") # DEBUG LOG (Indented)\r\n+\r\n+ if metadata_files_found == 0:\r\n+ print(\"No metadata files found. Nothing to process.\")\r\n+ print(\"--- Script Finished ---\")\r\n+ return True # No work needed is considered success\r\n+\r\n+ # --- Process Each Metadata File ---\r\n+ for metadata_path in metadata_paths:\r\n+ asset_dir_path = metadata_path.parent # Get the directory containing the metadata file\r\n+ print(f\"\\n--- Processing Metadata: {metadata_path.relative_to(root_path)} ---\")\r\n+ print(f\" DEBUG: Processing file: {metadata_path}\") # DEBUG LOG (Indented)\r\n+ try:\r\n+ with open(metadata_path, 'r', encoding='utf-8') as f:\r\n+ metadata = json.load(f)\r\n+\r\n+ # --- Extract Key Info ---\r\n+ asset_name = metadata.get(\"asset_name\")\r\n+ supplier_name = metadata.get(\"supplier_name\")\r\n+ archetype = metadata.get(\"archetype\")\r\n+ # Get map info from the correct keys\r\n+ processed_resolutions = metadata.get(\"processed_map_resolutions\", {}) # Default to empty dict\r\n+ merged_resolutions = metadata.get(\"merged_map_resolutions\", {}) # Get merged maps too\r\n+ map_details = metadata.get(\"map_details\", {}) # Default to empty dict\r\n+ image_stats_1k = metadata.get(\"image_stats_1k\") # Dict: {map_type: {stats}}\r\n+ aspect_string = metadata.get(\"aspect_ratio_change_string\")\r\n+\r\n+ # Combine processed and merged maps for iteration\r\n+ all_map_resolutions = {**processed_resolutions, **merged_resolutions}\r\n+\r\n+ # Validate essential data\r\n+ if not asset_name:\r\n+ print(f\" !!! ERROR: Metadata file is missing 'asset_name'. Skipping.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ if not all_map_resolutions:\r\n+ print(f\" !!! ERROR: Metadata file has no 'processed_map_resolutions' or 'merged_map_resolutions'. Skipping asset '{asset_name}'.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ # map_details check remains a warning as merged maps won't be in it\r\n+ print(f\" DEBUG: Valid metadata loaded for asset: {asset_name}\") # DEBUG LOG (Indented)\r\n+\r\n+ print(f\" Asset Name: {asset_name}\")\r\n+\r\n+ # --- Determine Highest Resolution ---\r\n+ highest_resolution_value = 0.0\r\n+ highest_resolution_str = \"Unknown\"\r\n+ all_resolutions_present = set()\r\n+ if all_map_resolutions: # Check combined dict\r\n+ for res_list in all_map_resolutions.values():\r\n+ if isinstance(res_list, list):\r\n+ all_resolutions_present.update(res_list)\r\n+\r\n+ if all_resolutions_present:\r\n+ for res_str in RESOLUTION_ORDER_DESC:\r\n+ if res_str in all_resolutions_present:\r\n+ highest_resolution_value = RESOLUTION_VALUE_MAP.get(res_str, 0.0)\r\n+ highest_resolution_str = res_str\r\n+ if highest_resolution_value > 0.0:\r\n+ break # Found the highest valid resolution\r\n+\r\n+ print(f\" Highest resolution found: {highest_resolution_str} (Value: {highest_resolution_value})\")\r\n+\r\n+ # --- Load Reference Image for Aspect Ratio ---\r\n+ ref_image_path = None\r\n+ ref_image_width = 0\r\n+ ref_image_height = 0\r\n+ ref_image_loaded = False\r\n+ # Use combined resolutions dict to find reference map\r\n+ for ref_map_type in REFERENCE_MAP_TYPES:\r\n+ if ref_map_type in all_map_resolutions:\r\n+ available_resolutions = all_map_resolutions[ref_map_type]\r\n+ lowest_res = None\r\n+ for res_pref in REFERENCE_RESOLUTION_ORDER:\r\n+ if res_pref in available_resolutions:\r\n+ lowest_res = res_pref\r\n+ break\r\n+ if lowest_res:\r\n+ # Get format from map_details if available, otherwise None\r\n+ ref_map_details = map_details.get(ref_map_type, {})\r\n+ ref_format = ref_map_details.get(\"output_format\")\r\n+ ref_image_path = reconstruct_image_path_with_fallback(\r\n+ asset_dir_path=asset_dir_path,\r\n+ asset_name=asset_name,\r\n+ map_type=ref_map_type,\r\n+ resolution=lowest_res,\r\n+ primary_format=ref_format # Pass None if not in map_details\r\n+ )\r\n+ if ref_image_path:\r\n+ break # Found a suitable reference image path\r\n+\r\n+ if ref_image_path:\r\n+ print(f\" Loading reference image for aspect ratio: {Path(ref_image_path).name}\")\r\n+ try:\r\n+ # Load image temporarily\r\n+ ref_img = bpy.data.images.load(ref_image_path, check_existing=True)\r\n+ if ref_img:\r\n+ ref_image_width = ref_img.size[0]\r\n+ ref_image_height = ref_img.size[1]\r\n+ ref_image_loaded = True\r\n+ print(f\" Reference image dimensions: {ref_image_width}x{ref_image_height}\")\r\n+ # Remove the temporary image datablock to save memory\r\n+ bpy.data.images.remove(ref_img)\r\n+ else:\r\n+ print(f\" !!! ERROR: Failed loading reference image via bpy.data.images.load: {ref_image_path}\")\r\n+ except Exception as e_ref_load:\r\n+ print(f\" !!! ERROR loading reference image '{ref_image_path}': {e_ref_load}\")\r\n+ else:\r\n+ print(f\" !!! WARNING: Could not find suitable reference image ({REFERENCE_MAP_TYPES} at {REFERENCE_RESOLUTION_ORDER}) for aspect ratio calculation.\")\r\n+\r\n+\r\n+ # --- Manifest Check (Asset Level - Basic) ---\r\n+ if ENABLE_MANIFEST and is_asset_processed(manifest_data, asset_name):\r\n+ # Perform a quick check if *any* map needs processing for this asset\r\n+ needs_processing = False\r\n+ for map_type, resolutions in all_map_resolutions.items(): # Check combined maps\r\n+ for resolution in resolutions:\r\n+ if not is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n+ needs_processing = True\r\n+ break\r\n+ if needs_processing:\r\n+ break\r\n+ if not needs_processing:\r\n+ print(f\" Skipping asset '{asset_name}' (already fully processed according to manifest).\")\r\n+ assets_skipped_manifest += 1\r\n+ continue # Skip to next metadata file\r\n+\r\n+ # --- Parent Group Handling ---\r\n+ target_parent_name = f\"PBRSET_{asset_name}\"\r\n+ parent_group = bpy.data.node_groups.get(target_parent_name)\r\n+ is_new_parent = False\r\n+\r\n+ if parent_group is None:\r\n+ print(f\" Creating new parent group: '{target_parent_name}'\")\r\n+ print(f\" DEBUG: Copying parent template '{PARENT_TEMPLATE_NAME}'\") # DEBUG LOG (Indented)\r\n+ parent_group = template_parent.copy()\r\n+ if not parent_group:\r\n+ print(f\" !!! ERROR: Failed to copy parent template '{PARENT_TEMPLATE_NAME}'. Skipping asset '{asset_name}'.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ parent_group.name = target_parent_name\r\n+ parent_groups_created += 1\r\n+ is_new_parent = True\r\n+ else:\r\n+ print(f\" Updating existing parent group: '{target_parent_name}'\")\r\n+ print(f\" DEBUG: Found existing parent group.\") # DEBUG LOG (Indented)\r\n+ parent_groups_updated += 1\r\n+\r\n+ # Ensure marked as asset\r\n+ if not parent_group.asset_data:\r\n+ try:\r\n+ parent_group.asset_mark()\r\n+ print(f\" Marked '{parent_group.name}' as asset.\")\r\n+ except Exception as e_mark:\r\n+ print(f\" !!! ERROR: Failed to mark group '{parent_group.name}' as asset: {e_mark}\")\r\n+ # Continue processing other parts if possible\r\n+\r\n+ # Apply Asset Tags\r\n+ if parent_group.asset_data:\r\n+ if supplier_name:\r\n+ add_tag_if_new(parent_group.asset_data, supplier_name)\r\n+ if archetype:\r\n+ add_tag_if_new(parent_group.asset_data, archetype)\r\n+ # Add other tags if needed\r\n+ # else: print(f\" Warn: Cannot apply tags, asset_data not available for '{parent_group.name}'.\") # Optional warning\r\n+\r\n+ # Apply Aspect Ratio Correction\r\n+ aspect_nodes = find_nodes_by_label(parent_group, ASPECT_RATIO_NODE_LABEL, 'ShaderNodeValue')\r\n+ if aspect_nodes:\r\n+ aspect_node = aspect_nodes[0]\r\n+ correction_factor = 1.0 # Default if ref image fails\r\n+ if ref_image_loaded:\r\n+ correction_factor = calculate_aspect_correction_factor(ref_image_width, ref_image_height, aspect_string)\r\n+ print(f\" Calculated aspect correction factor: {correction_factor:.4f}\")\r\n+ else:\r\n+ print(f\" !!! WARNING: Using default aspect ratio correction (1.0) due to missing reference image.\")\r\n+\r\n+ # Check if update is needed\r\n+ current_val = aspect_node.outputs[0].default_value\r\n+ if abs(current_val - correction_factor) > 0.0001:\r\n+ aspect_node.outputs[0].default_value = correction_factor\r\n+ print(f\" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f} (was {current_val:.4f})\")\r\n+ aspect_ratio_set += 1\r\n+ # else: print(f\" Warn: Aspect ratio node '{ASPECT_RATIO_NODE_LABEL}' not found in parent group.\") # Optional\r\n+\r\n+ # Apply Highest Resolution Value\r\n+ hr_nodes = find_nodes_by_label(parent_group, HIGHEST_RESOLUTION_NODE_LABEL, 'ShaderNodeValue')\r\n+ if hr_nodes:\r\n+ hr_node = hr_nodes[0]\r\n+ current_hr_val = hr_node.outputs[0].default_value\r\n+ if highest_resolution_value > 0.0 and abs(current_hr_val - highest_resolution_value) > 0.001:\r\n+ hr_node.outputs[0].default_value = highest_resolution_value\r\n+ print(f\" Set '{HIGHEST_RESOLUTION_NODE_LABEL}' value to {highest_resolution_value} ({highest_resolution_str}) (was {current_hr_val:.1f})\")\r\n+ highest_res_set += 1 # Count successful sets\r\n+ # else: print(f\" Warn: Highest resolution node '{HIGHEST_RESOLUTION_NODE_LABEL}' not found in parent group.\") # Optional\r\n+\r\n+\r\n+ # Apply Stats (using image_stats_1k)\r\n+ if image_stats_1k and isinstance(image_stats_1k, dict):\r\n+ for map_type_to_stat in APPLY_STATS_FOR_MAP_TYPES:\r\n+ if map_type_to_stat in image_stats_1k:\r\n+ # Find the stats node in the parent group\r\n+ stats_node_label = f\"{STATS_NODE_PREFIX}{map_type_to_stat}\"\r\n+ stats_nodes = find_nodes_by_label(parent_group, stats_node_label, 'ShaderNodeCombineXYZ')\r\n+ if stats_nodes:\r\n+ stats_node = stats_nodes[0]\r\n+ stats = image_stats_1k[map_type_to_stat] # Get stats dict for this map type\r\n+\r\n+ if stats and isinstance(stats, dict):\r\n+ # Handle potential list format for RGB stats (use first value) or direct float\r\n+ def get_stat_value(stat_val):\r\n+ if isinstance(stat_val, list):\r\n+ return stat_val[0] if stat_val else None\r\n+ return stat_val\r\n+\r\n+ min_val = get_stat_value(stats.get(\"min\"))\r\n+ max_val = get_stat_value(stats.get(\"max\"))\r\n+ mean_val = get_stat_value(stats.get(\"mean\")) # Often stored as 'mean' or 'avg'\r\n+\r\n+ updated_stat = False\r\n+ # Check inputs exist before assigning\r\n+ input_x = stats_node.inputs.get(\"X\")\r\n+ input_y = stats_node.inputs.get(\"Y\")\r\n+ input_z = stats_node.inputs.get(\"Z\")\r\n+\r\n+ if input_x and min_val is not None and abs(input_x.default_value - min_val) > 0.0001:\r\n+ input_x.default_value = min_val\r\n+ updated_stat = True\r\n+ if input_y and max_val is not None and abs(input_y.default_value - max_val) > 0.0001:\r\n+ input_y.default_value = max_val\r\n+ updated_stat = True\r\n+ if input_z and mean_val is not None and abs(input_z.default_value - mean_val) > 0.0001:\r\n+ input_z.default_value = mean_val\r\n+ updated_stat = True\r\n+\r\n+ if updated_stat:\r\n+ print(f\" Set stats in '{stats_node_label}': Min={min_val:.4f}, Max={max_val:.4f}, Mean={mean_val:.4f}\")\r\n+ # else: print(f\" Info: No valid 'stats' dictionary found for map type '{map_type_to_stat}' in image_stats_1k.\") # Optional\r\n+ # else: print(f\" Warn: Stats node '{stats_node_label}' not found in parent group.\") # Optional\r\n+ # else: print(f\" Info: Map type '{map_type_to_stat}' not present in image_stats_1k for stats application.\") # Optional\r\n+ # else: print(f\" Warn: 'image_stats_1k' missing or invalid in metadata.\") # Optional\r\n+\r\n+ # --- Set Asset Preview (only for new parent groups) ---\r\n+ # Use the reference image path found earlier if available\r\n+ if is_new_parent and parent_group.asset_data:\r\n+ if ref_image_loaded and ref_image_path: # Check if ref image was successfully loaded earlier\r\n+ print(f\" Attempting to set preview from reference image: {Path(ref_image_path).name}\")\r\n+ try:\r\n+ # Ensure the ID (node group) is the active one for the operator context\r\n+ with context.temp_override(id=parent_group):\r\n+ bpy.ops.ed.lib_id_load_custom_preview(filepath=ref_image_path)\r\n+ print(f\" Successfully set custom preview.\")\r\n+ previews_set += 1\r\n+ except Exception as e_preview:\r\n+ print(f\" !!! ERROR setting custom preview: {e_preview}\")\r\n+ errors_encountered += 1\r\n+ else:\r\n+ print(f\" Info: Could not set preview for '{asset_name}' as reference image was not found or loaded.\")\r\n+\r\n+\r\n+ # --- Child Group Handling ---\r\n+ # Iterate through the COMBINED map types\r\n+ print(f\" DEBUG: Starting child group loop for asset '{asset_name}'. Map types: {list(all_map_resolutions.keys())}\") # DEBUG LOG (Indented)\r\n+ for map_type, resolutions in all_map_resolutions.items():\r\n+ print(f\" Processing Map Type: {map_type}\")\r\n+\r\n+ # Determine if this is a merged map (not in map_details)\r\n+ is_merged_map = map_type not in map_details\r\n+\r\n+ # Get details for this map type if available\r\n+ current_map_details = map_details.get(map_type, {})\r\n+ # For merged maps, primary_format will be None\r\n+ output_format = current_map_details.get(\"output_format\")\r\n+\r\n+ if not output_format and not is_merged_map:\r\n+ # This case should ideally not happen if metadata is well-formed\r\n+ # but handle defensively for processed maps.\r\n+ print(f\" !!! WARNING: Missing 'output_format' in map_details for processed map '{map_type}'. Path reconstruction might fail.\")\r\n+ # We will rely solely on fallback for this map type\r\n+\r\n+ # Find placeholder node in parent\r\n+ holder_nodes = find_nodes_by_label(parent_group, map_type, 'ShaderNodeGroup')\r\n+ if not holder_nodes:\r\n+ print(f\" !!! WARNING: No placeholder node labeled '{map_type}' found in parent group '{parent_group.name}'. Skipping this map type.\")\r\n+ continue\r\n+ holder_node = holder_nodes[0] # Assume first is correct\r\n+ print(f\" DEBUG: Found placeholder node '{holder_node.label}' for map type '{map_type}'.\") # DEBUG LOG (Indented)\r\n+\r\n+ # Determine child group name (LOGICAL and ENCODED)\r\n+ logical_child_name = f\"{asset_name}_{map_type}\"\r\n+ target_child_name_b64 = encode_name_b64(logical_child_name) # Use Base64 name\r\n+\r\n+ child_group = bpy.data.node_groups.get(target_child_name_b64) # Find using encoded name\r\n+ is_new_child = False\r\n+\r\n+ if child_group is None:\r\n+ print(f\" DEBUG: Child group '{target_child_name_b64}' not found. Creating new one.\") # DEBUG LOG (Indented)\r\n+ # print(f\" Creating new child group: '{target_child_name_b64}' (logical: '{logical_child_name}')\") # Verbose\r\n+ child_group = template_child.copy()\r\n+ if not child_group:\r\n+ print(f\" !!! ERROR: Failed to copy child template '{CHILD_TEMPLATE_NAME}'. Skipping map type '{map_type}'.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ child_group.name = target_child_name_b64 # Set encoded name\r\n+ child_groups_created += 1\r\n+ is_new_child = True\r\n+ else:\r\n+ print(f\" DEBUG: Found existing child group '{target_child_name_b64}'.\") # DEBUG LOG (Indented)\r\n+ # print(f\" Updating existing child group: '{target_child_name_b64}'\") # Verbose\r\n+ child_groups_updated += 1\r\n+\r\n+ # Assign child group to placeholder if needed\r\n+ if holder_node.node_tree != child_group:\r\n+ try:\r\n+ holder_node.node_tree = child_group\r\n+ print(f\" Assigned child group '{child_group.name}' to placeholder '{holder_node.label}'.\")\r\n+ except TypeError as e_assign: # Catch potential type errors if placeholder isn't a group node\r\n+ print(f\" !!! ERROR: Could not assign child group to placeholder '{holder_node.label}'. Is it a Group Node? Error: {e_assign}\")\r\n+ continue # Skip this map type if assignment fails\r\n+\r\n+ # Link placeholder output to parent output socket\r\n+ try:\r\n+ # Find parent's output node\r\n+ group_output_node = next((n for n in parent_group.nodes if n.type == 'GROUP_OUTPUT'), None)\r\n+ if group_output_node:\r\n+ # Get the specific output socket on the placeholder (usually index 0 or named 'Color'/'Value')\r\n+ source_socket = holder_node.outputs.get(\"Color\") or holder_node.outputs.get(\"Value\") or holder_node.outputs[0]\r\n+ # Get the specific input socket on the parent output node (matching map_type)\r\n+ target_socket = group_output_node.inputs.get(map_type)\r\n+\r\n+ if source_socket and target_socket:\r\n+ # Check if link already exists\r\n+ link_exists = any(link.from_socket == source_socket and link.to_socket == target_socket for link in parent_group.links)\r\n+ if not link_exists:\r\n+ parent_group.links.new(source_socket, target_socket)\r\n+ print(f\" Linked '{holder_node.label}' output to parent output socket '{map_type}'.\")\r\n+ # else: # Optional warnings\r\n+ # if not source_socket: print(f\" Warn: Could not find suitable output socket on placeholder '{holder_node.label}'.\")\r\n+ # if not target_socket: print(f\" Warn: Could not find input socket '{map_type}' on parent output node.\")\r\n+ # else: print(f\" Warn: Parent group '{parent_group.name}' has no Group Output node.\")\r\n+\r\n+ except Exception as e_link:\r\n+ print(f\" !!! ERROR linking sockets for '{map_type}': {e_link}\")\r\n+\r\n+ # Ensure parent output socket type is Color (if it exists)\r\n+ try:\r\n+ # Use the interface API for modern Blender versions\r\n+ item = parent_group.interface.items_tree.get(map_type)\r\n+ if item and item.item_type == 'SOCKET' and item.in_out == 'OUTPUT':\r\n+ # Common useful types: 'NodeSocketColor', 'NodeSocketVector', 'NodeSocketFloat'\r\n+ # Defaulting to Color seems reasonable for most PBR outputs\r\n+ if item.socket_type != 'NodeSocketColor':\r\n+ item.socket_type = 'NodeSocketColor'\r\n+ # print(f\" Set parent output socket '{map_type}' type to Color.\") # Optional info\r\n+ except Exception as e_sock_type:\r\n+ print(f\" Warn: Could not verify/set socket type for '{map_type}': {e_sock_type}\")\r\n+\r\n+\r\n+ # --- Image Node Handling (Inside Child Group) ---\r\n+ if not isinstance(resolutions, list):\r\n+ print(f\" !!! ERROR: Invalid format for resolutions list for map type '{map_type}'. Skipping.\")\r\n+ continue\r\n+\r\n+ for resolution in resolutions:\r\n+ # --- Manifest Check (Map/Resolution Level) ---\r\n+ if ENABLE_MANIFEST and is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n+ # print(f\" Skipping {resolution} (Manifest)\") # Verbose\r\n+ maps_skipped_manifest += 1\r\n+ continue\r\n+ print(f\" DEBUG: Processing map '{map_type}' resolution '{resolution}'. Manifest skip check passed.\") # DEBUG LOG (Indented)\r\n+\r\n+ print(f\" Processing Resolution: {resolution}\")\r\n+\r\n+ # Reconstruct the image path using fallback logic\r\n+ # Pass output_format (which might be None for merged maps)\r\n+ image_path_str = reconstruct_image_path_with_fallback(\r\n+ asset_dir_path=asset_dir_path,\r\n+ asset_name=asset_name,\r\n+ map_type=map_type,\r\n+ resolution=resolution,\r\n+ primary_format=output_format\r\n+ )\r\n+ print(f\" DEBUG: Reconstructed image path for {map_type}/{resolution}: {image_path_str}\") # DEBUG LOG (Indented)\r\n+\r\n+ if not image_path_str:\r\n+ # Error already printed by reconstruct function\r\n+ errors_encountered += 1\r\n+ continue # Skip this resolution if path not found\r\n+\r\n+ # Find image texture node within the CHILD group (labeled by resolution, e.g., \"4K\")\r\n+ image_nodes = find_nodes_by_label(child_group, resolution, 'ShaderNodeTexImage')\r\n+ if not image_nodes:\r\n+ print(f\" !!! WARNING: No Image Texture node labeled '{resolution}' found in child group '{child_group.name}'. Cannot assign image.\")\r\n+ continue # Skip this resolution if node not found\r\n+ print(f\" DEBUG: Found {len(image_nodes)} image node(s) labeled '{resolution}' in child group '{child_group.name}'.\") # DEBUG LOG (Indented)\r\n+\r\n+ # --- Load Image ---\r\n+ img = None\r\n+ image_load_failed = False\r\n+ try:\r\n+ image_path = Path(image_path_str) # Path object created from already found path string\r\n+ # Use check_existing=True to reuse existing datablocks if path matches\r\n+ img = bpy.data.images.load(str(image_path), check_existing=True)\r\n+ if not img:\r\n+ print(f\" !!! ERROR: Failed loading image via bpy.data.images.load: {image_path_str}\")\r\n+ image_load_failed = True\r\n+ else:\r\n+ # Only count as loaded if bpy.data.images.load succeeded\r\n+ # Check if it's newly loaded or reused\r\n+ is_newly_loaded = img.library is None # Newly loaded images don't have a library initially\r\n+ if is_newly_loaded: images_loaded += 1\r\n+\r\n+ except RuntimeError as e_runtime_load:\r\n+ # Catch specific Blender runtime errors (e.g., unsupported format)\r\n+ print(f\" !!! ERROR loading image '{image_path_str}': {e_runtime_load}\")\r\n+ image_load_failed = True\r\n+ except Exception as e_gen_load:\r\n+ print(f\" !!! UNEXPECTED ERROR loading image '{image_path_str}': {e_gen_load}\")\r\n+ image_load_failed = True\r\n+ errors_encountered += 1\r\n+\r\n+ # --- Assign Image & Set Color Space ---\r\n+ if not image_load_failed and img:\r\n+ assigned_count_this_res = 0\r\n+ for image_node in image_nodes:\r\n+ if image_node.image != img:\r\n+ image_node.image = img\r\n+ assigned_count_this_res += 1\r\n+\r\n+ if assigned_count_this_res > 0:\r\n+ images_assigned += assigned_count_this_res\r\n+ print(f\" Assigned image '{img.name}' to {assigned_count_this_res} node(s).\")\r\n+\r\n+ # Set Color Space\r\n+ correct_color_space = get_color_space(map_type)\r\n+ try:\r\n+ if img.colorspace_settings.name != correct_color_space:\r\n+ img.colorspace_settings.name = correct_color_space\r\n+ print(f\" Set '{img.name}' color space -> {correct_color_space}\")\r\n+ except TypeError as e_cs: # Handle case where colorspace name is invalid\r\n+ print(f\" !!! WARNING: Could not set color space '{correct_color_space}' for image '{img.name}'. Is the color space available in Blender? Error: {e_cs}\")\r\n+ except Exception as e_cs_gen:\r\n+ print(f\" !!! ERROR setting color space for image '{img.name}': {e_cs_gen}\")\r\n+\r\n+\r\n+ # --- Update Manifest (Map/Resolution Level) ---\r\n+ if update_manifest(manifest_data, asset_name, map_type, resolution):\r\n+ manifest_needs_saving = True\r\n+ # print(f\" Marked {map_type}/{resolution} processed in manifest.\") # Verbose\r\n+ maps_processed += 1\r\n+\r\n+ else:\r\n+ # Increment error count if loading failed\r\n+ if image_load_failed: errors_encountered += 1\r\n+\r\n+ # --- End Resolution Loop ---\r\n+ # --- End Map Type Loop ---\r\n+\r\n+ assets_processed += 1\r\n+\r\n+ except FileNotFoundError:\r\n+ print(f\" !!! ERROR: Metadata file not found (should not happen if scan worked): {metadata_path}\")\r\n+ errors_encountered += 1\r\n+ except json.JSONDecodeError:\r\n+ print(f\" !!! ERROR: Invalid JSON in metadata file: {metadata_path}\")\r\n+ errors_encountered += 1\r\n+ except Exception as e_main_loop:\r\n+ print(f\" !!! UNEXPECTED ERROR processing asset from {metadata_path}: {e_main_loop}\")\r\n+ import traceback\r\n+ traceback.print_exc() # Print detailed traceback for debugging\r\n+ errors_encountered += 1\r\n+ # Continue to the next asset\r\n+\r\n+ # --- End Metadata File Loop ---\r\n+\r\n+ # --- Final Manifest Save ---\r\n+ if ENABLE_MANIFEST and manifest_needs_saving:\r\n+ print(\"\\nAttempting final manifest save...\")\r\n+ save_manifest(context, manifest_data)\r\n+ elif ENABLE_MANIFEST:\r\n+ print(\"\\nManifest is enabled, but no changes require saving.\")\r\n+ # --- End Final Manifest Save ---\r\n+\r\n+ # --- Final Summary ---\r\n+ end_time = time.time()\r\n+ duration = end_time - start_time\r\n+ print(\"\\n--- Script Run Finished ---\")\r\n+ print(f\"Duration: {duration:.2f} seconds\")\r\n+ print(f\"Metadata Files Found: {metadata_files_found}\")\r\n+ print(f\"Assets Processed/Attempted: {assets_processed}\")\r\n+ if ENABLE_MANIFEST:\r\n+ print(f\"Assets Skipped (Manifest): {assets_skipped_manifest}\")\r\n+ print(f\"Maps Skipped (Manifest): {maps_skipped_manifest}\")\r\n+ print(f\"Parent Groups Created: {parent_groups_created}\")\r\n+ print(f\"Parent Groups Updated: {parent_groups_updated}\")\r\n+ print(f\"Child Groups Created: {child_groups_created}\")\r\n+ print(f\"Child Groups Updated: {child_groups_updated}\")\r\n+ print(f\"Images Loaded: {images_loaded}\")\r\n+ print(f\"Image Nodes Assigned: {images_assigned}\")\r\n+ print(f\"Individual Maps Processed: {maps_processed}\")\r\n+ print(f\"Asset Previews Set: {previews_set}\")\r\n+ print(f\"Highest Resolution Nodes Set: {highest_res_set}\")\r\n+ print(f\"Aspect Ratio Nodes Set: {aspect_ratio_set}\") # Added counter\r\n+ if errors_encountered > 0:\r\n+ print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n+ print(\"---------------------------\")\r\n+\r\n+ # --- Explicit Save ---\r\n+ print(f\" DEBUG: Attempting explicit save for file: {bpy.data.filepath}\") # DEBUG LOG (Indented)\r\n+ try:\r\n+ bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)\r\n+ print(\"\\n--- Explicitly saved the .blend file. ---\")\r\n+ except Exception as e_save:\r\n+ print(f\"\\n!!! ERROR explicitly saving .blend file: {e_save} !!!\")\r\n+ errors_encountered += 1 # Count save errors\r\n+\r\n+ return True\r\n+\r\n+\r\n+# --- Execution Block ---\r\n+\r\n+if __name__ == \"__main__\":\r\n+ # Ensure we are running within Blender\r\n+ try:\r\n+ import bpy\r\n+ import base64 # Ensure base64 is imported here too if needed globally\r\n+ import sys\r\n+ except ImportError:\r\n+ print(\"!!! ERROR: This script must be run from within Blender. !!!\")\r\n+ else:\r\n+ # --- Argument Parsing for Asset Library Root ---\r\n+ asset_root_arg = None\r\n+ try:\r\n+ # Blender arguments passed after '--' appear in sys.argv\r\n+ if \"--\" in sys.argv:\r\n+ args_after_dash = sys.argv[sys.argv.index(\"--\") + 1:]\r\n+ if len(args_after_dash) >= 1:\r\n+ asset_root_arg = args_after_dash[0]\r\n+ print(f\"Found asset library root argument: {asset_root_arg}\")\r\n+ else:\r\n+ print(\"Info: '--' found but no arguments after it.\")\r\n+ # else: print(\"Info: No '--' found in arguments.\") # Optional debug\r\n+ except Exception as e:\r\n+ print(f\"Error parsing command line arguments: {e}\")\r\n+ # --- End Argument Parsing ---\r\n+\r\n+ process_library(bpy.context, asset_library_root_override=asset_root_arg)\r\n" }, { "date": 1745309402905, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -489,8 +489,9 @@\n # --- Extract Key Info ---\r\n asset_name = metadata.get(\"asset_name\")\r\n supplier_name = metadata.get(\"supplier_name\")\r\n archetype = metadata.get(\"archetype\")\r\n+ category = metadata.get(\"category\", \"Unknown\") # <<< ADDED: Extract category, default to \"Unknown\"\r\n # Get map info from the correct keys\r\n processed_resolutions = metadata.get(\"processed_map_resolutions\", {}) # Default to empty dict\r\n merged_resolutions = metadata.get(\"merged_map_resolutions\", {}) # Get merged maps too\r\n map_details = metadata.get(\"map_details\", {}) # Default to empty dict\r\n@@ -632,1030 +633,19 @@\n if supplier_name:\r\n add_tag_if_new(parent_group.asset_data, supplier_name)\r\n if archetype:\r\n add_tag_if_new(parent_group.asset_data, archetype)\r\n+ if category: # <<< ADDED: Add category tag\r\n+ add_tag_if_new(parent_group.asset_data, category)\r\n # Add other tags if needed\r\n # else: print(f\" Warn: Cannot apply tags, asset_data not available for '{parent_group.name}'.\") # Optional warning\r\n \r\n- # Apply Aspect Ratio Correction\r\n- aspect_nodes = find_nodes_by_label(parent_group, ASPECT_RATIO_NODE_LABEL, 'ShaderNodeValue')\r\n- if aspect_nodes:\r\n- aspect_node = aspect_nodes[0]\r\n- correction_factor = 1.0 # Default if ref image fails\r\n- if ref_image_loaded:\r\n- correction_factor = calculate_aspect_correction_factor(ref_image_width, ref_image_height, aspect_string)\r\n- print(f\" Calculated aspect correction factor: {correction_factor:.4f}\")\r\n- else:\r\n- print(f\" !!! WARNING: Using default aspect ratio correction (1.0) due to missing reference image.\")\r\n+ # <<< ADDED: Conditional skip based on category >>>\r\n+ if category not in [\"Surface\", \"Decal\"]:\r\n+ print(f\" Skipping nodegroup content generation for asset '{asset_name}' (Category: '{category}'). Tag added.\")\r\n+ assets_processed += 1 # Still count as processed for summary, even if skipped\r\n+ continue # Skip the rest of the processing for this asset\r\n \r\n- # Check if update is needed\r\n- current_val = aspect_node.outputs[0].default_value\r\n- if abs(current_val - correction_factor) > 0.0001:\r\n- aspect_node.outputs[0].default_value = correction_factor\r\n- print(f\" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f} (was {current_val:.4f})\")\r\n- aspect_ratio_set += 1\r\n- # else: print(f\" Warn: Aspect ratio node '{ASPECT_RATIO_NODE_LABEL}' not found in parent group.\") # Optional\r\n-\r\n- # Apply Highest Resolution Value\r\n- hr_nodes = find_nodes_by_label(parent_group, HIGHEST_RESOLUTION_NODE_LABEL, 'ShaderNodeValue')\r\n- if hr_nodes:\r\n- hr_node = hr_nodes[0]\r\n- current_hr_val = hr_node.outputs[0].default_value\r\n- if highest_resolution_value > 0.0 and abs(current_hr_val - highest_resolution_value) > 0.001:\r\n- hr_node.outputs[0].default_value = highest_resolution_value\r\n- print(f\" Set '{HIGHEST_RESOLUTION_NODE_LABEL}' value to {highest_resolution_value} ({highest_resolution_str}) (was {current_hr_val:.1f})\")\r\n- highest_res_set += 1 # Count successful sets\r\n- # else: print(f\" Warn: Highest resolution node '{HIGHEST_RESOLUTION_NODE_LABEL}' not found in parent group.\") # Optional\r\n-\r\n-\r\n- # Apply Stats (using image_stats_1k)\r\n- if image_stats_1k and isinstance(image_stats_1k, dict):\r\n- for map_type_to_stat in APPLY_STATS_FOR_MAP_TYPES:\r\n- if map_type_to_stat in image_stats_1k:\r\n- # Find the stats node in the parent group\r\n- stats_node_label = f\"{STATS_NODE_PREFIX}{map_type_to_stat}\"\r\n- stats_nodes = find_nodes_by_label(parent_group, stats_node_label, 'ShaderNodeCombineXYZ')\r\n- if stats_nodes:\r\n- stats_node = stats_nodes[0]\r\n- stats = image_stats_1k[map_type_to_stat] # Get stats dict for this map type\r\n-\r\n- if stats and isinstance(stats, dict):\r\n- # Handle potential list format for RGB stats (use first value) or direct float\r\n- def get_stat_value(stat_val):\r\n- if isinstance(stat_val, list):\r\n- return stat_val[0] if stat_val else None\r\n- return stat_val\r\n-\r\n- min_val = get_stat_value(stats.get(\"min\"))\r\n- max_val = get_stat_value(stats.get(\"max\"))\r\n- mean_val = get_stat_value(stats.get(\"mean\")) # Often stored as 'mean' or 'avg'\r\n-\r\n- updated_stat = False\r\n- # Check inputs exist before assigning\r\n- input_x = stats_node.inputs.get(\"X\")\r\n- input_y = stats_node.inputs.get(\"Y\")\r\n- input_z = stats_node.inputs.get(\"Z\")\r\n-\r\n- if input_x and min_val is not None and abs(input_x.default_value - min_val) > 0.0001:\r\n- input_x.default_value = min_val\r\n- updated_stat = True\r\n- if input_y and max_val is not None and abs(input_y.default_value - max_val) > 0.0001:\r\n- input_y.default_value = max_val\r\n- updated_stat = True\r\n- if input_z and mean_val is not None and abs(input_z.default_value - mean_val) > 0.0001:\r\n- input_z.default_value = mean_val\r\n- updated_stat = True\r\n-\r\n- if updated_stat:\r\n- print(f\" Set stats in '{stats_node_label}': Min={min_val:.4f}, Max={max_val:.4f}, Mean={mean_val:.4f}\")\r\n- # else: print(f\" Info: No valid 'stats' dictionary found for map type '{map_type_to_stat}' in image_stats_1k.\") # Optional\r\n- # else: print(f\" Warn: Stats node '{stats_node_label}' not found in parent group.\") # Optional\r\n- # else: print(f\" Info: Map type '{map_type_to_stat}' not present in image_stats_1k for stats application.\") # Optional\r\n- # else: print(f\" Warn: 'image_stats_1k' missing or invalid in metadata.\") # Optional\r\n-\r\n- # --- Set Asset Preview (only for new parent groups) ---\r\n- # Use the reference image path found earlier if available\r\n- if is_new_parent and parent_group.asset_data:\r\n- if ref_image_loaded and ref_image_path: # Check if ref image was successfully loaded earlier\r\n- print(f\" Attempting to set preview from reference image: {Path(ref_image_path).name}\")\r\n- try:\r\n- # Ensure the ID (node group) is the active one for the operator context\r\n- with context.temp_override(id=parent_group):\r\n- bpy.ops.ed.lib_id_load_custom_preview(filepath=ref_image_path)\r\n- print(f\" Successfully set custom preview.\")\r\n- previews_set += 1\r\n- except Exception as e_preview:\r\n- print(f\" !!! ERROR setting custom preview: {e_preview}\")\r\n- errors_encountered += 1\r\n- else:\r\n- print(f\" Info: Could not set preview for '{asset_name}' as reference image was not found or loaded.\")\r\n-\r\n-\r\n- # --- Child Group Handling ---\r\n- # Iterate through the COMBINED map types\r\n- print(f\" DEBUG: Starting child group loop for asset '{asset_name}'. Map types: {list(all_map_resolutions.keys())}\") # DEBUG LOG (Indented)\r\n- for map_type, resolutions in all_map_resolutions.items():\r\n- print(f\" Processing Map Type: {map_type}\")\r\n-\r\n- # Determine if this is a merged map (not in map_details)\r\n- is_merged_map = map_type not in map_details\r\n-\r\n- # Get details for this map type if available\r\n- current_map_details = map_details.get(map_type, {})\r\n- # For merged maps, primary_format will be None\r\n- output_format = current_map_details.get(\"output_format\")\r\n-\r\n- if not output_format and not is_merged_map:\r\n- # This case should ideally not happen if metadata is well-formed\r\n- # but handle defensively for processed maps.\r\n- print(f\" !!! WARNING: Missing 'output_format' in map_details for processed map '{map_type}'. Path reconstruction might fail.\")\r\n- # We will rely solely on fallback for this map type\r\n-\r\n- # Find placeholder node in parent\r\n- holder_nodes = find_nodes_by_label(parent_group, map_type, 'ShaderNodeGroup')\r\n- if not holder_nodes:\r\n- print(f\" !!! WARNING: No placeholder node labeled '{map_type}' found in parent group '{parent_group.name}'. Skipping this map type.\")\r\n- continue\r\n- holder_node = holder_nodes[0] # Assume first is correct\r\n- print(f\" DEBUG: Found placeholder node '{holder_node.label}' for map type '{map_type}'.\") # DEBUG LOG (Indented)\r\n-\r\n- # Determine child group name (LOGICAL and ENCODED)\r\n- logical_child_name = f\"{asset_name}_{map_type}\"\r\n- target_child_name_b64 = encode_name_b64(logical_child_name) # Use Base64 name\r\n-\r\n- child_group = bpy.data.node_groups.get(target_child_name_b64) # Find using encoded name\r\n- is_new_child = False\r\n-\r\n- if child_group is None:\r\n- print(f\" DEBUG: Child group '{target_child_name_b64}' not found. Creating new one.\") # DEBUG LOG (Indented)\r\n- # print(f\" Creating new child group: '{target_child_name_b64}' (logical: '{logical_child_name}')\") # Verbose\r\n- child_group = template_child.copy()\r\n- if not child_group:\r\n- print(f\" !!! ERROR: Failed to copy child template '{CHILD_TEMPLATE_NAME}'. Skipping map type '{map_type}'.\")\r\n- errors_encountered += 1\r\n- continue\r\n- child_group.name = target_child_name_b64 # Set encoded name\r\n- child_groups_created += 1\r\n- is_new_child = True\r\n- else:\r\n- print(f\" DEBUG: Found existing child group '{target_child_name_b64}'.\") # DEBUG LOG (Indented)\r\n- # print(f\" Updating existing child group: '{target_child_name_b64}'\") # Verbose\r\n- child_groups_updated += 1\r\n-\r\n- # Assign child group to placeholder if needed\r\n- if holder_node.node_tree != child_group:\r\n- try:\r\n- holder_node.node_tree = child_group\r\n- print(f\" Assigned child group '{child_group.name}' to placeholder '{holder_node.label}'.\")\r\n- except TypeError as e_assign: # Catch potential type errors if placeholder isn't a group node\r\n- print(f\" !!! ERROR: Could not assign child group to placeholder '{holder_node.label}'. Is it a Group Node? Error: {e_assign}\")\r\n- continue # Skip this map type if assignment fails\r\n-\r\n- # Link placeholder output to parent output socket\r\n- try:\r\n- # Find parent's output node\r\n- group_output_node = next((n for n in parent_group.nodes if n.type == 'GROUP_OUTPUT'), None)\r\n- if group_output_node:\r\n- # Get the specific output socket on the placeholder (usually index 0 or named 'Color'/'Value')\r\n- source_socket = holder_node.outputs.get(\"Color\") or holder_node.outputs.get(\"Value\") or holder_node.outputs[0]\r\n- # Get the specific input socket on the parent output node (matching map_type)\r\n- target_socket = group_output_node.inputs.get(map_type)\r\n-\r\n- if source_socket and target_socket:\r\n- # Check if link already exists\r\n- link_exists = any(link.from_socket == source_socket and link.to_socket == target_socket for link in parent_group.links)\r\n- if not link_exists:\r\n- parent_group.links.new(source_socket, target_socket)\r\n- print(f\" Linked '{holder_node.label}' output to parent output socket '{map_type}'.\")\r\n- # else: # Optional warnings\r\n- # if not source_socket: print(f\" Warn: Could not find suitable output socket on placeholder '{holder_node.label}'.\")\r\n- # if not target_socket: print(f\" Warn: Could not find input socket '{map_type}' on parent output node.\")\r\n- # else: print(f\" Warn: Parent group '{parent_group.name}' has no Group Output node.\")\r\n-\r\n- except Exception as e_link:\r\n- print(f\" !!! ERROR linking sockets for '{map_type}': {e_link}\")\r\n-\r\n- # Ensure parent output socket type is Color (if it exists)\r\n- try:\r\n- # Use the interface API for modern Blender versions\r\n- item = parent_group.interface.items_tree.get(map_type)\r\n- if item and item.item_type == 'SOCKET' and item.in_out == 'OUTPUT':\r\n- # Common useful types: 'NodeSocketColor', 'NodeSocketVector', 'NodeSocketFloat'\r\n- # Defaulting to Color seems reasonable for most PBR outputs\r\n- if item.socket_type != 'NodeSocketColor':\r\n- item.socket_type = 'NodeSocketColor'\r\n- # print(f\" Set parent output socket '{map_type}' type to Color.\") # Optional info\r\n- except Exception as e_sock_type:\r\n- print(f\" Warn: Could not verify/set socket type for '{map_type}': {e_sock_type}\")\r\n-\r\n-\r\n- # --- Image Node Handling (Inside Child Group) ---\r\n- if not isinstance(resolutions, list):\r\n- print(f\" !!! ERROR: Invalid format for resolutions list for map type '{map_type}'. Skipping.\")\r\n- continue\r\n-\r\n- for resolution in resolutions:\r\n- # --- Manifest Check (Map/Resolution Level) ---\r\n- if ENABLE_MANIFEST and is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- # print(f\" Skipping {resolution} (Manifest)\") # Verbose\r\n- maps_skipped_manifest += 1\r\n- continue\r\n- print(f\" DEBUG: Processing map '{map_type}' resolution '{resolution}'. Manifest skip check passed.\") # DEBUG LOG (Indented)\r\n-\r\n- print(f\" Processing Resolution: {resolution}\")\r\n-\r\n- # Reconstruct the image path using fallback logic\r\n- # Pass output_format (which might be None for merged maps)\r\n- image_path_str = reconstruct_image_path_with_fallback(\r\n- asset_dir_path=asset_dir_path,\r\n- asset_name=asset_name,\r\n- map_type=map_type,\r\n- resolution=resolution,\r\n- primary_format=output_format\r\n- )\r\n- print(f\" DEBUG: Reconstructed image path for {map_type}/{resolution}: {image_path_str}\") # DEBUG LOG (Indented)\r\n-\r\n- if not image_path_str:\r\n- # Error already printed by reconstruct function\r\n- errors_encountered += 1\r\n- continue # Skip this resolution if path not found\r\n-\r\n- # Find image texture node within the CHILD group (labeled by resolution, e.g., \"4K\")\r\n- image_nodes = find_nodes_by_label(child_group, resolution, 'ShaderNodeTexImage')\r\n- if not image_nodes:\r\n- print(f\" !!! WARNING: No Image Texture node labeled '{resolution}' found in child group '{child_group.name}'. Cannot assign image.\")\r\n- continue # Skip this resolution if node not found\r\n- print(f\" DEBUG: Found {len(image_nodes)} image node(s) labeled '{resolution}' in child group '{child_group.name}'.\") # DEBUG LOG (Indented)\r\n-\r\n- # --- Load Image ---\r\n- img = None\r\n- image_load_failed = False\r\n- try:\r\n- image_path = Path(image_path_str) # Path object created from already found path string\r\n- # Use check_existing=True to reuse existing datablocks if path matches\r\n- img = bpy.data.images.load(str(image_path), check_existing=True)\r\n- if not img:\r\n- print(f\" !!! ERROR: Failed loading image via bpy.data.images.load: {image_path_str}\")\r\n- image_load_failed = True\r\n- else:\r\n- # Only count as loaded if bpy.data.images.load succeeded\r\n- # Check if it's newly loaded or reused\r\n- is_newly_loaded = img.library is None # Newly loaded images don't have a library initially\r\n- if is_newly_loaded: images_loaded += 1\r\n-\r\n- except RuntimeError as e_runtime_load:\r\n- # Catch specific Blender runtime errors (e.g., unsupported format)\r\n- print(f\" !!! ERROR loading image '{image_path_str}': {e_runtime_load}\")\r\n- image_load_failed = True\r\n- except Exception as e_gen_load:\r\n- print(f\" !!! UNEXPECTED ERROR loading image '{image_path_str}': {e_gen_load}\")\r\n- image_load_failed = True\r\n- errors_encountered += 1\r\n-\r\n- # --- Assign Image & Set Color Space ---\r\n- if not image_load_failed and img:\r\n- assigned_count_this_res = 0\r\n- for image_node in image_nodes:\r\n- if image_node.image != img:\r\n- image_node.image = img\r\n- assigned_count_this_res += 1\r\n-\r\n- if assigned_count_this_res > 0:\r\n- images_assigned += assigned_count_this_res\r\n- print(f\" Assigned image '{img.name}' to {assigned_count_this_res} node(s).\")\r\n-\r\n- # Set Color Space\r\n- correct_color_space = get_color_space(map_type)\r\n- try:\r\n- if img.colorspace_settings.name != correct_color_space:\r\n- img.colorspace_settings.name = correct_color_space\r\n- print(f\" Set '{img.name}' color space -> {correct_color_space}\")\r\n- except TypeError as e_cs: # Handle case where colorspace name is invalid\r\n- print(f\" !!! WARNING: Could not set color space '{correct_color_space}' for image '{img.name}'. Is the color space available in Blender? Error: {e_cs}\")\r\n- except Exception as e_cs_gen:\r\n- print(f\" !!! ERROR setting color space for image '{img.name}': {e_cs_gen}\")\r\n-\r\n-\r\n- # --- Update Manifest (Map/Resolution Level) ---\r\n- if update_manifest(manifest_data, asset_name, map_type, resolution):\r\n- manifest_needs_saving = True\r\n- # print(f\" Marked {map_type}/{resolution} processed in manifest.\") # Verbose\r\n- maps_processed += 1\r\n-\r\n- else:\r\n- # Increment error count if loading failed\r\n- if image_load_failed: errors_encountered += 1\r\n-\r\n- # --- End Resolution Loop ---\r\n- # --- End Map Type Loop ---\r\n-\r\n- assets_processed += 1\r\n-\r\n- except FileNotFoundError:\r\n- print(f\" !!! ERROR: Metadata file not found (should not happen if scan worked): {metadata_path}\")\r\n- errors_encountered += 1\r\n- except json.JSONDecodeError:\r\n- print(f\" !!! ERROR: Invalid JSON in metadata file: {metadata_path}\")\r\n- errors_encountered += 1\r\n- except Exception as e_main_loop:\r\n- print(f\" !!! UNEXPECTED ERROR processing asset from {metadata_path}: {e_main_loop}\")\r\n- import traceback\r\n- traceback.print_exc() # Print detailed traceback for debugging\r\n- errors_encountered += 1\r\n- # Continue to the next asset\r\n-\r\n- # --- End Metadata File Loop ---\r\n-\r\n- # --- Final Manifest Save ---\r\n- if ENABLE_MANIFEST and manifest_needs_saving:\r\n- print(\"\\nAttempting final manifest save...\")\r\n- save_manifest(context, manifest_data)\r\n- elif ENABLE_MANIFEST:\r\n- print(\"\\nManifest is enabled, but no changes require saving.\")\r\n- # --- End Final Manifest Save ---\r\n-\r\n- # --- Final Summary ---\r\n- end_time = time.time()\r\n- duration = end_time - start_time\r\n- print(\"\\n--- Script Run Finished ---\")\r\n- print(f\"Duration: {duration:.2f} seconds\")\r\n- print(f\"Metadata Files Found: {metadata_files_found}\")\r\n- print(f\"Assets Processed/Attempted: {assets_processed}\")\r\n- if ENABLE_MANIFEST:\r\n- print(f\"Assets Skipped (Manifest): {assets_skipped_manifest}\")\r\n- print(f\"Maps Skipped (Manifest): {maps_skipped_manifest}\")\r\n- print(f\"Parent Groups Created: {parent_groups_created}\")\r\n- print(f\"Parent Groups Updated: {parent_groups_updated}\")\r\n- print(f\"Child Groups Created: {child_groups_created}\")\r\n- print(f\"Child Groups Updated: {child_groups_updated}\")\r\n- print(f\"Images Loaded: {images_loaded}\")\r\n- print(f\"Image Nodes Assigned: {images_assigned}\")\r\n- print(f\"Individual Maps Processed: {maps_processed}\")\r\n- print(f\"Asset Previews Set: {previews_set}\")\r\n- print(f\"Highest Resolution Nodes Set: {highest_res_set}\")\r\n- print(f\"Aspect Ratio Nodes Set: {aspect_ratio_set}\") # Added counter\r\n- if errors_encountered > 0:\r\n- print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n- print(\"---------------------------\")\r\n-\r\n- # --- Explicit Save ---\r\n- print(f\" DEBUG: Attempting explicit save for file: {bpy.data.filepath}\") # DEBUG LOG (Indented)\r\n- try:\r\n- bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)\r\n- print(\"\\n--- Explicitly saved the .blend file. ---\")\r\n- except Exception as e_save:\r\n- print(f\"\\n!!! ERROR explicitly saving .blend file: {e_save} !!!\")\r\n- errors_encountered += 1 # Count save errors\r\n-\r\n- return True\r\n-\r\n-\r\n-# --- Execution Block ---\r\n-\r\n-if __name__ == \"__main__\":\r\n- # Ensure we are running within Blender\r\n- try:\r\n- import bpy\r\n- import base64 # Ensure base64 is imported here too if needed globally\r\n- import sys\r\n- except ImportError:\r\n- print(\"!!! ERROR: This script must be run from within Blender. !!!\")\r\n- else:\r\n- # --- Argument Parsing for Asset Library Root ---\r\n- asset_root_arg = None\r\n- try:\r\n- # Blender arguments passed after '--' appear in sys.argv\r\n- if \"--\" in sys.argv:\r\n- args_after_dash = sys.argv[sys.argv.index(\"--\") + 1:]\r\n- if len(args_after_dash) >= 1:\r\n- asset_root_arg = args_after_dash[0]\r\n- print(f\"Found asset library root argument: {asset_root_arg}\")\r\n- else:\r\n- print(\"Info: '--' found but no arguments after it.\")\r\n- # else: print(\"Info: No '--' found in arguments.\") # Optional debug\r\n- except Exception as e:\r\n- print(f\"Error parsing command line arguments: {e}\")\r\n- # --- End Argument Parsing ---\r\n-\r\n- process_library(bpy.context, asset_library_root_override=asset_root_arg)\r\n-# Blender Script: Create/Update Node Groups from Asset Processor Output\r\n-# Version: 1.5\r\n-# Description: Scans a library processed by the Asset Processor Tool,\r\n-# reads metadata.json files, and creates/updates corresponding\r\n-# PBR node groups in the active Blender file.\r\n-# Changes v1.5:\r\n-# - Corrected aspect ratio calculation (`calculate_aspect_correction_factor`)\r\n-# to use actual image dimensions from a loaded reference image and the\r\n-# `aspect_ratio_change_string`, mirroring original script logic for\r\n-# \"EVEN\", \"Xnnn\", \"Ynnn\" formats.\r\n-# - Added logic in main loop to load reference image for dimensions.\r\n-# Changes v1.3:\r\n-# - Added logic to find the highest resolution present for an asset.\r\n-# - Added logic to set a \"HighestResolution\" Value node in the parent group\r\n-# (maps 1K->1.0, 2K->2.0, 4K->3.0, 8K->4.0).\r\n-# Changes v1.2:\r\n-# - Added Base64 encoding for child node group names (PBRTYPE_...).\r\n-# - Added fallback logic for reconstructing image paths with different extensions.\r\n-# - Added logic to set custom asset preview for new parent groups (using lowest res COL map).\r\n-# Changes v1.1:\r\n-# - Updated metadata parsing to match actual structure (using processed_map_resolutions, image_stats_1k, map_details).\r\n-# - Added logic to reconstruct image file paths based on metadata and assumed naming convention.\r\n-\r\n-import bpy\r\n-import os\r\n-import json\r\n-from pathlib import Path\r\n-import time\r\n-import re # For parsing aspect ratio string\r\n-import base64 # For encoding node group names\r\n-import sys # <<< ADDED IMPORT\r\n-\r\n-# --- USER CONFIGURATION ---\r\n-\r\n-# Path to the root output directory of the Asset Processor Tool\r\n-# Example: r\"G:\\Assets\\Processed\"\r\n-# IMPORTANT: This should point to the base directory containing supplier folders (e.g., Poliigon)\r\n-# This will be overridden by command-line arguments if provided.\r\n-PROCESSED_ASSET_LIBRARY_ROOT = None # Set to None initially\r\n-\r\n-# Names of the required node group templates in the Blender file\r\n-PARENT_TEMPLATE_NAME = \"Template_PBRSET\"\r\n-CHILD_TEMPLATE_NAME = \"Template_PBRTYPE\"\r\n-\r\n-# Labels of specific nodes within the PARENT template\r\n-ASPECT_RATIO_NODE_LABEL = \"AspectRatioCorrection\" # Value node for UV X-scaling factor\r\n-STATS_NODE_PREFIX = \"Histogram-\" # Prefix for Combine XYZ nodes storing stats (e.g., \"Histogram-ROUGH\")\r\n-HIGHEST_RESOLUTION_NODE_LABEL = \"HighestResolution\" # Value node to store highest res index\r\n-\r\n-# Enable/disable the manifest system to track processed assets/maps\r\n-# If enabled, requires the blend file to be saved.\r\n-ENABLE_MANIFEST = True # Disabled based on user feedback in previous run\r\n-\r\n-# Assumed filename pattern for processed images.\r\n-# {asset_name}, {map_type}, {resolution}, {format} will be replaced.\r\n-# Check Asset Processor Tool's config.py (TARGET_FILENAME_PATTERN) if this is wrong.\r\n-IMAGE_FILENAME_PATTERN = \"{asset_name}_{map_type}_{resolution}.{format}\"\r\n-\r\n-# Fallback extensions to try if the primary format from metadata is not found\r\n-# Order matters - first found will be used.\r\n-FALLBACK_IMAGE_EXTENSIONS = ['png', 'jpg', 'exr', 'tif']\r\n-\r\n-# Map type(s) to use for generating the asset preview AND for aspect ratio calculation reference\r\n-# The script will look for these in order and use the first one found.\r\n-REFERENCE_MAP_TYPES = [\"COL\", \"COL-1\", \"COL-2\"] # Used for preview and aspect calc\r\n-# Preferred resolution order for reference image (lowest first is often faster)\r\n-REFERENCE_RESOLUTION_ORDER = [\"1K\", \"512\", \"2K\", \"4K\"] # Adjust as needed\r\n-\r\n-# Mapping from resolution string to numerical value for the HighestResolution node\r\n-RESOLUTION_VALUE_MAP = {\"1K\": 1.0, \"2K\": 2.0, \"4K\": 3.0, \"8K\": 4.0}\r\n-# Order to check resolutions to find the highest present (highest value first)\r\n-RESOLUTION_ORDER_DESC = [\"8K\", \"4K\", \"2K\", \"1K\"] # Add others like \"512\" if needed and map them in RESOLUTION_VALUE_MAP\r\n-\r\n-# Map PBR type strings (from metadata) to Blender color spaces\r\n-# Add more mappings as needed based on your metadata types\r\n-PBR_COLOR_SPACE_MAP = {\r\n- \"AO\": \"Non-Color\", # Usually Non-Color, but depends on workflow\r\n- \"COL\": \"sRGB\",\r\n- \"COL-1\": \"sRGB\", # Handle variants if present in metadata\r\n- \"COL-2\": \"sRGB\",\r\n- \"COL-3\": \"sRGB\",\r\n- \"DISP\": \"Non-Color\",\r\n- \"NRM\": \"Non-Color\",\r\n- \"REFL\": \"Non-Color\", # Reflection/Specular\r\n- \"ROUGH\": \"Non-Color\",\r\n- \"METAL\": \"Non-Color\",\r\n- \"OPC\": \"Non-Color\", # Opacity/Alpha\r\n- \"TRN\": \"Non-Color\", # Transmission\r\n- \"SSS\": \"sRGB\", # Subsurface Color\r\n- \"EMISS\": \"sRGB\", # Emission Color\r\n- \"NRMRGH\": \"Non-Color\", # Added for merged map\r\n- \"FUZZ\": \"Non-Color\",\r\n- # Add other types like GLOSS, HEIGHT, etc. if needed\r\n-}\r\n-DEFAULT_COLOR_SPACE = \"sRGB\" # Fallback if map type not in the dictionary\r\n-\r\n-# Map types for which stats should be applied (if found in metadata and node exists)\r\n-# Reads stats from the 'image_stats_1k' section of metadata.json\r\n-APPLY_STATS_FOR_MAP_TYPES = [\"ROUGH\", \"DISP\", \"METAL\", \"AO\", \"REFL\"] # Add others if needed\r\n-\r\n-# --- END USER CONFIGURATION ---\r\n-\r\n-\r\n-# --- Helper Functions ---\r\n-\r\n-def encode_name_b64(name_str):\r\n- \"\"\"Encodes a string using URL-safe Base64 for node group names.\"\"\"\r\n- try:\r\n- # Ensure the input is a string\r\n- name_str = str(name_str)\r\n- return base64.urlsafe_b64encode(name_str.encode('utf-8')).decode('ascii')\r\n- except Exception as e:\r\n- print(f\" Error base64 encoding '{name_str}': {e}\")\r\n- return name_str # Fallback to original name on error\r\n-\r\n-def find_nodes_by_label(node_tree, label, node_type=None):\r\n- \"\"\"Finds ALL nodes in a node tree matching the label and optionally type.\"\"\"\r\n- if not node_tree:\r\n- return []\r\n- matching_nodes = []\r\n- for node in node_tree.nodes:\r\n- # Use node.label for labeled nodes, node.name for non-labeled (like Group Input/Output)\r\n- node_identifier = node.label if node.label else node.name\r\n- if node_identifier and node_identifier == label:\r\n- if node_type is None or node.bl_idname == node_type or node.type == node_type: # Check bl_idname and type for flexibility\r\n- matching_nodes.append(node)\r\n- return matching_nodes\r\n-\r\n-def add_tag_if_new(asset_data, tag_name):\r\n- \"\"\"Adds a tag to the asset data if it's not None/empty and doesn't already exist.\"\"\"\r\n- if not asset_data or not tag_name or not isinstance(tag_name, str):\r\n- return False\r\n- cleaned_tag_name = tag_name.strip()\r\n- if not cleaned_tag_name:\r\n- return False\r\n-\r\n- # Check if tag already exists (case-insensitive check might be better sometimes)\r\n- if cleaned_tag_name not in [t.name for t in asset_data.tags]:\r\n- try:\r\n- asset_data.tags.new(cleaned_tag_name)\r\n- print(f\" + Added Asset Tag: '{cleaned_tag_name}'\")\r\n- return True\r\n- except Exception as e:\r\n- print(f\" Error adding tag '{cleaned_tag_name}': {e}\")\r\n- return False\r\n- return False # Tag already existed\r\n-\r\n-def get_color_space(map_type):\r\n- \"\"\"Returns the appropriate Blender color space name for a given map type string.\"\"\"\r\n- # Handle potential numbered variants like COL-1, COL-2\r\n- base_map_type = map_type.split('-')[0]\r\n- return PBR_COLOR_SPACE_MAP.get(map_type.upper(), # Check full name first (e.g., NRMRGH)\r\n- PBR_COLOR_SPACE_MAP.get(base_map_type.upper(), DEFAULT_COLOR_SPACE)) # Fallback to base type\r\n-\r\n-def calculate_aspect_correction_factor(image_width, image_height, aspect_string):\r\n- \"\"\"\r\n- Calculates the UV X-axis scaling factor needed to correct distortion,\r\n- based on image dimensions and the aspect_ratio_change_string (\"EVEN\", \"Xnnn\", \"Ynnn\").\r\n- Mirrors the logic from the original POC script.\r\n- Returns 1.0 if dimensions are invalid or string is \"EVEN\" or invalid.\r\n- \"\"\"\r\n- if image_height <= 0 or image_width <= 0:\r\n- print(\" Warn: Invalid image dimensions for aspect ratio calculation. Returning 1.0.\")\r\n- return 1.0\r\n-\r\n- # Calculate the actual aspect ratio of the image file\r\n- current_aspect_ratio = image_width / image_height\r\n-\r\n- if not aspect_string or aspect_string.upper() == \"EVEN\":\r\n- # If scaling was even, the correction factor is just the image's aspect ratio\r\n- # to make UVs match the image proportions.\r\n- # print(f\" Aspect string is EVEN. Correction factor = current aspect ratio: {current_aspect_ratio:.4f}\")\r\n- return current_aspect_ratio\r\n-\r\n- # Handle non-uniform scaling cases (\"Xnnn\", \"Ynnn\")\r\n- # Use search instead of match to find anywhere in string (though unlikely needed based on format)\r\n- match = re.search(r\"([XY])(\\d+)\", aspect_string, re.IGNORECASE)\r\n- if not match:\r\n- print(f\" Warn: Invalid Scaling string format '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f} as fallback.\")\r\n- return current_aspect_ratio # Fallback to the image's own ratio\r\n-\r\n- axis = match.group(1).upper()\r\n- try:\r\n- amount = int(match.group(2))\r\n- if amount <= 0:\r\n- print(f\" Warn: Zero or negative Amount in Scaling string '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n- return current_aspect_ratio\r\n- except ValueError:\r\n- print(f\" Warn: Invalid Amount in Scaling string '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n- return current_aspect_ratio\r\n-\r\n- # Apply the non-uniform correction formula based on original script logic\r\n- scaling_factor_percent = amount / 100.0\r\n- correction_factor = current_aspect_ratio # Default\r\n-\r\n- try:\r\n- if axis == 'X':\r\n- if scaling_factor_percent == 0: raise ZeroDivisionError(\"X scaling factor is zero\")\r\n- # If image was stretched horizontally (X > 1), divide UV.x by factor\r\n- correction_factor = current_aspect_ratio / scaling_factor_percent\r\n- elif axis == 'Y':\r\n- # If image was stretched vertically (Y > 1), multiply UV.x by factor\r\n- correction_factor = current_aspect_ratio * scaling_factor_percent\r\n- # No 'else' needed as regex ensures X or Y\r\n-\r\n- except ZeroDivisionError as e:\r\n- print(f\" Warn: Division by zero during aspect factor calculation ({e}). Returning current ratio {current_aspect_ratio:.4f}.\")\r\n- return current_aspect_ratio\r\n- except Exception as e:\r\n- print(f\" Error calculating aspect correction factor: {e}. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n- return current_aspect_ratio\r\n-\r\n- # print(f\" Calculated aspect correction factor: {correction_factor:.4f} (from {image_width}x{image_height}, Scaling='{aspect_string}')\")\r\n- return correction_factor\r\n-\r\n-\r\n-def reconstruct_image_path_with_fallback(asset_dir_path, asset_name, map_type, resolution, primary_format=None):\r\n- \"\"\"\r\n- Constructs the expected image file path.\r\n- If primary_format is provided, tries that first.\r\n- Then falls back to common extensions if the path doesn't exist or primary_format was None.\r\n- Returns the found path as a string, or None if not found.\r\n- \"\"\"\r\n- if not all([asset_dir_path, asset_name, map_type, resolution]):\r\n- print(f\" !!! ERROR: Missing data for path reconstruction ({asset_name}/{map_type}/{resolution}).\")\r\n- return None\r\n-\r\n- found_path = None\r\n-\r\n- # 1. Try the primary format if provided\r\n- if primary_format:\r\n- try:\r\n- filename = IMAGE_FILENAME_PATTERN.format(\r\n- asset_name=asset_name,\r\n- map_type=map_type,\r\n- resolution=resolution,\r\n- format=primary_format.lower() # Ensure format is lowercase\r\n- )\r\n- primary_path = asset_dir_path / filename\r\n- if primary_path.is_file():\r\n- # print(f\" Found primary path: {str(primary_path)}\") # Verbose\r\n- return str(primary_path)\r\n- # else: print(f\" Primary path not found: {str(primary_path)}\") # Verbose\r\n- except KeyError as e:\r\n- print(f\" !!! ERROR: Missing key '{e}' in IMAGE_FILENAME_PATTERN. Cannot reconstruct path.\")\r\n- return None # Cannot proceed without valid pattern\r\n- except Exception as e:\r\n- print(f\" !!! ERROR reconstructing primary image path: {e}\")\r\n- # Continue to fallback\r\n-\r\n- # 2. Try fallback extensions\r\n- # print(f\" Trying fallback extensions for {map_type}/{resolution}...\") # Verbose\r\n- for ext in FALLBACK_IMAGE_EXTENSIONS:\r\n- # Skip if we already tried this extension as primary (and it failed)\r\n- if primary_format and ext.lower() == primary_format.lower():\r\n- continue\r\n- try:\r\n- fallback_filename = IMAGE_FILENAME_PATTERN.format(\r\n- asset_name=asset_name,\r\n- map_type=map_type,\r\n- resolution=resolution,\r\n- format=ext.lower()\r\n- )\r\n- fallback_path = asset_dir_path / fallback_filename\r\n- if fallback_path.is_file():\r\n- print(f\" Found fallback path: {str(fallback_path)}\")\r\n- return str(fallback_path) # Found it!\r\n- except KeyError:\r\n- # Should not happen if primary format worked, but handle defensively\r\n- print(f\" !!! ERROR: Missing key in IMAGE_FILENAME_PATTERN during fallback. Cannot reconstruct path.\")\r\n- return None\r\n- except Exception as e_fallback:\r\n- print(f\" !!! ERROR reconstructing fallback image path ({ext}): {e_fallback}\")\r\n- continue # Try next extension\r\n-\r\n- # If we get here, neither primary nor fallbacks worked\r\n- if primary_format:\r\n- print(f\" !!! ERROR: Could not find image file for {map_type}/{resolution} using primary format '{primary_format}' or fallbacks {FALLBACK_IMAGE_EXTENSIONS}.\")\r\n- else:\r\n- print(f\" !!! ERROR: Could not find image file for {map_type}/{resolution} using fallbacks {FALLBACK_IMAGE_EXTENSIONS}.\")\r\n- return None # Not found after all checks\r\n-\r\n-\r\n-# --- Manifest Functions ---\r\n-\r\n-def get_manifest_path(context):\r\n- \"\"\"Gets the expected path for the manifest JSON file.\"\"\"\r\n- if not context or not context.blend_data or not context.blend_data.filepath:\r\n- return None # Cannot determine path if blend file is not saved\r\n- blend_path = Path(context.blend_data.filepath)\r\n- manifest_filename = f\"{blend_path.stem}_manifest.json\"\r\n- return blend_path.parent / manifest_filename\r\n-\r\n-def load_manifest(context):\r\n- \"\"\"Loads the manifest data from the JSON file.\"\"\"\r\n- if not ENABLE_MANIFEST:\r\n- return {} # Manifest disabled\r\n-\r\n- manifest_path = get_manifest_path(context)\r\n- if not manifest_path:\r\n- print(\" Manifest Info: Blend file not saved. Cannot load manifest.\")\r\n- return {} # Cannot load without a path\r\n-\r\n- if not manifest_path.exists():\r\n- print(f\" Manifest Info: No manifest file found at '{manifest_path.name}'. Starting fresh.\")\r\n- return {} # No manifest file exists yet\r\n-\r\n- try:\r\n- with open(manifest_path, 'r', encoding='utf-8') as f:\r\n- data = json.load(f)\r\n- print(f\" Manifest Loaded from: {manifest_path.name}\")\r\n- # Basic validation (check if it's a dictionary)\r\n- if not isinstance(data, dict):\r\n- print(f\"!!! WARNING: Manifest file '{manifest_path.name}' has invalid format (not a dictionary). Starting fresh. !!!\")\r\n- return {}\r\n- return data\r\n- except json.JSONDecodeError:\r\n- print(f\"!!! WARNING: Manifest file '{manifest_path.name}' is corrupted. Starting fresh. !!!\")\r\n- return {}\r\n- except Exception as e:\r\n- print(f\"!!! ERROR: Could not load manifest file '{manifest_path.name}': {e} !!!\")\r\n- return {} # Treat as starting fresh on error\r\n-\r\n-def save_manifest(context, manifest_data):\r\n- \"\"\"Saves the manifest data to the JSON file.\"\"\"\r\n- if not ENABLE_MANIFEST or not manifest_data: # Don't save if disabled or empty\r\n- return False\r\n-\r\n- manifest_path = get_manifest_path(context)\r\n- if not manifest_path:\r\n- print(\" Manifest Error: Blend file not saved. Cannot save manifest.\")\r\n- return False\r\n-\r\n- try:\r\n- with open(manifest_path, 'w', encoding='utf-8') as f:\r\n- json.dump(manifest_data, f, indent=2, sort_keys=True) # Use indent and sort for readability\r\n- print(f\" Manifest Saved to: {manifest_path.name}\")\r\n- return True\r\n- except Exception as e:\r\n- print(f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\\n\"\r\n- f\"!!! Manifest save FAILED to '{manifest_path.name}': {e} !!!\\n\"\r\n- f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\")\r\n- return False\r\n-\r\n-def is_asset_processed(manifest_data, asset_name):\r\n- \"\"\"Checks if the entire asset (all its maps/resolutions) is marked as processed.\"\"\"\r\n- if not ENABLE_MANIFEST: return False\r\n- # Basic check if asset entry exists. Detailed check happens at map level.\r\n- return asset_name in manifest_data\r\n-\r\n-def is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- \"\"\"Checks if a specific map type and resolution for an asset is processed.\"\"\"\r\n- if not ENABLE_MANIFEST: return False\r\n- return resolution in manifest_data.get(asset_name, {}).get(map_type, [])\r\n-\r\n-def update_manifest(manifest_data, asset_name, map_type=None, resolution=None):\r\n- \"\"\"Updates the manifest dictionary in memory.\"\"\"\r\n- if not ENABLE_MANIFEST: return False\r\n-\r\n- # Ensure asset entry exists\r\n- if asset_name not in manifest_data:\r\n- manifest_data[asset_name] = {}\r\n-\r\n- # If map_type and resolution are provided, update the specific map entry\r\n- if map_type and resolution:\r\n- if map_type not in manifest_data[asset_name]:\r\n- manifest_data[asset_name][map_type] = []\r\n-\r\n- if resolution not in manifest_data[asset_name][map_type]:\r\n- manifest_data[asset_name][map_type].append(resolution)\r\n- manifest_data[asset_name][map_type].sort() # Keep sorted\r\n- return True # Indicate that a change was made\r\n- return False # No change made to this specific map/res\r\n-\r\n-\r\n-# --- Core Logic ---\r\n-\r\n-def process_library(context, asset_library_root_override=None): # Add override parameter\r\n- global ENABLE_MANIFEST # Declare intent to modify global if needed\r\n- global PROCESSED_ASSET_LIBRARY_ROOT # Allow modification of global\r\n- \"\"\"Scans the library, reads metadata, creates/updates node groups.\"\"\"\r\n- start_time = time.time()\r\n- print(f\"\\n--- Starting Node Group Processing ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---\")\r\n- print(f\" DEBUG: Received asset_library_root_override: {asset_library_root_override}\") # DEBUG LOG (Indented)\r\n-\r\n- # --- Determine Asset Library Root ---\r\n- if asset_library_root_override:\r\n- PROCESSED_ASSET_LIBRARY_ROOT = asset_library_root_override\r\n- print(f\"Using asset library root from argument: '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n- elif not PROCESSED_ASSET_LIBRARY_ROOT:\r\n- print(\"!!! ERROR: Processed asset library root not set in script and not provided via argument.\")\r\n- print(\"--- Script aborted. ---\")\r\n- return False\r\n- print(f\" DEBUG: Using final PROCESSED_ASSET_LIBRARY_ROOT: {PROCESSED_ASSET_LIBRARY_ROOT}\") # DEBUG LOG (Indented)\r\n-\r\n- # --- Pre-run Checks ---\r\n- print(\"Performing pre-run checks...\")\r\n- valid_setup = True\r\n- # 1. Check Library Root Path\r\n- root_path = Path(PROCESSED_ASSET_LIBRARY_ROOT)\r\n- if not root_path.is_dir():\r\n- print(f\"!!! ERROR: Processed asset library root directory not found or not a directory:\")\r\n- print(f\"!!! '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n- valid_setup = False\r\n- else:\r\n- print(f\" Asset Library Root: '{root_path}'\")\r\n- print(f\" DEBUG: Checking for templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\") # DEBUG LOG (Indented)\r\n-\r\n- # 2. Check Templates\r\n- template_parent = bpy.data.node_groups.get(PARENT_TEMPLATE_NAME)\r\n- template_child = bpy.data.node_groups.get(CHILD_TEMPLATE_NAME)\r\n- if not template_parent:\r\n- print(f\"!!! ERROR: Parent template node group '{PARENT_TEMPLATE_NAME}' not found in this Blender file.\")\r\n- valid_setup = False\r\n- if not template_child:\r\n- print(f\"!!! ERROR: Child template node group '{CHILD_TEMPLATE_NAME}' not found in this Blender file.\")\r\n- valid_setup = False\r\n- if template_parent and template_child:\r\n- print(f\" Found Templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\")\r\n- print(f\" DEBUG: Template Parent Found: {template_parent is not None}\") # DEBUG LOG (Indented)\r\n- print(f\" DEBUG: Template Child Found: {template_child is not None}\") # DEBUG LOG (Indented)\r\n-\r\n- # 3. Check Blend File Saved (if manifest enabled)\r\n- if ENABLE_MANIFEST and not context.blend_data.filepath:\r\n- print(f\"!!! WARNING: Manifest is enabled, but the current Blender file is not saved.\")\r\n- print(f\"!!! Manifest cannot be loaded or saved. Processing will continue without manifest checks.\")\r\n- ENABLE_MANIFEST = False # Disable manifest for this run\r\n-\r\n- if not valid_setup:\r\n- print(\"\\n--- Script aborted due to configuration errors. Please fix the issues above. ---\")\r\n- return False\r\n- print(\"Pre-run checks passed.\")\r\n- # --- End Pre-run Checks ---\r\n-\r\n- manifest_data = load_manifest(context)\r\n- manifest_needs_saving = False\r\n-\r\n- # --- Initialize Counters ---\r\n- metadata_files_found = 0\r\n- assets_processed = 0\r\n- assets_skipped_manifest = 0\r\n- parent_groups_created = 0\r\n- parent_groups_updated = 0\r\n- child_groups_created = 0\r\n- child_groups_updated = 0\r\n- images_loaded = 0\r\n- images_assigned = 0\r\n- maps_processed = 0\r\n- maps_skipped_manifest = 0\r\n- errors_encountered = 0\r\n- previews_set = 0\r\n- highest_res_set = 0\r\n- aspect_ratio_set = 0\r\n- # --- End Counters ---\r\n-\r\n- print(f\"\\nScanning for metadata files in '{root_path}'...\")\r\n-\r\n- # --- Scan for metadata.json ---\r\n- # Scan one level deeper for supplier folders (e.g., Poliigon)\r\n- # Then scan within each supplier for asset folders containing metadata.json\r\n- metadata_paths = []\r\n- for supplier_dir in root_path.iterdir():\r\n- if supplier_dir.is_dir():\r\n- # Now look for asset folders inside the supplier directory\r\n- for asset_dir in supplier_dir.iterdir():\r\n- if asset_dir.is_dir():\r\n- metadata_file = asset_dir / 'metadata.json'\r\n- if metadata_file.is_file():\r\n- metadata_paths.append(metadata_file)\r\n-\r\n- metadata_files_found = len(metadata_paths)\r\n- print(f\"Found {metadata_files_found} metadata.json files.\")\r\n- print(f\" DEBUG: Metadata paths found: {metadata_paths}\") # DEBUG LOG (Indented)\r\n-\r\n- if metadata_files_found == 0:\r\n- print(\"No metadata files found. Nothing to process.\")\r\n- print(\"--- Script Finished ---\")\r\n- return True # No work needed is considered success\r\n-\r\n- # --- Process Each Metadata File ---\r\n- for metadata_path in metadata_paths:\r\n- asset_dir_path = metadata_path.parent # Get the directory containing the metadata file\r\n- print(f\"\\n--- Processing Metadata: {metadata_path.relative_to(root_path)} ---\")\r\n- print(f\" DEBUG: Processing file: {metadata_path}\") # DEBUG LOG (Indented)\r\n- try:\r\n- with open(metadata_path, 'r', encoding='utf-8') as f:\r\n- metadata = json.load(f)\r\n-\r\n- # --- Extract Key Info ---\r\n- asset_name = metadata.get(\"asset_name\")\r\n- supplier_name = metadata.get(\"supplier_name\")\r\n- archetype = metadata.get(\"archetype\")\r\n- # Get map info from the correct keys\r\n- processed_resolutions = metadata.get(\"processed_map_resolutions\", {}) # Default to empty dict\r\n- merged_resolutions = metadata.get(\"merged_map_resolutions\", {}) # Get merged maps too\r\n- map_details = metadata.get(\"map_details\", {}) # Default to empty dict\r\n- image_stats_1k = metadata.get(\"image_stats_1k\") # Dict: {map_type: {stats}}\r\n- aspect_string = metadata.get(\"aspect_ratio_change_string\")\r\n-\r\n- # Combine processed and merged maps for iteration\r\n- all_map_resolutions = {**processed_resolutions, **merged_resolutions}\r\n-\r\n- # Validate essential data\r\n- if not asset_name:\r\n- print(f\" !!! ERROR: Metadata file is missing 'asset_name'. Skipping.\")\r\n- errors_encountered += 1\r\n- continue\r\n- if not all_map_resolutions:\r\n- print(f\" !!! ERROR: Metadata file has no 'processed_map_resolutions' or 'merged_map_resolutions'. Skipping asset '{asset_name}'.\")\r\n- errors_encountered += 1\r\n- continue\r\n- # map_details check remains a warning as merged maps won't be in it\r\n- print(f\" DEBUG: Valid metadata loaded for asset: {asset_name}\") # DEBUG LOG (Indented)\r\n-\r\n- print(f\" Asset Name: {asset_name}\")\r\n-\r\n- # --- Determine Highest Resolution ---\r\n- highest_resolution_value = 0.0\r\n- highest_resolution_str = \"Unknown\"\r\n- all_resolutions_present = set()\r\n- if all_map_resolutions: # Check combined dict\r\n- for res_list in all_map_resolutions.values():\r\n- if isinstance(res_list, list):\r\n- all_resolutions_present.update(res_list)\r\n-\r\n- if all_resolutions_present:\r\n- for res_str in RESOLUTION_ORDER_DESC:\r\n- if res_str in all_resolutions_present:\r\n- highest_resolution_value = RESOLUTION_VALUE_MAP.get(res_str, 0.0)\r\n- highest_resolution_str = res_str\r\n- if highest_resolution_value > 0.0:\r\n- break # Found the highest valid resolution\r\n-\r\n- print(f\" Highest resolution found: {highest_resolution_str} (Value: {highest_resolution_value})\")\r\n-\r\n- # --- Load Reference Image for Aspect Ratio ---\r\n- ref_image_path = None\r\n- ref_image_width = 0\r\n- ref_image_height = 0\r\n- ref_image_loaded = False\r\n- # Use combined resolutions dict to find reference map\r\n- for ref_map_type in REFERENCE_MAP_TYPES:\r\n- if ref_map_type in all_map_resolutions:\r\n- available_resolutions = all_map_resolutions[ref_map_type]\r\n- lowest_res = None\r\n- for res_pref in REFERENCE_RESOLUTION_ORDER:\r\n- if res_pref in available_resolutions:\r\n- lowest_res = res_pref\r\n- break\r\n- if lowest_res:\r\n- # Get format from map_details if available, otherwise None\r\n- ref_map_details = map_details.get(ref_map_type, {})\r\n- ref_format = ref_map_details.get(\"output_format\")\r\n- ref_image_path = reconstruct_image_path_with_fallback(\r\n- asset_dir_path=asset_dir_path,\r\n- asset_name=asset_name,\r\n- map_type=ref_map_type,\r\n- resolution=lowest_res,\r\n- primary_format=ref_format # Pass None if not in map_details\r\n- )\r\n- if ref_image_path:\r\n- break # Found a suitable reference image path\r\n-\r\n- if ref_image_path:\r\n- print(f\" Loading reference image for aspect ratio: {Path(ref_image_path).name}\")\r\n- try:\r\n- # Load image temporarily\r\n- ref_img = bpy.data.images.load(ref_image_path, check_existing=True)\r\n- if ref_img:\r\n- ref_image_width = ref_img.size[0]\r\n- ref_image_height = ref_img.size[1]\r\n- ref_image_loaded = True\r\n- print(f\" Reference image dimensions: {ref_image_width}x{ref_image_height}\")\r\n- # Remove the temporary image datablock to save memory\r\n- bpy.data.images.remove(ref_img)\r\n- else:\r\n- print(f\" !!! ERROR: Failed loading reference image via bpy.data.images.load: {ref_image_path}\")\r\n- except Exception as e_ref_load:\r\n- print(f\" !!! ERROR loading reference image '{ref_image_path}': {e_ref_load}\")\r\n- else:\r\n- print(f\" !!! WARNING: Could not find suitable reference image ({REFERENCE_MAP_TYPES} at {REFERENCE_RESOLUTION_ORDER}) for aspect ratio calculation.\")\r\n-\r\n-\r\n- # --- Manifest Check (Asset Level - Basic) ---\r\n- if ENABLE_MANIFEST and is_asset_processed(manifest_data, asset_name):\r\n- # Perform a quick check if *any* map needs processing for this asset\r\n- needs_processing = False\r\n- for map_type, resolutions in all_map_resolutions.items(): # Check combined maps\r\n- for resolution in resolutions:\r\n- if not is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- needs_processing = True\r\n- break\r\n- if needs_processing:\r\n- break\r\n- if not needs_processing:\r\n- print(f\" Skipping asset '{asset_name}' (already fully processed according to manifest).\")\r\n- assets_skipped_manifest += 1\r\n- continue # Skip to next metadata file\r\n-\r\n- # --- Parent Group Handling ---\r\n- target_parent_name = f\"PBRSET_{asset_name}\"\r\n- parent_group = bpy.data.node_groups.get(target_parent_name)\r\n- is_new_parent = False\r\n-\r\n- if parent_group is None:\r\n- print(f\" Creating new parent group: '{target_parent_name}'\")\r\n- print(f\" DEBUG: Copying parent template '{PARENT_TEMPLATE_NAME}'\") # DEBUG LOG (Indented)\r\n- parent_group = template_parent.copy()\r\n- if not parent_group:\r\n- print(f\" !!! ERROR: Failed to copy parent template '{PARENT_TEMPLATE_NAME}'. Skipping asset '{asset_name}'.\")\r\n- errors_encountered += 1\r\n- continue\r\n- parent_group.name = target_parent_name\r\n- parent_groups_created += 1\r\n- is_new_parent = True\r\n- else:\r\n- print(f\" Updating existing parent group: '{target_parent_name}'\")\r\n- print(f\" DEBUG: Found existing parent group.\") # DEBUG LOG (Indented)\r\n- parent_groups_updated += 1\r\n-\r\n- # Ensure marked as asset\r\n- if not parent_group.asset_data:\r\n- try:\r\n- parent_group.asset_mark()\r\n- print(f\" Marked '{parent_group.name}' as asset.\")\r\n- except Exception as e_mark:\r\n- print(f\" !!! ERROR: Failed to mark group '{parent_group.name}' as asset: {e_mark}\")\r\n- # Continue processing other parts if possible\r\n-\r\n- # Apply Asset Tags\r\n- if parent_group.asset_data:\r\n- if supplier_name:\r\n- add_tag_if_new(parent_group.asset_data, supplier_name)\r\n- if archetype:\r\n- add_tag_if_new(parent_group.asset_data, archetype)\r\n- # Add other tags if needed\r\n- # else: print(f\" Warn: Cannot apply tags, asset_data not available for '{parent_group.name}'.\") # Optional warning\r\n-\r\n # Apply Aspect Ratio Correction\r\n aspect_nodes = find_nodes_by_label(parent_group, ASPECT_RATIO_NODE_LABEL, 'ShaderNodeValue')\r\n if aspect_nodes:\r\n aspect_node = aspect_nodes[0]\r\n@@ -2035,1023 +1025,4 @@\n print(f\"Error parsing command line arguments: {e}\")\r\n # --- End Argument Parsing ---\r\n \r\n process_library(bpy.context, asset_library_root_override=asset_root_arg)\r\n-# Blender Script: Create/Update Node Groups from Asset Processor Output\r\n-# Version: 1.5\r\n-# Description: Scans a library processed by the Asset Processor Tool,\r\n-# reads metadata.json files, and creates/updates corresponding\r\n-# PBR node groups in the active Blender file.\r\n-# Changes v1.5:\r\n-# - Corrected aspect ratio calculation (`calculate_aspect_correction_factor`)\r\n-# to use actual image dimensions from a loaded reference image and the\r\n-# `aspect_ratio_change_string`, mirroring original script logic for\r\n-# \"EVEN\", \"Xnnn\", \"Ynnn\" formats.\r\n-# - Added logic in main loop to load reference image for dimensions.\r\n-# Changes v1.3:\r\n-# - Added logic to find the highest resolution present for an asset.\r\n-# - Added logic to set a \"HighestResolution\" Value node in the parent group\r\n-# (maps 1K->1.0, 2K->2.0, 4K->3.0, 8K->4.0).\r\n-# Changes v1.2:\r\n-# - Added Base64 encoding for child node group names (PBRTYPE_...).\r\n-# - Added fallback logic for reconstructing image paths with different extensions.\r\n-# - Added logic to set custom asset preview for new parent groups (using lowest res COL map).\r\n-# Changes v1.1:\r\n-# - Updated metadata parsing to match actual structure (using processed_map_resolutions, image_stats_1k, map_details).\r\n-# - Added logic to reconstruct image file paths based on metadata and assumed naming convention.\r\n-\r\n-import bpy\r\n-import os\r\n-import json\r\n-from pathlib import Path\r\n-import time\r\n-import re # For parsing aspect ratio string\r\n-import base64 # For encoding node group names\r\n-import sys # <<< ADDED IMPORT\r\n-\r\n-# --- USER CONFIGURATION ---\r\n-\r\n-# Path to the root output directory of the Asset Processor Tool\r\n-# Example: r\"G:\\Assets\\Processed\"\r\n-# IMPORTANT: This should point to the base directory containing supplier folders (e.g., Poliigon)\r\n-# This will be overridden by command-line arguments if provided.\r\n-PROCESSED_ASSET_LIBRARY_ROOT = None # Set to None initially\r\n-\r\n-# Names of the required node group templates in the Blender file\r\n-PARENT_TEMPLATE_NAME = \"Template_PBRSET\"\r\n-CHILD_TEMPLATE_NAME = \"Template_PBRTYPE\"\r\n-\r\n-# Labels of specific nodes within the PARENT template\r\n-ASPECT_RATIO_NODE_LABEL = \"AspectRatioCorrection\" # Value node for UV X-scaling factor\r\n-STATS_NODE_PREFIX = \"Histogram-\" # Prefix for Combine XYZ nodes storing stats (e.g., \"Histogram-ROUGH\")\r\n-HIGHEST_RESOLUTION_NODE_LABEL = \"HighestResolution\" # Value node to store highest res index\r\n-\r\n-# Enable/disable the manifest system to track processed assets/maps\r\n-# If enabled, requires the blend file to be saved.\r\n-ENABLE_MANIFEST = True # Disabled based on user feedback in previous run\r\n-\r\n-# Assumed filename pattern for processed images.\r\n-# {asset_name}, {map_type}, {resolution}, {format} will be replaced.\r\n-# Check Asset Processor Tool's config.py (TARGET_FILENAME_PATTERN) if this is wrong.\r\n-IMAGE_FILENAME_PATTERN = \"{asset_name}_{map_type}_{resolution}.{format}\"\r\n-\r\n-# Fallback extensions to try if the primary format from metadata is not found\r\n-# Order matters - first found will be used.\r\n-FALLBACK_IMAGE_EXTENSIONS = ['png', 'jpg', 'exr', 'tif']\r\n-\r\n-# Map type(s) to use for generating the asset preview AND for aspect ratio calculation reference\r\n-# The script will look for these in order and use the first one found.\r\n-REFERENCE_MAP_TYPES = [\"COL\", \"COL-1\", \"COL-2\"] # Used for preview and aspect calc\r\n-# Preferred resolution order for reference image (lowest first is often faster)\r\n-REFERENCE_RESOLUTION_ORDER = [\"1K\", \"512\", \"2K\", \"4K\"] # Adjust as needed\r\n-\r\n-# Mapping from resolution string to numerical value for the HighestResolution node\r\n-RESOLUTION_VALUE_MAP = {\"1K\": 1.0, \"2K\": 2.0, \"4K\": 3.0, \"8K\": 4.0}\r\n-# Order to check resolutions to find the highest present (highest value first)\r\n-RESOLUTION_ORDER_DESC = [\"8K\", \"4K\", \"2K\", \"1K\"] # Add others like \"512\" if needed and map them in RESOLUTION_VALUE_MAP\r\n-\r\n-# Map PBR type strings (from metadata) to Blender color spaces\r\n-# Add more mappings as needed based on your metadata types\r\n-PBR_COLOR_SPACE_MAP = {\r\n- \"AO\": \"Non-Color\", # Usually Non-Color, but depends on workflow\r\n- \"COL\": \"sRGB\",\r\n- \"COL-1\": \"sRGB\", # Handle variants if present in metadata\r\n- \"COL-2\": \"sRGB\",\r\n- \"COL-3\": \"sRGB\",\r\n- \"DISP\": \"Non-Color\",\r\n- \"NRM\": \"Non-Color\",\r\n- \"REFL\": \"Non-Color\", # Reflection/Specular\r\n- \"ROUGH\": \"Non-Color\",\r\n- \"METAL\": \"Non-Color\",\r\n- \"OPC\": \"Non-Color\", # Opacity/Alpha\r\n- \"TRN\": \"Non-Color\", # Transmission\r\n- \"SSS\": \"sRGB\", # Subsurface Color\r\n- \"EMISS\": \"sRGB\", # Emission Color\r\n- \"NRMRGH\": \"Non-Color\", # Added for merged map\r\n- \"FUZZ\": \"Non-Color\",\r\n- # Add other types like GLOSS, HEIGHT, etc. if needed\r\n-}\r\n-DEFAULT_COLOR_SPACE = \"sRGB\" # Fallback if map type not in the dictionary\r\n-\r\n-# Map types for which stats should be applied (if found in metadata and node exists)\r\n-# Reads stats from the 'image_stats_1k' section of metadata.json\r\n-APPLY_STATS_FOR_MAP_TYPES = [\"ROUGH\", \"DISP\", \"METAL\", \"AO\", \"REFL\"] # Add others if needed\r\n-\r\n-# --- END USER CONFIGURATION ---\r\n-\r\n-\r\n-# --- Helper Functions ---\r\n-\r\n-def encode_name_b64(name_str):\r\n- \"\"\"Encodes a string using URL-safe Base64 for node group names.\"\"\"\r\n- try:\r\n- # Ensure the input is a string\r\n- name_str = str(name_str)\r\n- return base64.urlsafe_b64encode(name_str.encode('utf-8')).decode('ascii')\r\n- except Exception as e:\r\n- print(f\" Error base64 encoding '{name_str}': {e}\")\r\n- return name_str # Fallback to original name on error\r\n-\r\n-def find_nodes_by_label(node_tree, label, node_type=None):\r\n- \"\"\"Finds ALL nodes in a node tree matching the label and optionally type.\"\"\"\r\n- if not node_tree:\r\n- return []\r\n- matching_nodes = []\r\n- for node in node_tree.nodes:\r\n- # Use node.label for labeled nodes, node.name for non-labeled (like Group Input/Output)\r\n- node_identifier = node.label if node.label else node.name\r\n- if node_identifier and node_identifier == label:\r\n- if node_type is None or node.bl_idname == node_type or node.type == node_type: # Check bl_idname and type for flexibility\r\n- matching_nodes.append(node)\r\n- return matching_nodes\r\n-\r\n-def add_tag_if_new(asset_data, tag_name):\r\n- \"\"\"Adds a tag to the asset data if it's not None/empty and doesn't already exist.\"\"\"\r\n- if not asset_data or not tag_name or not isinstance(tag_name, str):\r\n- return False\r\n- cleaned_tag_name = tag_name.strip()\r\n- if not cleaned_tag_name:\r\n- return False\r\n-\r\n- # Check if tag already exists (case-insensitive check might be better sometimes)\r\n- if cleaned_tag_name not in [t.name for t in asset_data.tags]:\r\n- try:\r\n- asset_data.tags.new(cleaned_tag_name)\r\n- print(f\" + Added Asset Tag: '{cleaned_tag_name}'\")\r\n- return True\r\n- except Exception as e:\r\n- print(f\" Error adding tag '{cleaned_tag_name}': {e}\")\r\n- return False\r\n- return False # Tag already existed\r\n-\r\n-def get_color_space(map_type):\r\n- \"\"\"Returns the appropriate Blender color space name for a given map type string.\"\"\"\r\n- # Handle potential numbered variants like COL-1, COL-2\r\n- base_map_type = map_type.split('-')[0]\r\n- return PBR_COLOR_SPACE_MAP.get(map_type.upper(), # Check full name first (e.g., NRMRGH)\r\n- PBR_COLOR_SPACE_MAP.get(base_map_type.upper(), DEFAULT_COLOR_SPACE)) # Fallback to base type\r\n-\r\n-def calculate_aspect_correction_factor(image_width, image_height, aspect_string):\r\n- \"\"\"\r\n- Calculates the UV X-axis scaling factor needed to correct distortion,\r\n- based on image dimensions and the aspect_ratio_change_string (\"EVEN\", \"Xnnn\", \"Ynnn\").\r\n- Mirrors the logic from the original POC script.\r\n- Returns 1.0 if dimensions are invalid or string is \"EVEN\" or invalid.\r\n- \"\"\"\r\n- if image_height <= 0 or image_width <= 0:\r\n- print(\" Warn: Invalid image dimensions for aspect ratio calculation. Returning 1.0.\")\r\n- return 1.0\r\n-\r\n- # Calculate the actual aspect ratio of the image file\r\n- current_aspect_ratio = image_width / image_height\r\n-\r\n- if not aspect_string or aspect_string.upper() == \"EVEN\":\r\n- # If scaling was even, the correction factor is just the image's aspect ratio\r\n- # to make UVs match the image proportions.\r\n- # print(f\" Aspect string is EVEN. Correction factor = current aspect ratio: {current_aspect_ratio:.4f}\")\r\n- return current_aspect_ratio\r\n-\r\n- # Handle non-uniform scaling cases (\"Xnnn\", \"Ynnn\")\r\n- # Use search instead of match to find anywhere in string (though unlikely needed based on format)\r\n- match = re.search(r\"([XY])(\\d+)\", aspect_string, re.IGNORECASE)\r\n- if not match:\r\n- print(f\" Warn: Invalid Scaling string format '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f} as fallback.\")\r\n- return current_aspect_ratio # Fallback to the image's own ratio\r\n-\r\n- axis = match.group(1).upper()\r\n- try:\r\n- amount = int(match.group(2))\r\n- if amount <= 0:\r\n- print(f\" Warn: Zero or negative Amount in Scaling string '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n- return current_aspect_ratio\r\n- except ValueError:\r\n- print(f\" Warn: Invalid Amount in Scaling string '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n- return current_aspect_ratio\r\n-\r\n- # Apply the non-uniform correction formula based on original script logic\r\n- scaling_factor_percent = amount / 100.0\r\n- correction_factor = current_aspect_ratio # Default\r\n-\r\n- try:\r\n- if axis == 'X':\r\n- if scaling_factor_percent == 0: raise ZeroDivisionError(\"X scaling factor is zero\")\r\n- # If image was stretched horizontally (X > 1), divide UV.x by factor\r\n- correction_factor = current_aspect_ratio / scaling_factor_percent\r\n- elif axis == 'Y':\r\n- # If image was stretched vertically (Y > 1), multiply UV.x by factor\r\n- correction_factor = current_aspect_ratio * scaling_factor_percent\r\n- # No 'else' needed as regex ensures X or Y\r\n-\r\n- except ZeroDivisionError as e:\r\n- print(f\" Warn: Division by zero during aspect factor calculation ({e}). Returning current ratio {current_aspect_ratio:.4f}.\")\r\n- return current_aspect_ratio\r\n- except Exception as e:\r\n- print(f\" Error calculating aspect correction factor: {e}. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n- return current_aspect_ratio\r\n-\r\n- # print(f\" Calculated aspect correction factor: {correction_factor:.4f} (from {image_width}x{image_height}, Scaling='{aspect_string}')\")\r\n- return correction_factor\r\n-\r\n-\r\n-def reconstruct_image_path_with_fallback(asset_dir_path, asset_name, map_type, resolution, primary_format=None):\r\n- \"\"\"\r\n- Constructs the expected image file path.\r\n- If primary_format is provided, tries that first.\r\n- Then falls back to common extensions if the path doesn't exist or primary_format was None.\r\n- Returns the found path as a string, or None if not found.\r\n- \"\"\"\r\n- if not all([asset_dir_path, asset_name, map_type, resolution]):\r\n- print(f\" !!! ERROR: Missing data for path reconstruction ({asset_name}/{map_type}/{resolution}).\")\r\n- return None\r\n-\r\n- found_path = None\r\n-\r\n- # 1. Try the primary format if provided\r\n- if primary_format:\r\n- try:\r\n- filename = IMAGE_FILENAME_PATTERN.format(\r\n- asset_name=asset_name,\r\n- map_type=map_type,\r\n- resolution=resolution,\r\n- format=primary_format.lower() # Ensure format is lowercase\r\n- )\r\n- primary_path = asset_dir_path / filename\r\n- if primary_path.is_file():\r\n- # print(f\" Found primary path: {str(primary_path)}\") # Verbose\r\n- return str(primary_path)\r\n- # else: print(f\" Primary path not found: {str(primary_path)}\") # Verbose\r\n- except KeyError as e:\r\n- print(f\" !!! ERROR: Missing key '{e}' in IMAGE_FILENAME_PATTERN. Cannot reconstruct path.\")\r\n- return None # Cannot proceed without valid pattern\r\n- except Exception as e:\r\n- print(f\" !!! ERROR reconstructing primary image path: {e}\")\r\n- # Continue to fallback\r\n-\r\n- # 2. Try fallback extensions\r\n- # print(f\" Trying fallback extensions for {map_type}/{resolution}...\") # Verbose\r\n- for ext in FALLBACK_IMAGE_EXTENSIONS:\r\n- # Skip if we already tried this extension as primary (and it failed)\r\n- if primary_format and ext.lower() == primary_format.lower():\r\n- continue\r\n- try:\r\n- fallback_filename = IMAGE_FILENAME_PATTERN.format(\r\n- asset_name=asset_name,\r\n- map_type=map_type,\r\n- resolution=resolution,\r\n- format=ext.lower()\r\n- )\r\n- fallback_path = asset_dir_path / fallback_filename\r\n- if fallback_path.is_file():\r\n- print(f\" Found fallback path: {str(fallback_path)}\")\r\n- return str(fallback_path) # Found it!\r\n- except KeyError:\r\n- # Should not happen if primary format worked, but handle defensively\r\n- print(f\" !!! ERROR: Missing key in IMAGE_FILENAME_PATTERN during fallback. Cannot reconstruct path.\")\r\n- return None\r\n- except Exception as e_fallback:\r\n- print(f\" !!! ERROR reconstructing fallback image path ({ext}): {e_fallback}\")\r\n- continue # Try next extension\r\n-\r\n- # If we get here, neither primary nor fallbacks worked\r\n- if primary_format:\r\n- print(f\" !!! ERROR: Could not find image file for {map_type}/{resolution} using primary format '{primary_format}' or fallbacks {FALLBACK_IMAGE_EXTENSIONS}.\")\r\n- else:\r\n- print(f\" !!! ERROR: Could not find image file for {map_type}/{resolution} using fallbacks {FALLBACK_IMAGE_EXTENSIONS}.\")\r\n- return None # Not found after all checks\r\n-\r\n-\r\n-# --- Manifest Functions ---\r\n-\r\n-def get_manifest_path(context):\r\n- \"\"\"Gets the expected path for the manifest JSON file.\"\"\"\r\n- if not context or not context.blend_data or not context.blend_data.filepath:\r\n- return None # Cannot determine path if blend file is not saved\r\n- blend_path = Path(context.blend_data.filepath)\r\n- manifest_filename = f\"{blend_path.stem}_manifest.json\"\r\n- return blend_path.parent / manifest_filename\r\n-\r\n-def load_manifest(context):\r\n- \"\"\"Loads the manifest data from the JSON file.\"\"\"\r\n- if not ENABLE_MANIFEST:\r\n- return {} # Manifest disabled\r\n-\r\n- manifest_path = get_manifest_path(context)\r\n- if not manifest_path:\r\n- print(\" Manifest Info: Blend file not saved. Cannot load manifest.\")\r\n- return {} # Cannot load without a path\r\n-\r\n- if not manifest_path.exists():\r\n- print(f\" Manifest Info: No manifest file found at '{manifest_path.name}'. Starting fresh.\")\r\n- return {} # No manifest file exists yet\r\n-\r\n- try:\r\n- with open(manifest_path, 'r', encoding='utf-8') as f:\r\n- data = json.load(f)\r\n- print(f\" Manifest Loaded from: {manifest_path.name}\")\r\n- # Basic validation (check if it's a dictionary)\r\n- if not isinstance(data, dict):\r\n- print(f\"!!! WARNING: Manifest file '{manifest_path.name}' has invalid format (not a dictionary). Starting fresh. !!!\")\r\n- return {}\r\n- return data\r\n- except json.JSONDecodeError:\r\n- print(f\"!!! WARNING: Manifest file '{manifest_path.name}' is corrupted. Starting fresh. !!!\")\r\n- return {}\r\n- except Exception as e:\r\n- print(f\"!!! ERROR: Could not load manifest file '{manifest_path.name}': {e} !!!\")\r\n- return {} # Treat as starting fresh on error\r\n-\r\n-def save_manifest(context, manifest_data):\r\n- \"\"\"Saves the manifest data to the JSON file.\"\"\"\r\n- if not ENABLE_MANIFEST or not manifest_data: # Don't save if disabled or empty\r\n- return False\r\n-\r\n- manifest_path = get_manifest_path(context)\r\n- if not manifest_path:\r\n- print(\" Manifest Error: Blend file not saved. Cannot save manifest.\")\r\n- return False\r\n-\r\n- try:\r\n- with open(manifest_path, 'w', encoding='utf-8') as f:\r\n- json.dump(manifest_data, f, indent=2, sort_keys=True) # Use indent and sort for readability\r\n- print(f\" Manifest Saved to: {manifest_path.name}\")\r\n- return True\r\n- except Exception as e:\r\n- print(f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\\n\"\r\n- f\"!!! Manifest save FAILED to '{manifest_path.name}': {e} !!!\\n\"\r\n- f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\")\r\n- return False\r\n-\r\n-def is_asset_processed(manifest_data, asset_name):\r\n- \"\"\"Checks if the entire asset (all its maps/resolutions) is marked as processed.\"\"\"\r\n- if not ENABLE_MANIFEST: return False\r\n- # Basic check if asset entry exists. Detailed check happens at map level.\r\n- return asset_name in manifest_data\r\n-\r\n-def is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- \"\"\"Checks if a specific map type and resolution for an asset is processed.\"\"\"\r\n- if not ENABLE_MANIFEST: return False\r\n- return resolution in manifest_data.get(asset_name, {}).get(map_type, [])\r\n-\r\n-def update_manifest(manifest_data, asset_name, map_type=None, resolution=None):\r\n- \"\"\"Updates the manifest dictionary in memory.\"\"\"\r\n- if not ENABLE_MANIFEST: return False\r\n-\r\n- # Ensure asset entry exists\r\n- if asset_name not in manifest_data:\r\n- manifest_data[asset_name] = {}\r\n-\r\n- # If map_type and resolution are provided, update the specific map entry\r\n- if map_type and resolution:\r\n- if map_type not in manifest_data[asset_name]:\r\n- manifest_data[asset_name][map_type] = []\r\n-\r\n- if resolution not in manifest_data[asset_name][map_type]:\r\n- manifest_data[asset_name][map_type].append(resolution)\r\n- manifest_data[asset_name][map_type].sort() # Keep sorted\r\n- return True # Indicate that a change was made\r\n- return False # No change made to this specific map/res\r\n-\r\n-\r\n-# --- Core Logic ---\r\n-\r\n-def process_library(context, asset_library_root_override=None): # Add override parameter\r\n- global ENABLE_MANIFEST # Declare intent to modify global if needed\r\n- global PROCESSED_ASSET_LIBRARY_ROOT # Allow modification of global\r\n- \"\"\"Scans the library, reads metadata, creates/updates node groups.\"\"\"\r\n- start_time = time.time()\r\n- print(f\"\\n--- Starting Node Group Processing ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---\")\r\n- print(f\" DEBUG: Received asset_library_root_override: {asset_library_root_override}\") # DEBUG LOG (Indented)\r\n-\r\n- # --- Determine Asset Library Root ---\r\n- if asset_library_root_override:\r\n- PROCESSED_ASSET_LIBRARY_ROOT = asset_library_root_override\r\n- print(f\"Using asset library root from argument: '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n- elif not PROCESSED_ASSET_LIBRARY_ROOT:\r\n- print(\"!!! ERROR: Processed asset library root not set in script and not provided via argument.\")\r\n- print(\"--- Script aborted. ---\")\r\n- return False\r\n- print(f\" DEBUG: Using final PROCESSED_ASSET_LIBRARY_ROOT: {PROCESSED_ASSET_LIBRARY_ROOT}\") # DEBUG LOG (Indented)\r\n-\r\n- # --- Pre-run Checks ---\r\n- print(\"Performing pre-run checks...\")\r\n- valid_setup = True\r\n- # 1. Check Library Root Path\r\n- root_path = Path(PROCESSED_ASSET_LIBRARY_ROOT)\r\n- if not root_path.is_dir():\r\n- print(f\"!!! ERROR: Processed asset library root directory not found or not a directory:\")\r\n- print(f\"!!! '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n- valid_setup = False\r\n- else:\r\n- print(f\" Asset Library Root: '{root_path}'\")\r\n- print(f\" DEBUG: Checking for templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\") # DEBUG LOG (Indented)\r\n-\r\n- # 2. Check Templates\r\n- template_parent = bpy.data.node_groups.get(PARENT_TEMPLATE_NAME)\r\n- template_child = bpy.data.node_groups.get(CHILD_TEMPLATE_NAME)\r\n- if not template_parent:\r\n- print(f\"!!! ERROR: Parent template node group '{PARENT_TEMPLATE_NAME}' not found in this Blender file.\")\r\n- valid_setup = False\r\n- if not template_child:\r\n- print(f\"!!! ERROR: Child template node group '{CHILD_TEMPLATE_NAME}' not found in this Blender file.\")\r\n- valid_setup = False\r\n- if template_parent and template_child:\r\n- print(f\" Found Templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\")\r\n- print(f\" DEBUG: Template Parent Found: {template_parent is not None}\") # DEBUG LOG (Indented)\r\n- print(f\" DEBUG: Template Child Found: {template_child is not None}\") # DEBUG LOG (Indented)\r\n-\r\n- # 3. Check Blend File Saved (if manifest enabled)\r\n- if ENABLE_MANIFEST and not context.blend_data.filepath:\r\n- print(f\"!!! WARNING: Manifest is enabled, but the current Blender file is not saved.\")\r\n- print(f\"!!! Manifest cannot be loaded or saved. Processing will continue without manifest checks.\")\r\n- ENABLE_MANIFEST = False # Disable manifest for this run\r\n-\r\n- if not valid_setup:\r\n- print(\"\\n--- Script aborted due to configuration errors. Please fix the issues above. ---\")\r\n- return False\r\n- print(\"Pre-run checks passed.\")\r\n- # --- End Pre-run Checks ---\r\n-\r\n- manifest_data = load_manifest(context)\r\n- manifest_needs_saving = False\r\n-\r\n- # --- Initialize Counters ---\r\n- metadata_files_found = 0\r\n- assets_processed = 0\r\n- assets_skipped_manifest = 0\r\n- parent_groups_created = 0\r\n- parent_groups_updated = 0\r\n- child_groups_created = 0\r\n- child_groups_updated = 0\r\n- images_loaded = 0\r\n- images_assigned = 0\r\n- maps_processed = 0\r\n- maps_skipped_manifest = 0\r\n- errors_encountered = 0\r\n- previews_set = 0\r\n- highest_res_set = 0\r\n- aspect_ratio_set = 0\r\n- # --- End Counters ---\r\n-\r\n- print(f\"\\nScanning for metadata files in '{root_path}'...\")\r\n-\r\n- # --- Scan for metadata.json ---\r\n- # Scan one level deeper for supplier folders (e.g., Poliigon)\r\n- # Then scan within each supplier for asset folders containing metadata.json\r\n- metadata_paths = []\r\n- for supplier_dir in root_path.iterdir():\r\n- if supplier_dir.is_dir():\r\n- # Now look for asset folders inside the supplier directory\r\n- for asset_dir in supplier_dir.iterdir():\r\n- if asset_dir.is_dir():\r\n- metadata_file = asset_dir / 'metadata.json'\r\n- if metadata_file.is_file():\r\n- metadata_paths.append(metadata_file)\r\n-\r\n- metadata_files_found = len(metadata_paths)\r\n- print(f\"Found {metadata_files_found} metadata.json files.\")\r\n- print(f\" DEBUG: Metadata paths found: {metadata_paths}\") # DEBUG LOG (Indented)\r\n-\r\n- if metadata_files_found == 0:\r\n- print(\"No metadata files found. Nothing to process.\")\r\n- print(\"--- Script Finished ---\")\r\n- return True # No work needed is considered success\r\n-\r\n- # --- Process Each Metadata File ---\r\n- for metadata_path in metadata_paths:\r\n- asset_dir_path = metadata_path.parent # Get the directory containing the metadata file\r\n- print(f\"\\n--- Processing Metadata: {metadata_path.relative_to(root_path)} ---\")\r\n- print(f\" DEBUG: Processing file: {metadata_path}\") # DEBUG LOG (Indented)\r\n- try:\r\n- with open(metadata_path, 'r', encoding='utf-8') as f:\r\n- metadata = json.load(f)\r\n-\r\n- # --- Extract Key Info ---\r\n- asset_name = metadata.get(\"asset_name\")\r\n- supplier_name = metadata.get(\"supplier_name\")\r\n- archetype = metadata.get(\"archetype\")\r\n- # Get map info from the correct keys\r\n- processed_resolutions = metadata.get(\"processed_map_resolutions\", {}) # Default to empty dict\r\n- merged_resolutions = metadata.get(\"merged_map_resolutions\", {}) # Get merged maps too\r\n- map_details = metadata.get(\"map_details\", {}) # Default to empty dict\r\n- image_stats_1k = metadata.get(\"image_stats_1k\") # Dict: {map_type: {stats}}\r\n- aspect_string = metadata.get(\"aspect_ratio_change_string\")\r\n-\r\n- # Combine processed and merged maps for iteration\r\n- all_map_resolutions = {**processed_resolutions, **merged_resolutions}\r\n-\r\n- # Validate essential data\r\n- if not asset_name:\r\n- print(f\" !!! ERROR: Metadata file is missing 'asset_name'. Skipping.\")\r\n- errors_encountered += 1\r\n- continue\r\n- if not all_map_resolutions:\r\n- print(f\" !!! ERROR: Metadata file has no 'processed_map_resolutions' or 'merged_map_resolutions'. Skipping asset '{asset_name}'.\")\r\n- errors_encountered += 1\r\n- continue\r\n- # map_details check remains a warning as merged maps won't be in it\r\n- print(f\" DEBUG: Valid metadata loaded for asset: {asset_name}\") # DEBUG LOG (Indented)\r\n-\r\n- print(f\" Asset Name: {asset_name}\")\r\n-\r\n- # --- Determine Highest Resolution ---\r\n- highest_resolution_value = 0.0\r\n- highest_resolution_str = \"Unknown\"\r\n- all_resolutions_present = set()\r\n- if all_map_resolutions: # Check combined dict\r\n- for res_list in all_map_resolutions.values():\r\n- if isinstance(res_list, list):\r\n- all_resolutions_present.update(res_list)\r\n-\r\n- if all_resolutions_present:\r\n- for res_str in RESOLUTION_ORDER_DESC:\r\n- if res_str in all_resolutions_present:\r\n- highest_resolution_value = RESOLUTION_VALUE_MAP.get(res_str, 0.0)\r\n- highest_resolution_str = res_str\r\n- if highest_resolution_value > 0.0:\r\n- break # Found the highest valid resolution\r\n-\r\n- print(f\" Highest resolution found: {highest_resolution_str} (Value: {highest_resolution_value})\")\r\n-\r\n- # --- Load Reference Image for Aspect Ratio ---\r\n- ref_image_path = None\r\n- ref_image_width = 0\r\n- ref_image_height = 0\r\n- ref_image_loaded = False\r\n- # Use combined resolutions dict to find reference map\r\n- for ref_map_type in REFERENCE_MAP_TYPES:\r\n- if ref_map_type in all_map_resolutions:\r\n- available_resolutions = all_map_resolutions[ref_map_type]\r\n- lowest_res = None\r\n- for res_pref in REFERENCE_RESOLUTION_ORDER:\r\n- if res_pref in available_resolutions:\r\n- lowest_res = res_pref\r\n- break\r\n- if lowest_res:\r\n- # Get format from map_details if available, otherwise None\r\n- ref_map_details = map_details.get(ref_map_type, {})\r\n- ref_format = ref_map_details.get(\"output_format\")\r\n- ref_image_path = reconstruct_image_path_with_fallback(\r\n- asset_dir_path=asset_dir_path,\r\n- asset_name=asset_name,\r\n- map_type=ref_map_type,\r\n- resolution=lowest_res,\r\n- primary_format=ref_format # Pass None if not in map_details\r\n- )\r\n- if ref_image_path:\r\n- break # Found a suitable reference image path\r\n-\r\n- if ref_image_path:\r\n- print(f\" Loading reference image for aspect ratio: {Path(ref_image_path).name}\")\r\n- try:\r\n- # Load image temporarily\r\n- ref_img = bpy.data.images.load(ref_image_path, check_existing=True)\r\n- if ref_img:\r\n- ref_image_width = ref_img.size[0]\r\n- ref_image_height = ref_img.size[1]\r\n- ref_image_loaded = True\r\n- print(f\" Reference image dimensions: {ref_image_width}x{ref_image_height}\")\r\n- # Remove the temporary image datablock to save memory\r\n- bpy.data.images.remove(ref_img)\r\n- else:\r\n- print(f\" !!! ERROR: Failed loading reference image via bpy.data.images.load: {ref_image_path}\")\r\n- except Exception as e_ref_load:\r\n- print(f\" !!! ERROR loading reference image '{ref_image_path}': {e_ref_load}\")\r\n- else:\r\n- print(f\" !!! WARNING: Could not find suitable reference image ({REFERENCE_MAP_TYPES} at {REFERENCE_RESOLUTION_ORDER}) for aspect ratio calculation.\")\r\n-\r\n-\r\n- # --- Manifest Check (Asset Level - Basic) ---\r\n- if ENABLE_MANIFEST and is_asset_processed(manifest_data, asset_name):\r\n- # Perform a quick check if *any* map needs processing for this asset\r\n- needs_processing = False\r\n- for map_type, resolutions in all_map_resolutions.items(): # Check combined maps\r\n- for resolution in resolutions:\r\n- if not is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- needs_processing = True\r\n- break\r\n- if needs_processing:\r\n- break\r\n- if not needs_processing:\r\n- print(f\" Skipping asset '{asset_name}' (already fully processed according to manifest).\")\r\n- assets_skipped_manifest += 1\r\n- continue # Skip to next metadata file\r\n-\r\n- # --- Parent Group Handling ---\r\n- target_parent_name = f\"PBRSET_{asset_name}\"\r\n- parent_group = bpy.data.node_groups.get(target_parent_name)\r\n- is_new_parent = False\r\n-\r\n- if parent_group is None:\r\n- print(f\" Creating new parent group: '{target_parent_name}'\")\r\n- print(f\" DEBUG: Copying parent template '{PARENT_TEMPLATE_NAME}'\") # DEBUG LOG (Indented)\r\n- parent_group = template_parent.copy()\r\n- if not parent_group:\r\n- print(f\" !!! ERROR: Failed to copy parent template '{PARENT_TEMPLATE_NAME}'. Skipping asset '{asset_name}'.\")\r\n- errors_encountered += 1\r\n- continue\r\n- parent_group.name = target_parent_name\r\n- parent_groups_created += 1\r\n- is_new_parent = True\r\n- else:\r\n- print(f\" Updating existing parent group: '{target_parent_name}'\")\r\n- print(f\" DEBUG: Found existing parent group.\") # DEBUG LOG (Indented)\r\n- parent_groups_updated += 1\r\n-\r\n- # Ensure marked as asset\r\n- if not parent_group.asset_data:\r\n- try:\r\n- parent_group.asset_mark()\r\n- print(f\" Marked '{parent_group.name}' as asset.\")\r\n- except Exception as e_mark:\r\n- print(f\" !!! ERROR: Failed to mark group '{parent_group.name}' as asset: {e_mark}\")\r\n- # Continue processing other parts if possible\r\n-\r\n- # Apply Asset Tags\r\n- if parent_group.asset_data:\r\n- if supplier_name:\r\n- add_tag_if_new(parent_group.asset_data, supplier_name)\r\n- if archetype:\r\n- add_tag_if_new(parent_group.asset_data, archetype)\r\n- # Add other tags if needed\r\n- # else: print(f\" Warn: Cannot apply tags, asset_data not available for '{parent_group.name}'.\") # Optional warning\r\n-\r\n- # Apply Aspect Ratio Correction\r\n- aspect_nodes = find_nodes_by_label(parent_group, ASPECT_RATIO_NODE_LABEL, 'ShaderNodeValue')\r\n- if aspect_nodes:\r\n- aspect_node = aspect_nodes[0]\r\n- correction_factor = 1.0 # Default if ref image fails\r\n- if ref_image_loaded:\r\n- correction_factor = calculate_aspect_correction_factor(ref_image_width, ref_image_height, aspect_string)\r\n- print(f\" Calculated aspect correction factor: {correction_factor:.4f}\")\r\n- else:\r\n- print(f\" !!! WARNING: Using default aspect ratio correction (1.0) due to missing reference image.\")\r\n-\r\n- # Check if update is needed\r\n- current_val = aspect_node.outputs[0].default_value\r\n- if abs(current_val - correction_factor) > 0.0001:\r\n- aspect_node.outputs[0].default_value = correction_factor\r\n- print(f\" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f} (was {current_val:.4f})\")\r\n- aspect_ratio_set += 1\r\n- # else: print(f\" Warn: Aspect ratio node '{ASPECT_RATIO_NODE_LABEL}' not found in parent group.\") # Optional\r\n-\r\n- # Apply Highest Resolution Value\r\n- hr_nodes = find_nodes_by_label(parent_group, HIGHEST_RESOLUTION_NODE_LABEL, 'ShaderNodeValue')\r\n- if hr_nodes:\r\n- hr_node = hr_nodes[0]\r\n- current_hr_val = hr_node.outputs[0].default_value\r\n- if highest_resolution_value > 0.0 and abs(current_hr_val - highest_resolution_value) > 0.001:\r\n- hr_node.outputs[0].default_value = highest_resolution_value\r\n- print(f\" Set '{HIGHEST_RESOLUTION_NODE_LABEL}' value to {highest_resolution_value} ({highest_resolution_str}) (was {current_hr_val:.1f})\")\r\n- highest_res_set += 1 # Count successful sets\r\n- # else: print(f\" Warn: Highest resolution node '{HIGHEST_RESOLUTION_NODE_LABEL}' not found in parent group.\") # Optional\r\n-\r\n-\r\n- # Apply Stats (using image_stats_1k)\r\n- if image_stats_1k and isinstance(image_stats_1k, dict):\r\n- for map_type_to_stat in APPLY_STATS_FOR_MAP_TYPES:\r\n- if map_type_to_stat in image_stats_1k:\r\n- # Find the stats node in the parent group\r\n- stats_node_label = f\"{STATS_NODE_PREFIX}{map_type_to_stat}\"\r\n- stats_nodes = find_nodes_by_label(parent_group, stats_node_label, 'ShaderNodeCombineXYZ')\r\n- if stats_nodes:\r\n- stats_node = stats_nodes[0]\r\n- stats = image_stats_1k[map_type_to_stat] # Get stats dict for this map type\r\n-\r\n- if stats and isinstance(stats, dict):\r\n- # Handle potential list format for RGB stats (use first value) or direct float\r\n- def get_stat_value(stat_val):\r\n- if isinstance(stat_val, list):\r\n- return stat_val[0] if stat_val else None\r\n- return stat_val\r\n-\r\n- min_val = get_stat_value(stats.get(\"min\"))\r\n- max_val = get_stat_value(stats.get(\"max\"))\r\n- mean_val = get_stat_value(stats.get(\"mean\")) # Often stored as 'mean' or 'avg'\r\n-\r\n- updated_stat = False\r\n- # Check inputs exist before assigning\r\n- input_x = stats_node.inputs.get(\"X\")\r\n- input_y = stats_node.inputs.get(\"Y\")\r\n- input_z = stats_node.inputs.get(\"Z\")\r\n-\r\n- if input_x and min_val is not None and abs(input_x.default_value - min_val) > 0.0001:\r\n- input_x.default_value = min_val\r\n- updated_stat = True\r\n- if input_y and max_val is not None and abs(input_y.default_value - max_val) > 0.0001:\r\n- input_y.default_value = max_val\r\n- updated_stat = True\r\n- if input_z and mean_val is not None and abs(input_z.default_value - mean_val) > 0.0001:\r\n- input_z.default_value = mean_val\r\n- updated_stat = True\r\n-\r\n- if updated_stat:\r\n- print(f\" Set stats in '{stats_node_label}': Min={min_val:.4f}, Max={max_val:.4f}, Mean={mean_val:.4f}\")\r\n- # else: print(f\" Info: No valid 'stats' dictionary found for map type '{map_type_to_stat}' in image_stats_1k.\") # Optional\r\n- # else: print(f\" Warn: Stats node '{stats_node_label}' not found in parent group.\") # Optional\r\n- # else: print(f\" Info: Map type '{map_type_to_stat}' not present in image_stats_1k for stats application.\") # Optional\r\n- # else: print(f\" Warn: 'image_stats_1k' missing or invalid in metadata.\") # Optional\r\n-\r\n- # --- Set Asset Preview (only for new parent groups) ---\r\n- # Use the reference image path found earlier if available\r\n- if is_new_parent and parent_group.asset_data:\r\n- if ref_image_loaded and ref_image_path: # Check if ref image was successfully loaded earlier\r\n- print(f\" Attempting to set preview from reference image: {Path(ref_image_path).name}\")\r\n- try:\r\n- # Ensure the ID (node group) is the active one for the operator context\r\n- with context.temp_override(id=parent_group):\r\n- bpy.ops.ed.lib_id_load_custom_preview(filepath=ref_image_path)\r\n- print(f\" Successfully set custom preview.\")\r\n- previews_set += 1\r\n- except Exception as e_preview:\r\n- print(f\" !!! ERROR setting custom preview: {e_preview}\")\r\n- errors_encountered += 1\r\n- else:\r\n- print(f\" Info: Could not set preview for '{asset_name}' as reference image was not found or loaded.\")\r\n-\r\n-\r\n- # --- Child Group Handling ---\r\n- # Iterate through the COMBINED map types\r\n- print(f\" DEBUG: Starting child group loop for asset '{asset_name}'. Map types: {list(all_map_resolutions.keys())}\") # DEBUG LOG (Indented)\r\n- for map_type, resolutions in all_map_resolutions.items():\r\n- print(f\" Processing Map Type: {map_type}\")\r\n-\r\n- # Determine if this is a merged map (not in map_details)\r\n- is_merged_map = map_type not in map_details\r\n-\r\n- # Get details for this map type if available\r\n- current_map_details = map_details.get(map_type, {})\r\n- # For merged maps, primary_format will be None\r\n- output_format = current_map_details.get(\"output_format\")\r\n-\r\n- if not output_format and not is_merged_map:\r\n- # This case should ideally not happen if metadata is well-formed\r\n- # but handle defensively for processed maps.\r\n- print(f\" !!! WARNING: Missing 'output_format' in map_details for processed map '{map_type}'. Path reconstruction might fail.\")\r\n- # We will rely solely on fallback for this map type\r\n-\r\n- # Find placeholder node in parent\r\n- holder_nodes = find_nodes_by_label(parent_group, map_type, 'ShaderNodeGroup')\r\n- if not holder_nodes:\r\n- print(f\" !!! WARNING: No placeholder node labeled '{map_type}' found in parent group '{parent_group.name}'. Skipping this map type.\")\r\n- continue\r\n- holder_node = holder_nodes[0] # Assume first is correct\r\n- print(f\" DEBUG: Found placeholder node '{holder_node.label}' for map type '{map_type}'.\") # DEBUG LOG (Indented)\r\n-\r\n- # Determine child group name (LOGICAL and ENCODED)\r\n- logical_child_name = f\"{asset_name}_{map_type}\"\r\n- target_child_name_b64 = encode_name_b64(logical_child_name) # Use Base64 name\r\n-\r\n- child_group = bpy.data.node_groups.get(target_child_name_b64) # Find using encoded name\r\n- is_new_child = False\r\n-\r\n- if child_group is None:\r\n- print(f\" DEBUG: Child group '{target_child_name_b64}' not found. Creating new one.\") # DEBUG LOG (Indented)\r\n- # print(f\" Creating new child group: '{target_child_name_b64}' (logical: '{logical_child_name}')\") # Verbose\r\n- child_group = template_child.copy()\r\n- if not child_group:\r\n- print(f\" !!! ERROR: Failed to copy child template '{CHILD_TEMPLATE_NAME}'. Skipping map type '{map_type}'.\")\r\n- errors_encountered += 1\r\n- continue\r\n- child_group.name = target_child_name_b64 # Set encoded name\r\n- child_groups_created += 1\r\n- is_new_child = True\r\n- else:\r\n- print(f\" DEBUG: Found existing child group '{target_child_name_b64}'.\") # DEBUG LOG (Indented)\r\n- # print(f\" Updating existing child group: '{target_child_name_b64}'\") # Verbose\r\n- child_groups_updated += 1\r\n-\r\n- # Assign child group to placeholder if needed\r\n- if holder_node.node_tree != child_group:\r\n- try:\r\n- holder_node.node_tree = child_group\r\n- print(f\" Assigned child group '{child_group.name}' to placeholder '{holder_node.label}'.\")\r\n- except TypeError as e_assign: # Catch potential type errors if placeholder isn't a group node\r\n- print(f\" !!! ERROR: Could not assign child group to placeholder '{holder_node.label}'. Is it a Group Node? Error: {e_assign}\")\r\n- continue # Skip this map type if assignment fails\r\n-\r\n- # Link placeholder output to parent output socket\r\n- try:\r\n- # Find parent's output node\r\n- group_output_node = next((n for n in parent_group.nodes if n.type == 'GROUP_OUTPUT'), None)\r\n- if group_output_node:\r\n- # Get the specific output socket on the placeholder (usually index 0 or named 'Color'/'Value')\r\n- source_socket = holder_node.outputs.get(\"Color\") or holder_node.outputs.get(\"Value\") or holder_node.outputs[0]\r\n- # Get the specific input socket on the parent output node (matching map_type)\r\n- target_socket = group_output_node.inputs.get(map_type)\r\n-\r\n- if source_socket and target_socket:\r\n- # Check if link already exists\r\n- link_exists = any(link.from_socket == source_socket and link.to_socket == target_socket for link in parent_group.links)\r\n- if not link_exists:\r\n- parent_group.links.new(source_socket, target_socket)\r\n- print(f\" Linked '{holder_node.label}' output to parent output socket '{map_type}'.\")\r\n- # else: # Optional warnings\r\n- # if not source_socket: print(f\" Warn: Could not find suitable output socket on placeholder '{holder_node.label}'.\")\r\n- # if not target_socket: print(f\" Warn: Could not find input socket '{map_type}' on parent output node.\")\r\n- # else: print(f\" Warn: Parent group '{parent_group.name}' has no Group Output node.\")\r\n-\r\n- except Exception as e_link:\r\n- print(f\" !!! ERROR linking sockets for '{map_type}': {e_link}\")\r\n-\r\n- # Ensure parent output socket type is Color (if it exists)\r\n- try:\r\n- # Use the interface API for modern Blender versions\r\n- item = parent_group.interface.items_tree.get(map_type)\r\n- if item and item.item_type == 'SOCKET' and item.in_out == 'OUTPUT':\r\n- # Common useful types: 'NodeSocketColor', 'NodeSocketVector', 'NodeSocketFloat'\r\n- # Defaulting to Color seems reasonable for most PBR outputs\r\n- if item.socket_type != 'NodeSocketColor':\r\n- item.socket_type = 'NodeSocketColor'\r\n- # print(f\" Set parent output socket '{map_type}' type to Color.\") # Optional info\r\n- except Exception as e_sock_type:\r\n- print(f\" Warn: Could not verify/set socket type for '{map_type}': {e_sock_type}\")\r\n-\r\n-\r\n- # --- Image Node Handling (Inside Child Group) ---\r\n- if not isinstance(resolutions, list):\r\n- print(f\" !!! ERROR: Invalid format for resolutions list for map type '{map_type}'. Skipping.\")\r\n- continue\r\n-\r\n- for resolution in resolutions:\r\n- # --- Manifest Check (Map/Resolution Level) ---\r\n- if ENABLE_MANIFEST and is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- # print(f\" Skipping {resolution} (Manifest)\") # Verbose\r\n- maps_skipped_manifest += 1\r\n- continue\r\n- print(f\" DEBUG: Processing map '{map_type}' resolution '{resolution}'. Manifest skip check passed.\") # DEBUG LOG (Indented)\r\n-\r\n- print(f\" Processing Resolution: {resolution}\")\r\n-\r\n- # Reconstruct the image path using fallback logic\r\n- # Pass output_format (which might be None for merged maps)\r\n- image_path_str = reconstruct_image_path_with_fallback(\r\n- asset_dir_path=asset_dir_path,\r\n- asset_name=asset_name,\r\n- map_type=map_type,\r\n- resolution=resolution,\r\n- primary_format=output_format\r\n- )\r\n- print(f\" DEBUG: Reconstructed image path for {map_type}/{resolution}: {image_path_str}\") # DEBUG LOG (Indented)\r\n-\r\n- if not image_path_str:\r\n- # Error already printed by reconstruct function\r\n- errors_encountered += 1\r\n- continue # Skip this resolution if path not found\r\n-\r\n- # Find image texture node within the CHILD group (labeled by resolution, e.g., \"4K\")\r\n- image_nodes = find_nodes_by_label(child_group, resolution, 'ShaderNodeTexImage')\r\n- if not image_nodes:\r\n- print(f\" !!! WARNING: No Image Texture node labeled '{resolution}' found in child group '{child_group.name}'. Cannot assign image.\")\r\n- continue # Skip this resolution if node not found\r\n- print(f\" DEBUG: Found {len(image_nodes)} image node(s) labeled '{resolution}' in child group '{child_group.name}'.\") # DEBUG LOG (Indented)\r\n-\r\n- # --- Load Image ---\r\n- img = None\r\n- image_load_failed = False\r\n- try:\r\n- image_path = Path(image_path_str) # Path object created from already found path string\r\n- # Use check_existing=True to reuse existing datablocks if path matches\r\n- img = bpy.data.images.load(str(image_path), check_existing=True)\r\n- if not img:\r\n- print(f\" !!! ERROR: Failed loading image via bpy.data.images.load: {image_path_str}\")\r\n- image_load_failed = True\r\n- else:\r\n- # Only count as loaded if bpy.data.images.load succeeded\r\n- # Check if it's newly loaded or reused\r\n- is_newly_loaded = img.library is None # Newly loaded images don't have a library initially\r\n- if is_newly_loaded: images_loaded += 1\r\n-\r\n- except RuntimeError as e_runtime_load:\r\n- # Catch specific Blender runtime errors (e.g., unsupported format)\r\n- print(f\" !!! ERROR loading image '{image_path_str}': {e_runtime_load}\")\r\n- image_load_failed = True\r\n- except Exception as e_gen_load:\r\n- print(f\" !!! UNEXPECTED ERROR loading image '{image_path_str}': {e_gen_load}\")\r\n- image_load_failed = True\r\n- errors_encountered += 1\r\n-\r\n- # --- Assign Image & Set Color Space ---\r\n- if not image_load_failed and img:\r\n- assigned_count_this_res = 0\r\n- for image_node in image_nodes:\r\n- if image_node.image != img:\r\n- image_node.image = img\r\n- assigned_count_this_res += 1\r\n-\r\n- if assigned_count_this_res > 0:\r\n- images_assigned += assigned_count_this_res\r\n- print(f\" Assigned image '{img.name}' to {assigned_count_this_res} node(s).\")\r\n-\r\n- # Set Color Space\r\n- correct_color_space = get_color_space(map_type)\r\n- try:\r\n- if img.colorspace_settings.name != correct_color_space:\r\n- img.colorspace_settings.name = correct_color_space\r\n- print(f\" Set '{img.name}' color space -> {correct_color_space}\")\r\n- except TypeError as e_cs: # Handle case where colorspace name is invalid\r\n- print(f\" !!! WARNING: Could not set color space '{correct_color_space}' for image '{img.name}'. Is the color space available in Blender? Error: {e_cs}\")\r\n- except Exception as e_cs_gen:\r\n- print(f\" !!! ERROR setting color space for image '{img.name}': {e_cs_gen}\")\r\n-\r\n-\r\n- # --- Update Manifest (Map/Resolution Level) ---\r\n- if update_manifest(manifest_data, asset_name, map_type, resolution):\r\n- manifest_needs_saving = True\r\n- # print(f\" Marked {map_type}/{resolution} processed in manifest.\") # Verbose\r\n- maps_processed += 1\r\n-\r\n- else:\r\n- # Increment error count if loading failed\r\n- if image_load_failed: errors_encountered += 1\r\n-\r\n- # --- End Resolution Loop ---\r\n- # --- End Map Type Loop ---\r\n-\r\n- assets_processed += 1\r\n-\r\n- except FileNotFoundError:\r\n- print(f\" !!! ERROR: Metadata file not found (should not happen if scan worked): {metadata_path}\")\r\n- errors_encountered += 1\r\n- except json.JSONDecodeError:\r\n- print(f\" !!! ERROR: Invalid JSON in metadata file: {metadata_path}\")\r\n- errors_encountered += 1\r\n- except Exception as e_main_loop:\r\n- print(f\" !!! UNEXPECTED ERROR processing asset from {metadata_path}: {e_main_loop}\")\r\n- import traceback\r\n- traceback.print_exc() # Print detailed traceback for debugging\r\n- errors_encountered += 1\r\n- # Continue to the next asset\r\n-\r\n- # --- End Metadata File Loop ---\r\n-\r\n- # --- Final Manifest Save ---\r\n- if ENABLE_MANIFEST and manifest_needs_saving:\r\n- print(\"\\nAttempting final manifest save...\")\r\n- save_manifest(context, manifest_data)\r\n- elif ENABLE_MANIFEST:\r\n- print(\"\\nManifest is enabled, but no changes require saving.\")\r\n- # --- End Final Manifest Save ---\r\n-\r\n- # --- Final Summary ---\r\n- end_time = time.time()\r\n- duration = end_time - start_time\r\n- print(\"\\n--- Script Run Finished ---\")\r\n- print(f\"Duration: {duration:.2f} seconds\")\r\n- print(f\"Metadata Files Found: {metadata_files_found}\")\r\n- print(f\"Assets Processed/Attempted: {assets_processed}\")\r\n- if ENABLE_MANIFEST:\r\n- print(f\"Assets Skipped (Manifest): {assets_skipped_manifest}\")\r\n- print(f\"Maps Skipped (Manifest): {maps_skipped_manifest}\")\r\n- print(f\"Parent Groups Created: {parent_groups_created}\")\r\n- print(f\"Parent Groups Updated: {parent_groups_updated}\")\r\n- print(f\"Child Groups Created: {child_groups_created}\")\r\n- print(f\"Child Groups Updated: {child_groups_updated}\")\r\n- print(f\"Images Loaded: {images_loaded}\")\r\n- print(f\"Image Nodes Assigned: {images_assigned}\")\r\n- print(f\"Individual Maps Processed: {maps_processed}\")\r\n- print(f\"Asset Previews Set: {previews_set}\")\r\n- print(f\"Highest Resolution Nodes Set: {highest_res_set}\")\r\n- print(f\"Aspect Ratio Nodes Set: {aspect_ratio_set}\") # Added counter\r\n- if errors_encountered > 0:\r\n- print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n- print(\"---------------------------\")\r\n-\r\n- # --- Explicit Save ---\r\n- print(f\" DEBUG: Attempting explicit save for file: {bpy.data.filepath}\") # DEBUG LOG (Indented)\r\n- try:\r\n- bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)\r\n- print(\"\\n--- Explicitly saved the .blend file. ---\")\r\n- except Exception as e_save:\r\n- print(f\"\\n!!! ERROR explicitly saving .blend file: {e_save} !!!\")\r\n- errors_encountered += 1 # Count save errors\r\n-\r\n- return True\r\n-\r\n-\r\n-# --- Execution Block ---\r\n-\r\n-if __name__ == \"__main__\":\r\n- # Ensure we are running within Blender\r\n- try:\r\n- import bpy\r\n- import base64 # Ensure base64 is imported here too if needed globally\r\n- import sys\r\n- except ImportError:\r\n- print(\"!!! ERROR: This script must be run from within Blender. !!!\")\r\n- else:\r\n- # --- Argument Parsing for Asset Library Root ---\r\n- asset_root_arg = None\r\n- try:\r\n- # Blender arguments passed after '--' appear in sys.argv\r\n- if \"--\" in sys.argv:\r\n- args_after_dash = sys.argv[sys.argv.index(\"--\") + 1:]\r\n- if len(args_after_dash) >= 1:\r\n- asset_root_arg = args_after_dash[0]\r\n- print(f\"Found asset library root argument: {asset_root_arg}\")\r\n- else:\r\n- print(\"Info: '--' found but no arguments after it.\")\r\n- # else: print(\"Info: No '--' found in arguments.\") # Optional debug\r\n- except Exception as e:\r\n- print(f\"Error parsing command line arguments: {e}\")\r\n- # --- End Argument Parsing ---\r\n-\r\n- process_library(bpy.context, asset_library_root_override=asset_root_arg)\r\n" }, { "date": 1745309684670, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -97,8 +97,11 @@\n # Map types for which stats should be applied (if found in metadata and node exists)\r\n # Reads stats from the 'image_stats_1k' section of metadata.json\r\n APPLY_STATS_FOR_MAP_TYPES = [\"ROUGH\", \"DISP\", \"METAL\", \"AO\", \"REFL\"] # Add others if needed\r\n \r\n+# Categories for which full nodegroup generation should occur\r\n+CATEGORIES_FOR_NODEGROUP_GENERATION = [\"Surface\", \"Decal\"]\r\n+\r\n # --- END USER CONFIGURATION ---\r\n \r\n \r\n # --- Helper Functions ---\r\n@@ -639,9 +642,9 @@\n # Add other tags if needed\r\n # else: print(f\" Warn: Cannot apply tags, asset_data not available for '{parent_group.name}'.\") # Optional warning\r\n \r\n # <<< ADDED: Conditional skip based on category >>>\r\n- if category not in [\"Surface\", \"Decal\"]:\r\n+ if category not in CATEGORIES_FOR_NODEGROUP_GENERATION: # <<< MODIFIED: Use config variable\r\n print(f\" Skipping nodegroup content generation for asset '{asset_name}' (Category: '{category}'). Tag added.\")\r\n assets_processed += 1 # Still count as processed for summary, even if skipped\r\n continue # Skip the rest of the processing for this asset\r\n \r\n" }, { "date": 1745345342856, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -0,0 +1,1031 @@\n+# Blender Script: Create/Update Node Groups from Asset Processor Output\r\n+# Version: 1.5\r\n+# Description: Scans a library processed by the Asset Processor Tool,\r\n+# reads metadata.json files, and creates/updates corresponding\r\n+# PBR node groups in the active Blender file.\r\n+# Changes v1.5:\r\n+# - Corrected aspect ratio calculation (`calculate_aspect_correction_factor`)\r\n+# to use actual image dimensions from a loaded reference image and the\r\n+# `aspect_ratio_change_string`, mirroring original script logic for\r\n+# \"EVEN\", \"Xnnn\", \"Ynnn\" formats.\r\n+# - Added logic in main loop to load reference image for dimensions.\r\n+# Changes v1.3:\r\n+# - Added logic to find the highest resolution present for an asset.\r\n+# - Added logic to set a \"HighestResolution\" Value node in the parent group\r\n+# (maps 1K->1.0, 2K->2.0, 4K->3.0, 8K->4.0).\r\n+# Changes v1.2:\r\n+# - Added Base64 encoding for child node group names (PBRTYPE_...).\r\n+# - Added fallback logic for reconstructing image paths with different extensions.\r\n+# - Added logic to set custom asset preview for new parent groups (using lowest res COL map).\r\n+# Changes v1.1:\r\n+# - Updated metadata parsing to match actual structure (using processed_map_resolutions, image_stats_1k, map_details).\r\n+# - Added logic to reconstruct image file paths based on metadata and assumed naming convention.\r\n+\r\n+import bpy\r\n+import os\r\n+import json\r\n+from pathlib import Path\r\n+import time\r\n+import re # For parsing aspect ratio string\r\n+import base64 # For encoding node group names\r\n+import sys # <<< ADDED IMPORT\r\n+\r\n+# --- USER CONFIGURATION ---\r\n+\r\n+# Path to the root output directory of the Asset Processor Tool\r\n+# Example: r\"G:\\Assets\\Processed\"\r\n+# IMPORTANT: This should point to the base directory containing supplier folders (e.g., Poliigon)\r\n+# This will be overridden by command-line arguments if provided.\r\n+PROCESSED_ASSET_LIBRARY_ROOT = None # Set to None initially\r\n+\r\n+# Names of the required node group templates in the Blender file\r\n+PARENT_TEMPLATE_NAME = \"Template_PBRSET\"\r\n+CHILD_TEMPLATE_NAME = \"Template_PBRTYPE\"\r\n+\r\n+# Labels of specific nodes within the PARENT template\r\n+ASPECT_RATIO_NODE_LABEL = \"AspectRatioCorrection\" # Value node for UV X-scaling factor\r\n+STATS_NODE_PREFIX = \"Histogram-\" # Prefix for Combine XYZ nodes storing stats (e.g., \"Histogram-ROUGH\")\r\n+HIGHEST_RESOLUTION_NODE_LABEL = \"HighestResolution\" # Value node to store highest res index\r\n+\r\n+# Enable/disable the manifest system to track processed assets/maps\r\n+# If enabled, requires the blend file to be saved.\r\n+ENABLE_MANIFEST = False # Disabled based on user feedback in previous run\r\n+\r\n+# Assumed filename pattern for processed images.\r\n+# {asset_name}, {map_type}, {resolution}, {format} will be replaced.\r\n+# Check Asset Processor Tool's config.py (TARGET_FILENAME_PATTERN) if this is wrong.\r\n+IMAGE_FILENAME_PATTERN = \"{asset_name}_{map_type}_{resolution}.{format}\"\r\n+\r\n+# Fallback extensions to try if the primary format from metadata is not found\r\n+# Order matters - first found will be used.\r\n+FALLBACK_IMAGE_EXTENSIONS = ['png', 'jpg', 'exr', 'tif']\r\n+\r\n+# Map type(s) to use for generating the asset preview AND for aspect ratio calculation reference\r\n+# The script will look for these in order and use the first one found.\r\n+REFERENCE_MAP_TYPES = [\"COL\", \"COL-1\", \"COL-2\"] # Used for preview and aspect calc\r\n+# Preferred resolution order for reference image (lowest first is often faster)\r\n+REFERENCE_RESOLUTION_ORDER = [\"1K\", \"512\", \"2K\", \"4K\"] # Adjust as needed\r\n+\r\n+# Mapping from resolution string to numerical value for the HighestResolution node\r\n+RESOLUTION_VALUE_MAP = {\"1K\": 1.0, \"2K\": 2.0, \"4K\": 3.0, \"8K\": 4.0}\r\n+# Order to check resolutions to find the highest present (highest value first)\r\n+RESOLUTION_ORDER_DESC = [\"8K\", \"4K\", \"2K\", \"1K\"] # Add others like \"512\" if needed and map them in RESOLUTION_VALUE_MAP\r\n+\r\n+# Map PBR type strings (from metadata) to Blender color spaces\r\n+# Add more mappings as needed based on your metadata types\r\n+PBR_COLOR_SPACE_MAP = {\r\n+ \"AO\": \"Non-Color\", # Usually Non-Color, but depends on workflow\r\n+ \"COL\": \"sRGB\",\r\n+ \"COL-1\": \"sRGB\", # Handle variants if present in metadata\r\n+ \"COL-2\": \"sRGB\",\r\n+ \"COL-3\": \"sRGB\",\r\n+ \"DISP\": \"Non-Color\",\r\n+ \"NRM\": \"Non-Color\",\r\n+ \"REFL\": \"Non-Color\", # Reflection/Specular\r\n+ \"ROUGH\": \"Non-Color\",\r\n+ \"METAL\": \"Non-Color\",\r\n+ \"OPC\": \"Non-Color\", # Opacity/Alpha\r\n+ \"TRN\": \"Non-Color\", # Transmission\r\n+ \"SSS\": \"sRGB\", # Subsurface Color\r\n+ \"EMISS\": \"sRGB\", # Emission Color\r\n+ \"NRMRGH\": \"Non-Color\", # Added for merged map\r\n+ \"FUZZ\": \"Non-Color\",\r\n+ # Add other types like GLOSS, HEIGHT, etc. if needed\r\n+}\r\n+DEFAULT_COLOR_SPACE = \"sRGB\" # Fallback if map type not in the dictionary\r\n+\r\n+# Map types for which stats should be applied (if found in metadata and node exists)\r\n+# Reads stats from the 'image_stats_1k' section of metadata.json\r\n+APPLY_STATS_FOR_MAP_TYPES = [\"ROUGH\", \"DISP\", \"METAL\", \"AO\", \"REFL\"] # Add others if needed\r\n+\r\n+# Categories for which full nodegroup generation should occur\r\n+CATEGORIES_FOR_NODEGROUP_GENERATION = [\"Surface\", \"Decal\"]\r\n+\r\n+# --- END USER CONFIGURATION ---\r\n+\r\n+\r\n+# --- Helper Functions ---\r\n+\r\n+def encode_name_b64(name_str):\r\n+ \"\"\"Encodes a string using URL-safe Base64 for node group names.\"\"\"\r\n+ try:\r\n+ # Ensure the input is a string\r\n+ name_str = str(name_str)\r\n+ return base64.urlsafe_b64encode(name_str.encode('utf-8')).decode('ascii')\r\n+ except Exception as e:\r\n+ print(f\" Error base64 encoding '{name_str}': {e}\")\r\n+ return name_str # Fallback to original name on error\r\n+\r\n+def find_nodes_by_label(node_tree, label, node_type=None):\r\n+ \"\"\"Finds ALL nodes in a node tree matching the label and optionally type.\"\"\"\r\n+ if not node_tree:\r\n+ return []\r\n+ matching_nodes = []\r\n+ for node in node_tree.nodes:\r\n+ # Use node.label for labeled nodes, node.name for non-labeled (like Group Input/Output)\r\n+ node_identifier = node.label if node.label else node.name\r\n+ if node_identifier and node_identifier == label:\r\n+ if node_type is None or node.bl_idname == node_type or node.type == node_type: # Check bl_idname and type for flexibility\r\n+ matching_nodes.append(node)\r\n+ return matching_nodes\r\n+\r\n+def add_tag_if_new(asset_data, tag_name):\r\n+ \"\"\"Adds a tag to the asset data if it's not None/empty and doesn't already exist.\"\"\"\r\n+ if not asset_data or not tag_name or not isinstance(tag_name, str):\r\n+ return False\r\n+ cleaned_tag_name = tag_name.strip()\r\n+ if not cleaned_tag_name:\r\n+ return False\r\n+\r\n+ # Check if tag already exists (case-insensitive check might be better sometimes)\r\n+ if cleaned_tag_name not in [t.name for t in asset_data.tags]:\r\n+ try:\r\n+ asset_data.tags.new(cleaned_tag_name)\r\n+ print(f\" + Added Asset Tag: '{cleaned_tag_name}'\")\r\n+ return True\r\n+ except Exception as e:\r\n+ print(f\" Error adding tag '{cleaned_tag_name}': {e}\")\r\n+ return False\r\n+ return False # Tag already existed\r\n+\r\n+def get_color_space(map_type):\r\n+ \"\"\"Returns the appropriate Blender color space name for a given map type string.\"\"\"\r\n+ # Handle potential numbered variants like COL-1, COL-2\r\n+ base_map_type = map_type.split('-')[0]\r\n+ return PBR_COLOR_SPACE_MAP.get(map_type.upper(), # Check full name first (e.g., NRMRGH)\r\n+ PBR_COLOR_SPACE_MAP.get(base_map_type.upper(), DEFAULT_COLOR_SPACE)) # Fallback to base type\r\n+\r\n+def calculate_aspect_correction_factor(image_width, image_height, aspect_string):\r\n+ \"\"\"\r\n+ Calculates the UV X-axis scaling factor needed to correct distortion,\r\n+ based on image dimensions and the aspect_ratio_change_string (\"EVEN\", \"Xnnn\", \"Ynnn\").\r\n+ Mirrors the logic from the original POC script.\r\n+ Returns 1.0 if dimensions are invalid or string is \"EVEN\" or invalid.\r\n+ \"\"\"\r\n+ if image_height <= 0 or image_width <= 0:\r\n+ print(\" Warn: Invalid image dimensions for aspect ratio calculation. Returning 1.0.\")\r\n+ return 1.0\r\n+\r\n+ # Calculate the actual aspect ratio of the image file\r\n+ current_aspect_ratio = image_width / image_height\r\n+\r\n+ if not aspect_string or aspect_string.upper() == \"EVEN\":\r\n+ # If scaling was even, the correction factor is just the image's aspect ratio\r\n+ # to make UVs match the image proportions.\r\n+ # print(f\" Aspect string is EVEN. Correction factor = current aspect ratio: {current_aspect_ratio:.4f}\")\r\n+ return current_aspect_ratio\r\n+\r\n+ # Handle non-uniform scaling cases (\"Xnnn\", \"Ynnn\")\r\n+ # Use search instead of match to find anywhere in string (though unlikely needed based on format)\r\n+ match = re.search(r\"([XY])(\\d+)\", aspect_string, re.IGNORECASE)\r\n+ if not match:\r\n+ print(f\" Warn: Invalid Scaling string format '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f} as fallback.\")\r\n+ return current_aspect_ratio # Fallback to the image's own ratio\r\n+\r\n+ axis = match.group(1).upper()\r\n+ try:\r\n+ amount = int(match.group(2))\r\n+ if amount <= 0:\r\n+ print(f\" Warn: Zero or negative Amount in Scaling string '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n+ return current_aspect_ratio\r\n+ except ValueError:\r\n+ print(f\" Warn: Invalid Amount in Scaling string '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n+ return current_aspect_ratio\r\n+\r\n+ # Apply the non-uniform correction formula based on original script logic\r\n+ scaling_factor_percent = amount / 100.0\r\n+ correction_factor = current_aspect_ratio # Default\r\n+\r\n+ try:\r\n+ if axis == 'X':\r\n+ if scaling_factor_percent == 0: raise ZeroDivisionError(\"X scaling factor is zero\")\r\n+ # If image was stretched horizontally (X > 1), divide UV.x by factor\r\n+ correction_factor = current_aspect_ratio / scaling_factor_percent\r\n+ elif axis == 'Y':\r\n+ # If image was stretched vertically (Y > 1), multiply UV.x by factor\r\n+ correction_factor = current_aspect_ratio * scaling_factor_percent\r\n+ # No 'else' needed as regex ensures X or Y\r\n+\r\n+ except ZeroDivisionError as e:\r\n+ print(f\" Warn: Division by zero during aspect factor calculation ({e}). Returning current ratio {current_aspect_ratio:.4f}.\")\r\n+ return current_aspect_ratio\r\n+ except Exception as e:\r\n+ print(f\" Error calculating aspect correction factor: {e}. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n+ return current_aspect_ratio\r\n+\r\n+ # print(f\" Calculated aspect correction factor: {correction_factor:.4f} (from {image_width}x{image_height}, Scaling='{aspect_string}')\")\r\n+ return correction_factor\r\n+\r\n+\r\n+def reconstruct_image_path_with_fallback(asset_dir_path, asset_name, map_type, resolution, primary_format=None):\r\n+ \"\"\"\r\n+ Constructs the expected image file path.\r\n+ If primary_format is provided, tries that first.\r\n+ Then falls back to common extensions if the path doesn't exist or primary_format was None.\r\n+ Returns the found path as a string, or None if not found.\r\n+ \"\"\"\r\n+ if not all([asset_dir_path, asset_name, map_type, resolution]):\r\n+ print(f\" !!! ERROR: Missing data for path reconstruction ({asset_name}/{map_type}/{resolution}).\")\r\n+ return None\r\n+\r\n+ found_path = None\r\n+\r\n+ # 1. Try the primary format if provided\r\n+ if primary_format:\r\n+ try:\r\n+ filename = IMAGE_FILENAME_PATTERN.format(\r\n+ asset_name=asset_name,\r\n+ map_type=map_type,\r\n+ resolution=resolution,\r\n+ format=primary_format.lower() # Ensure format is lowercase\r\n+ )\r\n+ primary_path = asset_dir_path / filename\r\n+ if primary_path.is_file():\r\n+ # print(f\" Found primary path: {str(primary_path)}\") # Verbose\r\n+ return str(primary_path)\r\n+ # else: print(f\" Primary path not found: {str(primary_path)}\") # Verbose\r\n+ except KeyError as e:\r\n+ print(f\" !!! ERROR: Missing key '{e}' in IMAGE_FILENAME_PATTERN. Cannot reconstruct path.\")\r\n+ return None # Cannot proceed without valid pattern\r\n+ except Exception as e:\r\n+ print(f\" !!! ERROR reconstructing primary image path: {e}\")\r\n+ # Continue to fallback\r\n+\r\n+ # 2. Try fallback extensions\r\n+ # print(f\" Trying fallback extensions for {map_type}/{resolution}...\") # Verbose\r\n+ for ext in FALLBACK_IMAGE_EXTENSIONS:\r\n+ # Skip if we already tried this extension as primary (and it failed)\r\n+ if primary_format and ext.lower() == primary_format.lower():\r\n+ continue\r\n+ try:\r\n+ fallback_filename = IMAGE_FILENAME_PATTERN.format(\r\n+ asset_name=asset_name,\r\n+ map_type=map_type,\r\n+ resolution=resolution,\r\n+ format=ext.lower()\r\n+ )\r\n+ fallback_path = asset_dir_path / fallback_filename\r\n+ if fallback_path.is_file():\r\n+ print(f\" Found fallback path: {str(fallback_path)}\")\r\n+ return str(fallback_path) # Found it!\r\n+ except KeyError:\r\n+ # Should not happen if primary format worked, but handle defensively\r\n+ print(f\" !!! ERROR: Missing key in IMAGE_FILENAME_PATTERN during fallback. Cannot reconstruct path.\")\r\n+ return None\r\n+ except Exception as e_fallback:\r\n+ print(f\" !!! ERROR reconstructing fallback image path ({ext}): {e_fallback}\")\r\n+ continue # Try next extension\r\n+\r\n+ # If we get here, neither primary nor fallbacks worked\r\n+ if primary_format:\r\n+ print(f\" !!! ERROR: Could not find image file for {map_type}/{resolution} using primary format '{primary_format}' or fallbacks {FALLBACK_IMAGE_EXTENSIONS}.\")\r\n+ else:\r\n+ print(f\" !!! ERROR: Could not find image file for {map_type}/{resolution} using fallbacks {FALLBACK_IMAGE_EXTENSIONS}.\")\r\n+ return None # Not found after all checks\r\n+\r\n+\r\n+# --- Manifest Functions ---\r\n+\r\n+def get_manifest_path(context):\r\n+ \"\"\"Gets the expected path for the manifest JSON file.\"\"\"\r\n+ if not context or not context.blend_data or not context.blend_data.filepath:\r\n+ return None # Cannot determine path if blend file is not saved\r\n+ blend_path = Path(context.blend_data.filepath)\r\n+ manifest_filename = f\"{blend_path.stem}_manifest.json\"\r\n+ return blend_path.parent / manifest_filename\r\n+\r\n+def load_manifest(context):\r\n+ \"\"\"Loads the manifest data from the JSON file.\"\"\"\r\n+ if not ENABLE_MANIFEST:\r\n+ return {} # Manifest disabled\r\n+\r\n+ manifest_path = get_manifest_path(context)\r\n+ if not manifest_path:\r\n+ print(\" Manifest Info: Blend file not saved. Cannot load manifest.\")\r\n+ return {} # Cannot load without a path\r\n+\r\n+ if not manifest_path.exists():\r\n+ print(f\" Manifest Info: No manifest file found at '{manifest_path.name}'. Starting fresh.\")\r\n+ return {} # No manifest file exists yet\r\n+\r\n+ try:\r\n+ with open(manifest_path, 'r', encoding='utf-8') as f:\r\n+ data = json.load(f)\r\n+ print(f\" Manifest Loaded from: {manifest_path.name}\")\r\n+ # Basic validation (check if it's a dictionary)\r\n+ if not isinstance(data, dict):\r\n+ print(f\"!!! WARNING: Manifest file '{manifest_path.name}' has invalid format (not a dictionary). Starting fresh. !!!\")\r\n+ return {}\r\n+ return data\r\n+ except json.JSONDecodeError:\r\n+ print(f\"!!! WARNING: Manifest file '{manifest_path.name}' is corrupted. Starting fresh. !!!\")\r\n+ return {}\r\n+ except Exception as e:\r\n+ print(f\"!!! ERROR: Could not load manifest file '{manifest_path.name}': {e} !!!\")\r\n+ return {} # Treat as starting fresh on error\r\n+\r\n+def save_manifest(context, manifest_data):\r\n+ \"\"\"Saves the manifest data to the JSON file.\"\"\"\r\n+ if not ENABLE_MANIFEST or not manifest_data: # Don't save if disabled or empty\r\n+ return False\r\n+\r\n+ manifest_path = get_manifest_path(context)\r\n+ if not manifest_path:\r\n+ print(\" Manifest Error: Blend file not saved. Cannot save manifest.\")\r\n+ return False\r\n+\r\n+ try:\r\n+ with open(manifest_path, 'w', encoding='utf-8') as f:\r\n+ json.dump(manifest_data, f, indent=2, sort_keys=True) # Use indent and sort for readability\r\n+ print(f\" Manifest Saved to: {manifest_path.name}\")\r\n+ return True\r\n+ except Exception as e:\r\n+ print(f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\\n\"\r\n+ f\"!!! Manifest save FAILED to '{manifest_path.name}': {e} !!!\\n\"\r\n+ f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\")\r\n+ return False\r\n+\r\n+def is_asset_processed(manifest_data, asset_name):\r\n+ \"\"\"Checks if the entire asset (all its maps/resolutions) is marked as processed.\"\"\"\r\n+ if not ENABLE_MANIFEST: return False\r\n+ # Basic check if asset entry exists. Detailed check happens at map level.\r\n+ return asset_name in manifest_data\r\n+\r\n+def is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n+ \"\"\"Checks if a specific map type and resolution for an asset is processed.\"\"\"\r\n+ if not ENABLE_MANIFEST: return False\r\n+ return resolution in manifest_data.get(asset_name, {}).get(map_type, [])\r\n+\r\n+def update_manifest(manifest_data, asset_name, map_type=None, resolution=None):\r\n+ \"\"\"Updates the manifest dictionary in memory.\"\"\"\r\n+ if not ENABLE_MANIFEST: return False\r\n+\r\n+ # Ensure asset entry exists\r\n+ if asset_name not in manifest_data:\r\n+ manifest_data[asset_name] = {}\r\n+\r\n+ # If map_type and resolution are provided, update the specific map entry\r\n+ if map_type and resolution:\r\n+ if map_type not in manifest_data[asset_name]:\r\n+ manifest_data[asset_name][map_type] = []\r\n+\r\n+ if resolution not in manifest_data[asset_name][map_type]:\r\n+ manifest_data[asset_name][map_type].append(resolution)\r\n+ manifest_data[asset_name][map_type].sort() # Keep sorted\r\n+ return True # Indicate that a change was made\r\n+ return False # No change made to this specific map/res\r\n+\r\n+\r\n+# --- Core Logic ---\r\n+\r\n+def process_library(context, asset_library_root_override=None): # Add override parameter\r\n+ global ENABLE_MANIFEST # Declare intent to modify global if needed\r\n+ global PROCESSED_ASSET_LIBRARY_ROOT # Allow modification of global\r\n+ \"\"\"Scans the library, reads metadata, creates/updates node groups.\"\"\"\r\n+ start_time = time.time()\r\n+ print(f\"\\n--- Starting Node Group Processing ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---\")\r\n+ print(f\" DEBUG: Received asset_library_root_override: {asset_library_root_override}\") # DEBUG LOG (Indented)\r\n+\r\n+ # --- Determine Asset Library Root ---\r\n+ if asset_library_root_override:\r\n+ PROCESSED_ASSET_LIBRARY_ROOT = asset_library_root_override\r\n+ print(f\"Using asset library root from argument: '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n+ elif not PROCESSED_ASSET_LIBRARY_ROOT:\r\n+ print(\"!!! ERROR: Processed asset library root not set in script and not provided via argument.\")\r\n+ print(\"--- Script aborted. ---\")\r\n+ return False\r\n+ print(f\" DEBUG: Using final PROCESSED_ASSET_LIBRARY_ROOT: {PROCESSED_ASSET_LIBRARY_ROOT}\") # DEBUG LOG (Indented)\r\n+\r\n+ # --- Pre-run Checks ---\r\n+ print(\"Performing pre-run checks...\")\r\n+ valid_setup = True\r\n+ # 1. Check Library Root Path\r\n+ root_path = Path(PROCESSED_ASSET_LIBRARY_ROOT)\r\n+ if not root_path.is_dir():\r\n+ print(f\"!!! ERROR: Processed asset library root directory not found or not a directory:\")\r\n+ print(f\"!!! '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n+ valid_setup = False\r\n+ else:\r\n+ print(f\" Asset Library Root: '{root_path}'\")\r\n+ print(f\" DEBUG: Checking for templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\") # DEBUG LOG (Indented)\r\n+\r\n+ # 2. Check Templates\r\n+ template_parent = bpy.data.node_groups.get(PARENT_TEMPLATE_NAME)\r\n+ template_child = bpy.data.node_groups.get(CHILD_TEMPLATE_NAME)\r\n+ if not template_parent:\r\n+ print(f\"!!! ERROR: Parent template node group '{PARENT_TEMPLATE_NAME}' not found in this Blender file.\")\r\n+ valid_setup = False\r\n+ if not template_child:\r\n+ print(f\"!!! ERROR: Child template node group '{CHILD_TEMPLATE_NAME}' not found in this Blender file.\")\r\n+ valid_setup = False\r\n+ if template_parent and template_child:\r\n+ print(f\" Found Templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\")\r\n+ print(f\" DEBUG: Template Parent Found: {template_parent is not None}\") # DEBUG LOG (Indented)\r\n+ print(f\" DEBUG: Template Child Found: {template_child is not None}\") # DEBUG LOG (Indented)\r\n+\r\n+ # 3. Check Blend File Saved (if manifest enabled)\r\n+ if ENABLE_MANIFEST and not context.blend_data.filepath:\r\n+ print(f\"!!! WARNING: Manifest is enabled, but the current Blender file is not saved.\")\r\n+ print(f\"!!! Manifest cannot be loaded or saved. Processing will continue without manifest checks.\")\r\n+ ENABLE_MANIFEST = False # Disable manifest for this run\r\n+\r\n+ if not valid_setup:\r\n+ print(\"\\n--- Script aborted due to configuration errors. Please fix the issues above. ---\")\r\n+ return False\r\n+ print(\"Pre-run checks passed.\")\r\n+ # --- End Pre-run Checks ---\r\n+\r\n+ manifest_data = load_manifest(context)\r\n+ manifest_needs_saving = False\r\n+\r\n+ # --- Initialize Counters ---\r\n+ metadata_files_found = 0\r\n+ assets_processed = 0\r\n+ assets_skipped_manifest = 0\r\n+ parent_groups_created = 0\r\n+ parent_groups_updated = 0\r\n+ child_groups_created = 0\r\n+ child_groups_updated = 0\r\n+ images_loaded = 0\r\n+ images_assigned = 0\r\n+ maps_processed = 0\r\n+ maps_skipped_manifest = 0\r\n+ errors_encountered = 0\r\n+ previews_set = 0\r\n+ highest_res_set = 0\r\n+ aspect_ratio_set = 0\r\n+ # --- End Counters ---\r\n+\r\n+ print(f\"\\nScanning for metadata files in '{root_path}'...\")\r\n+\r\n+ # --- Scan for metadata.json ---\r\n+ # Scan one level deeper for supplier folders (e.g., Poliigon)\r\n+ # Then scan within each supplier for asset folders containing metadata.json\r\n+ metadata_paths = []\r\n+ for supplier_dir in root_path.iterdir():\r\n+ if supplier_dir.is_dir():\r\n+ # Now look for asset folders inside the supplier directory\r\n+ for asset_dir in supplier_dir.iterdir():\r\n+ if asset_dir.is_dir():\r\n+ metadata_file = asset_dir / 'metadata.json'\r\n+ if metadata_file.is_file():\r\n+ metadata_paths.append(metadata_file)\r\n+\r\n+ metadata_files_found = len(metadata_paths)\r\n+ print(f\"Found {metadata_files_found} metadata.json files.\")\r\n+ print(f\" DEBUG: Metadata paths found: {metadata_paths}\") # DEBUG LOG (Indented)\r\n+\r\n+ if metadata_files_found == 0:\r\n+ print(\"No metadata files found. Nothing to process.\")\r\n+ print(\"--- Script Finished ---\")\r\n+ return True # No work needed is considered success\r\n+\r\n+ # --- Process Each Metadata File ---\r\n+ for metadata_path in metadata_paths:\r\n+ asset_dir_path = metadata_path.parent # Get the directory containing the metadata file\r\n+ print(f\"\\n--- Processing Metadata: {metadata_path.relative_to(root_path)} ---\")\r\n+ print(f\" DEBUG: Processing file: {metadata_path}\") # DEBUG LOG (Indented)\r\n+ try:\r\n+ with open(metadata_path, 'r', encoding='utf-8') as f:\r\n+ metadata = json.load(f)\r\n+\r\n+ # --- Extract Key Info ---\r\n+ asset_name = metadata.get(\"asset_name\")\r\n+ supplier_name = metadata.get(\"supplier_name\")\r\n+ archetype = metadata.get(\"archetype\")\r\n+ category = metadata.get(\"category\", \"Unknown\") # <<< ADDED: Extract category, default to \"Unknown\"\r\n+ # Get map info from the correct keys\r\n+ processed_resolutions = metadata.get(\"processed_map_resolutions\", {}) # Default to empty dict\r\n+ merged_resolutions = metadata.get(\"merged_map_resolutions\", {}) # Get merged maps too\r\n+ map_details = metadata.get(\"map_details\", {}) # Default to empty dict\r\n+ image_stats_1k = metadata.get(\"image_stats_1k\") # Dict: {map_type: {stats}}\r\n+ aspect_string = metadata.get(\"aspect_ratio_change_string\")\r\n+\r\n+ # Combine processed and merged maps for iteration\r\n+ all_map_resolutions = {**processed_resolutions, **merged_resolutions}\r\n+\r\n+ # Validate essential data\r\n+ if not asset_name:\r\n+ print(f\" !!! ERROR: Metadata file is missing 'asset_name'. Skipping.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ if not all_map_resolutions:\r\n+ print(f\" !!! ERROR: Metadata file has no 'processed_map_resolutions' or 'merged_map_resolutions'. Skipping asset '{asset_name}'.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ # map_details check remains a warning as merged maps won't be in it\r\n+ print(f\" DEBUG: Valid metadata loaded for asset: {asset_name}\") # DEBUG LOG (Indented)\r\n+\r\n+ print(f\" Asset Name: {asset_name}\")\r\n+\r\n+ # --- Determine Highest Resolution ---\r\n+ highest_resolution_value = 0.0\r\n+ highest_resolution_str = \"Unknown\"\r\n+ all_resolutions_present = set()\r\n+ if all_map_resolutions: # Check combined dict\r\n+ for res_list in all_map_resolutions.values():\r\n+ if isinstance(res_list, list):\r\n+ all_resolutions_present.update(res_list)\r\n+\r\n+ if all_resolutions_present:\r\n+ for res_str in RESOLUTION_ORDER_DESC:\r\n+ if res_str in all_resolutions_present:\r\n+ highest_resolution_value = RESOLUTION_VALUE_MAP.get(res_str, 0.0)\r\n+ highest_resolution_str = res_str\r\n+ if highest_resolution_value > 0.0:\r\n+ break # Found the highest valid resolution\r\n+\r\n+ print(f\" Highest resolution found: {highest_resolution_str} (Value: {highest_resolution_value})\")\r\n+\r\n+ # --- Load Reference Image for Aspect Ratio ---\r\n+ ref_image_path = None\r\n+ ref_image_width = 0\r\n+ ref_image_height = 0\r\n+ ref_image_loaded = False\r\n+ # Use combined resolutions dict to find reference map\r\n+ for ref_map_type in REFERENCE_MAP_TYPES:\r\n+ if ref_map_type in all_map_resolutions:\r\n+ available_resolutions = all_map_resolutions[ref_map_type]\r\n+ lowest_res = None\r\n+ for res_pref in REFERENCE_RESOLUTION_ORDER:\r\n+ if res_pref in available_resolutions:\r\n+ lowest_res = res_pref\r\n+ break\r\n+ if lowest_res:\r\n+ # Get format from map_details if available, otherwise None\r\n+ ref_map_details = map_details.get(ref_map_type, {})\r\n+ ref_format = ref_map_details.get(\"output_format\")\r\n+ ref_image_path = reconstruct_image_path_with_fallback(\r\n+ asset_dir_path=asset_dir_path,\r\n+ asset_name=asset_name,\r\n+ map_type=ref_map_type,\r\n+ resolution=lowest_res,\r\n+ primary_format=ref_format # Pass None if not in map_details\r\n+ )\r\n+ if ref_image_path:\r\n+ break # Found a suitable reference image path\r\n+\r\n+ if ref_image_path:\r\n+ print(f\" Loading reference image for aspect ratio: {Path(ref_image_path).name}\")\r\n+ try:\r\n+ # Load image temporarily\r\n+ ref_img = bpy.data.images.load(ref_image_path, check_existing=True)\r\n+ if ref_img:\r\n+ ref_image_width = ref_img.size[0]\r\n+ ref_image_height = ref_img.size[1]\r\n+ ref_image_loaded = True\r\n+ print(f\" Reference image dimensions: {ref_image_width}x{ref_image_height}\")\r\n+ # Remove the temporary image datablock to save memory\r\n+ bpy.data.images.remove(ref_img)\r\n+ else:\r\n+ print(f\" !!! ERROR: Failed loading reference image via bpy.data.images.load: {ref_image_path}\")\r\n+ except Exception as e_ref_load:\r\n+ print(f\" !!! ERROR loading reference image '{ref_image_path}': {e_ref_load}\")\r\n+ else:\r\n+ print(f\" !!! WARNING: Could not find suitable reference image ({REFERENCE_MAP_TYPES} at {REFERENCE_RESOLUTION_ORDER}) for aspect ratio calculation.\")\r\n+\r\n+\r\n+ # --- Manifest Check (Asset Level - Basic) ---\r\n+ if ENABLE_MANIFEST and is_asset_processed(manifest_data, asset_name):\r\n+ # Perform a quick check if *any* map needs processing for this asset\r\n+ needs_processing = False\r\n+ for map_type, resolutions in all_map_resolutions.items(): # Check combined maps\r\n+ for resolution in resolutions:\r\n+ if not is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n+ needs_processing = True\r\n+ break\r\n+ if needs_processing:\r\n+ break\r\n+ if not needs_processing:\r\n+ print(f\" Skipping asset '{asset_name}' (already fully processed according to manifest).\")\r\n+ assets_skipped_manifest += 1\r\n+ continue # Skip to next metadata file\r\n+\r\n+ # --- Parent Group Handling ---\r\n+ target_parent_name = f\"PBRSET_{asset_name}\"\r\n+ parent_group = bpy.data.node_groups.get(target_parent_name)\r\n+ is_new_parent = False\r\n+\r\n+ if parent_group is None:\r\n+ print(f\" Creating new parent group: '{target_parent_name}'\")\r\n+ print(f\" DEBUG: Copying parent template '{PARENT_TEMPLATE_NAME}'\") # DEBUG LOG (Indented)\r\n+ parent_group = template_parent.copy()\r\n+ if not parent_group:\r\n+ print(f\" !!! ERROR: Failed to copy parent template '{PARENT_TEMPLATE_NAME}'. Skipping asset '{asset_name}'.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ parent_group.name = target_parent_name\r\n+ parent_groups_created += 1\r\n+ is_new_parent = True\r\n+ else:\r\n+ print(f\" Updating existing parent group: '{target_parent_name}'\")\r\n+ print(f\" DEBUG: Found existing parent group.\") # DEBUG LOG (Indented)\r\n+ parent_groups_updated += 1\r\n+\r\n+ # Ensure marked as asset\r\n+ if not parent_group.asset_data:\r\n+ try:\r\n+ parent_group.asset_mark()\r\n+ print(f\" Marked '{parent_group.name}' as asset.\")\r\n+ except Exception as e_mark:\r\n+ print(f\" !!! ERROR: Failed to mark group '{parent_group.name}' as asset: {e_mark}\")\r\n+ # Continue processing other parts if possible\r\n+\r\n+ # Apply Asset Tags\r\n+ if parent_group.asset_data:\r\n+ if supplier_name:\r\n+ add_tag_if_new(parent_group.asset_data, supplier_name)\r\n+ if archetype:\r\n+ add_tag_if_new(parent_group.asset_data, archetype)\r\n+ if category: # <<< ADDED: Add category tag\r\n+ add_tag_if_new(parent_group.asset_data, category)\r\n+ # Add other tags if needed\r\n+ # else: print(f\" Warn: Cannot apply tags, asset_data not available for '{parent_group.name}'.\") # Optional warning\r\n+\r\n+ # <<< ADDED: Conditional skip based on category >>>\r\n+ if category not in CATEGORIES_FOR_NODEGROUP_GENERATION: # <<< MODIFIED: Use config variable\r\n+ print(f\" Skipping nodegroup content generation for asset '{asset_name}' (Category: '{category}'). Tag added.\")\r\n+ assets_processed += 1 # Still count as processed for summary, even if skipped\r\n+ continue # Skip the rest of the processing for this asset\r\n+\r\n+ # Apply Aspect Ratio Correction\r\n+ aspect_nodes = find_nodes_by_label(parent_group, ASPECT_RATIO_NODE_LABEL, 'ShaderNodeValue')\r\n+ if aspect_nodes:\r\n+ aspect_node = aspect_nodes[0]\r\n+ correction_factor = 1.0 # Default if ref image fails\r\n+ if ref_image_loaded:\r\n+ correction_factor = calculate_aspect_correction_factor(ref_image_width, ref_image_height, aspect_string)\r\n+ print(f\" Calculated aspect correction factor: {correction_factor:.4f}\")\r\n+ else:\r\n+ print(f\" !!! WARNING: Using default aspect ratio correction (1.0) due to missing reference image.\")\r\n+\r\n+ # Check if update is needed\r\n+ current_val = aspect_node.outputs[0].default_value\r\n+ if abs(current_val - correction_factor) > 0.0001:\r\n+ aspect_node.outputs[0].default_value = correction_factor\r\n+ print(f\" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f} (was {current_val:.4f})\")\r\n+ aspect_ratio_set += 1\r\n+ # else: print(f\" Warn: Aspect ratio node '{ASPECT_RATIO_NODE_LABEL}' not found in parent group.\") # Optional\r\n+\r\n+ # Apply Highest Resolution Value\r\n+ hr_nodes = find_nodes_by_label(parent_group, HIGHEST_RESOLUTION_NODE_LABEL, 'ShaderNodeValue')\r\n+ if hr_nodes:\r\n+ hr_node = hr_nodes[0]\r\n+ current_hr_val = hr_node.outputs[0].default_value\r\n+ if highest_resolution_value > 0.0 and abs(current_hr_val - highest_resolution_value) > 0.001:\r\n+ hr_node.outputs[0].default_value = highest_resolution_value\r\n+ print(f\" Set '{HIGHEST_RESOLUTION_NODE_LABEL}' value to {highest_resolution_value} ({highest_resolution_str}) (was {current_hr_val:.1f})\")\r\n+ highest_res_set += 1 # Count successful sets\r\n+ # else: print(f\" Warn: Highest resolution node '{HIGHEST_RESOLUTION_NODE_LABEL}' not found in parent group.\") # Optional\r\n+\r\n+\r\n+ # Apply Stats (using image_stats_1k)\r\n+ if image_stats_1k and isinstance(image_stats_1k, dict):\r\n+ for map_type_to_stat in APPLY_STATS_FOR_MAP_TYPES:\r\n+ if map_type_to_stat in image_stats_1k:\r\n+ # Find the stats node in the parent group\r\n+ stats_node_label = f\"{STATS_NODE_PREFIX}{map_type_to_stat}\"\r\n+ stats_nodes = find_nodes_by_label(parent_group, stats_node_label, 'ShaderNodeCombineXYZ')\r\n+ if stats_nodes:\r\n+ stats_node = stats_nodes[0]\r\n+ stats = image_stats_1k[map_type_to_stat] # Get stats dict for this map type\r\n+\r\n+ if stats and isinstance(stats, dict):\r\n+ # Handle potential list format for RGB stats (use first value) or direct float\r\n+ def get_stat_value(stat_val):\r\n+ if isinstance(stat_val, list):\r\n+ return stat_val[0] if stat_val else None\r\n+ return stat_val\r\n+\r\n+ min_val = get_stat_value(stats.get(\"min\"))\r\n+ max_val = get_stat_value(stats.get(\"max\"))\r\n+ mean_val = get_stat_value(stats.get(\"mean\")) # Often stored as 'mean' or 'avg'\r\n+\r\n+ updated_stat = False\r\n+ # Check inputs exist before assigning\r\n+ input_x = stats_node.inputs.get(\"X\")\r\n+ input_y = stats_node.inputs.get(\"Y\")\r\n+ input_z = stats_node.inputs.get(\"Z\")\r\n+\r\n+ if input_x and min_val is not None and abs(input_x.default_value - min_val) > 0.0001:\r\n+ input_x.default_value = min_val\r\n+ updated_stat = True\r\n+ if input_y and max_val is not None and abs(input_y.default_value - max_val) > 0.0001:\r\n+ input_y.default_value = max_val\r\n+ updated_stat = True\r\n+ if input_z and mean_val is not None and abs(input_z.default_value - mean_val) > 0.0001:\r\n+ input_z.default_value = mean_val\r\n+ updated_stat = True\r\n+\r\n+ if updated_stat:\r\n+ print(f\" Set stats in '{stats_node_label}': Min={min_val:.4f}, Max={max_val:.4f}, Mean={mean_val:.4f}\")\r\n+ # else: print(f\" Info: No valid 'stats' dictionary found for map type '{map_type_to_stat}' in image_stats_1k.\") # Optional\r\n+ # else: print(f\" Warn: Stats node '{stats_node_label}' not found in parent group.\") # Optional\r\n+ # else: print(f\" Info: Map type '{map_type_to_stat}' not present in image_stats_1k for stats application.\") # Optional\r\n+ # else: print(f\" Warn: 'image_stats_1k' missing or invalid in metadata.\") # Optional\r\n+\r\n+ # --- Set Asset Preview (only for new parent groups) ---\r\n+ # Use the reference image path found earlier if available\r\n+ if is_new_parent and parent_group.asset_data:\r\n+ if ref_image_loaded and ref_image_path: # Check if ref image was successfully loaded earlier\r\n+ print(f\" Attempting to set preview from reference image: {Path(ref_image_path).name}\")\r\n+ try:\r\n+ # Ensure the ID (node group) is the active one for the operator context\r\n+ with context.temp_override(id=parent_group):\r\n+ bpy.ops.ed.lib_id_load_custom_preview(filepath=ref_image_path)\r\n+ print(f\" Successfully set custom preview.\")\r\n+ previews_set += 1\r\n+ except Exception as e_preview:\r\n+ print(f\" !!! ERROR setting custom preview: {e_preview}\")\r\n+ errors_encountered += 1\r\n+ else:\r\n+ print(f\" Info: Could not set preview for '{asset_name}' as reference image was not found or loaded.\")\r\n+\r\n+\r\n+ # --- Child Group Handling ---\r\n+ # Iterate through the COMBINED map types\r\n+ print(f\" DEBUG: Starting child group loop for asset '{asset_name}'. Map types: {list(all_map_resolutions.keys())}\") # DEBUG LOG (Indented)\r\n+ for map_type, resolutions in all_map_resolutions.items():\r\n+ print(f\" Processing Map Type: {map_type}\")\r\n+\r\n+ # Determine if this is a merged map (not in map_details)\r\n+ is_merged_map = map_type not in map_details\r\n+\r\n+ # Get details for this map type if available\r\n+ current_map_details = map_details.get(map_type, {})\r\n+ # For merged maps, primary_format will be None\r\n+ output_format = current_map_details.get(\"output_format\")\r\n+\r\n+ if not output_format and not is_merged_map:\r\n+ # This case should ideally not happen if metadata is well-formed\r\n+ # but handle defensively for processed maps.\r\n+ print(f\" !!! WARNING: Missing 'output_format' in map_details for processed map '{map_type}'. Path reconstruction might fail.\")\r\n+ # We will rely solely on fallback for this map type\r\n+\r\n+ # Find placeholder node in parent\r\n+ holder_nodes = find_nodes_by_label(parent_group, map_type, 'ShaderNodeGroup')\r\n+ if not holder_nodes:\r\n+ print(f\" !!! WARNING: No placeholder node labeled '{map_type}' found in parent group '{parent_group.name}'. Skipping this map type.\")\r\n+ continue\r\n+ holder_node = holder_nodes[0] # Assume first is correct\r\n+ print(f\" DEBUG: Found placeholder node '{holder_node.label}' for map type '{map_type}'.\") # DEBUG LOG (Indented)\r\n+\r\n+ # Determine child group name (LOGICAL and ENCODED)\r\n+ logical_child_name = f\"{asset_name}_{map_type}\"\r\n+ target_child_name_b64 = encode_name_b64(logical_child_name) # Use Base64 name\r\n+\r\n+ child_group = bpy.data.node_groups.get(target_child_name_b64) # Find using encoded name\r\n+ is_new_child = False\r\n+\r\n+ if child_group is None:\r\n+ print(f\" DEBUG: Child group '{target_child_name_b64}' not found. Creating new one.\") # DEBUG LOG (Indented)\r\n+ # print(f\" Creating new child group: '{target_child_name_b64}' (logical: '{logical_child_name}')\") # Verbose\r\n+ child_group = template_child.copy()\r\n+ if not child_group:\r\n+ print(f\" !!! ERROR: Failed to copy child template '{CHILD_TEMPLATE_NAME}'. Skipping map type '{map_type}'.\")\r\n+ errors_encountered += 1\r\n+ continue\r\n+ child_group.name = target_child_name_b64 # Set encoded name\r\n+ child_groups_created += 1\r\n+ is_new_child = True\r\n+ else:\r\n+ print(f\" DEBUG: Found existing child group '{target_child_name_b64}'.\") # DEBUG LOG (Indented)\r\n+ # print(f\" Updating existing child group: '{target_child_name_b64}'\") # Verbose\r\n+ child_groups_updated += 1\r\n+\r\n+ # Assign child group to placeholder if needed\r\n+ if holder_node.node_tree != child_group:\r\n+ try:\r\n+ holder_node.node_tree = child_group\r\n+ print(f\" Assigned child group '{child_group.name}' to placeholder '{holder_node.label}'.\")\r\n+ except TypeError as e_assign: # Catch potential type errors if placeholder isn't a group node\r\n+ print(f\" !!! ERROR: Could not assign child group to placeholder '{holder_node.label}'. Is it a Group Node? Error: {e_assign}\")\r\n+ continue # Skip this map type if assignment fails\r\n+\r\n+ # Link placeholder output to parent output socket\r\n+ try:\r\n+ # Find parent's output node\r\n+ group_output_node = next((n for n in parent_group.nodes if n.type == 'GROUP_OUTPUT'), None)\r\n+ if group_output_node:\r\n+ # Get the specific output socket on the placeholder (usually index 0 or named 'Color'/'Value')\r\n+ source_socket = holder_node.outputs.get(\"Color\") or holder_node.outputs.get(\"Value\") or holder_node.outputs[0]\r\n+ # Get the specific input socket on the parent output node (matching map_type)\r\n+ target_socket = group_output_node.inputs.get(map_type)\r\n+\r\n+ if source_socket and target_socket:\r\n+ # Check if link already exists\r\n+ link_exists = any(link.from_socket == source_socket and link.to_socket == target_socket for link in parent_group.links)\r\n+ if not link_exists:\r\n+ parent_group.links.new(source_socket, target_socket)\r\n+ print(f\" Linked '{holder_node.label}' output to parent output socket '{map_type}'.\")\r\n+ # else: # Optional warnings\r\n+ # if not source_socket: print(f\" Warn: Could not find suitable output socket on placeholder '{holder_node.label}'.\")\r\n+ # if not target_socket: print(f\" Warn: Could not find input socket '{map_type}' on parent output node.\")\r\n+ # else: print(f\" Warn: Parent group '{parent_group.name}' has no Group Output node.\")\r\n+\r\n+ except Exception as e_link:\r\n+ print(f\" !!! ERROR linking sockets for '{map_type}': {e_link}\")\r\n+\r\n+ # Ensure parent output socket type is Color (if it exists)\r\n+ try:\r\n+ # Use the interface API for modern Blender versions\r\n+ item = parent_group.interface.items_tree.get(map_type)\r\n+ if item and item.item_type == 'SOCKET' and item.in_out == 'OUTPUT':\r\n+ # Common useful types: 'NodeSocketColor', 'NodeSocketVector', 'NodeSocketFloat'\r\n+ # Defaulting to Color seems reasonable for most PBR outputs\r\n+ if item.socket_type != 'NodeSocketColor':\r\n+ item.socket_type = 'NodeSocketColor'\r\n+ # print(f\" Set parent output socket '{map_type}' type to Color.\") # Optional info\r\n+ except Exception as e_sock_type:\r\n+ print(f\" Warn: Could not verify/set socket type for '{map_type}': {e_sock_type}\")\r\n+\r\n+\r\n+ # --- Image Node Handling (Inside Child Group) ---\r\n+ if not isinstance(resolutions, list):\r\n+ print(f\" !!! ERROR: Invalid format for resolutions list for map type '{map_type}'. Skipping.\")\r\n+ continue\r\n+\r\n+ for resolution in resolutions:\r\n+ # --- Manifest Check (Map/Resolution Level) ---\r\n+ if ENABLE_MANIFEST and is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n+ # print(f\" Skipping {resolution} (Manifest)\") # Verbose\r\n+ maps_skipped_manifest += 1\r\n+ continue\r\n+ print(f\" DEBUG: Processing map '{map_type}' resolution '{resolution}'. Manifest skip check passed.\") # DEBUG LOG (Indented)\r\n+\r\n+ print(f\" Processing Resolution: {resolution}\")\r\n+\r\n+ # Reconstruct the image path using fallback logic\r\n+ # Pass output_format (which might be None for merged maps)\r\n+ image_path_str = reconstruct_image_path_with_fallback(\r\n+ asset_dir_path=asset_dir_path,\r\n+ asset_name=asset_name,\r\n+ map_type=map_type,\r\n+ resolution=resolution,\r\n+ primary_format=output_format\r\n+ )\r\n+ print(f\" DEBUG: Reconstructed image path for {map_type}/{resolution}: {image_path_str}\") # DEBUG LOG (Indented)\r\n+\r\n+ if not image_path_str:\r\n+ # Error already printed by reconstruct function\r\n+ errors_encountered += 1\r\n+ continue # Skip this resolution if path not found\r\n+\r\n+ # Find image texture node within the CHILD group (labeled by resolution, e.g., \"4K\")\r\n+ image_nodes = find_nodes_by_label(child_group, resolution, 'ShaderNodeTexImage')\r\n+ if not image_nodes:\r\n+ print(f\" !!! WARNING: No Image Texture node labeled '{resolution}' found in child group '{child_group.name}'. Cannot assign image.\")\r\n+ continue # Skip this resolution if node not found\r\n+ print(f\" DEBUG: Found {len(image_nodes)} image node(s) labeled '{resolution}' in child group '{child_group.name}'.\") # DEBUG LOG (Indented)\r\n+\r\n+ # --- Load Image ---\r\n+ img = None\r\n+ image_load_failed = False\r\n+ try:\r\n+ image_path = Path(image_path_str) # Path object created from already found path string\r\n+ # Use check_existing=True to reuse existing datablocks if path matches\r\n+ img = bpy.data.images.load(str(image_path), check_existing=True)\r\n+ if not img:\r\n+ print(f\" !!! ERROR: Failed loading image via bpy.data.images.load: {image_path_str}\")\r\n+ image_load_failed = True\r\n+ else:\r\n+ # Only count as loaded if bpy.data.images.load succeeded\r\n+ # Check if it's newly loaded or reused\r\n+ is_newly_loaded = img.library is None # Newly loaded images don't have a library initially\r\n+ if is_newly_loaded: images_loaded += 1\r\n+\r\n+ except RuntimeError as e_runtime_load:\r\n+ # Catch specific Blender runtime errors (e.g., unsupported format)\r\n+ print(f\" !!! ERROR loading image '{image_path_str}': {e_runtime_load}\")\r\n+ image_load_failed = True\r\n+ except Exception as e_gen_load:\r\n+ print(f\" !!! UNEXPECTED ERROR loading image '{image_path_str}': {e_gen_load}\")\r\n+ image_load_failed = True\r\n+ errors_encountered += 1\r\n+\r\n+ # --- Assign Image & Set Color Space ---\r\n+ if not image_load_failed and img:\r\n+ assigned_count_this_res = 0\r\n+ for image_node in image_nodes:\r\n+ if image_node.image != img:\r\n+ image_node.image = img\r\n+ assigned_count_this_res += 1\r\n+\r\n+ if assigned_count_this_res > 0:\r\n+ images_assigned += assigned_count_this_res\r\n+ print(f\" Assigned image '{img.name}' to {assigned_count_this_res} node(s).\")\r\n+\r\n+ # Set Color Space\r\n+ correct_color_space = get_color_space(map_type)\r\n+ try:\r\n+ if img.colorspace_settings.name != correct_color_space:\r\n+ img.colorspace_settings.name = correct_color_space\r\n+ print(f\" Set '{img.name}' color space -> {correct_color_space}\")\r\n+ except TypeError as e_cs: # Handle case where colorspace name is invalid\r\n+ print(f\" !!! WARNING: Could not set color space '{correct_color_space}' for image '{img.name}'. Is the color space available in Blender? Error: {e_cs}\")\r\n+ except Exception as e_cs_gen:\r\n+ print(f\" !!! ERROR setting color space for image '{img.name}': {e_cs_gen}\")\r\n+\r\n+\r\n+ # --- Update Manifest (Map/Resolution Level) ---\r\n+ if update_manifest(manifest_data, asset_name, map_type, resolution):\r\n+ manifest_needs_saving = True\r\n+ # print(f\" Marked {map_type}/{resolution} processed in manifest.\") # Verbose\r\n+ maps_processed += 1\r\n+\r\n+ else:\r\n+ # Increment error count if loading failed\r\n+ if image_load_failed: errors_encountered += 1\r\n+\r\n+ # --- End Resolution Loop ---\r\n+ # --- End Map Type Loop ---\r\n+\r\n+ assets_processed += 1\r\n+\r\n+ except FileNotFoundError:\r\n+ print(f\" !!! ERROR: Metadata file not found (should not happen if scan worked): {metadata_path}\")\r\n+ errors_encountered += 1\r\n+ except json.JSONDecodeError:\r\n+ print(f\" !!! ERROR: Invalid JSON in metadata file: {metadata_path}\")\r\n+ errors_encountered += 1\r\n+ except Exception as e_main_loop:\r\n+ print(f\" !!! UNEXPECTED ERROR processing asset from {metadata_path}: {e_main_loop}\")\r\n+ import traceback\r\n+ traceback.print_exc() # Print detailed traceback for debugging\r\n+ errors_encountered += 1\r\n+ # Continue to the next asset\r\n+\r\n+ # --- End Metadata File Loop ---\r\n+\r\n+ # --- Final Manifest Save ---\r\n+ if ENABLE_MANIFEST and manifest_needs_saving:\r\n+ print(\"\\nAttempting final manifest save...\")\r\n+ save_manifest(context, manifest_data)\r\n+ elif ENABLE_MANIFEST:\r\n+ print(\"\\nManifest is enabled, but no changes require saving.\")\r\n+ # --- End Final Manifest Save ---\r\n+\r\n+ # --- Final Summary ---\r\n+ end_time = time.time()\r\n+ duration = end_time - start_time\r\n+ print(\"\\n--- Script Run Finished ---\")\r\n+ print(f\"Duration: {duration:.2f} seconds\")\r\n+ print(f\"Metadata Files Found: {metadata_files_found}\")\r\n+ print(f\"Assets Processed/Attempted: {assets_processed}\")\r\n+ if ENABLE_MANIFEST:\r\n+ print(f\"Assets Skipped (Manifest): {assets_skipped_manifest}\")\r\n+ print(f\"Maps Skipped (Manifest): {maps_skipped_manifest}\")\r\n+ print(f\"Parent Groups Created: {parent_groups_created}\")\r\n+ print(f\"Parent Groups Updated: {parent_groups_updated}\")\r\n+ print(f\"Child Groups Created: {child_groups_created}\")\r\n+ print(f\"Child Groups Updated: {child_groups_updated}\")\r\n+ print(f\"Images Loaded: {images_loaded}\")\r\n+ print(f\"Image Nodes Assigned: {images_assigned}\")\r\n+ print(f\"Individual Maps Processed: {maps_processed}\")\r\n+ print(f\"Asset Previews Set: {previews_set}\")\r\n+ print(f\"Highest Resolution Nodes Set: {highest_res_set}\")\r\n+ print(f\"Aspect Ratio Nodes Set: {aspect_ratio_set}\") # Added counter\r\n+ if errors_encountered > 0:\r\n+ print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n+ print(\"---------------------------\")\r\n+\r\n+ # --- Explicit Save ---\r\n+ print(f\" DEBUG: Attempting explicit save for file: {bpy.data.filepath}\") # DEBUG LOG (Indented)\r\n+ try:\r\n+ bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)\r\n+ print(\"\\n--- Explicitly saved the .blend file. ---\")\r\n+ except Exception as e_save:\r\n+ print(f\"\\n!!! ERROR explicitly saving .blend file: {e_save} !!!\")\r\n+ errors_encountered += 1 # Count save errors\r\n+\r\n+ return True\r\n+\r\n+\r\n+# --- Execution Block ---\r\n+\r\n+if __name__ == \"__main__\":\r\n+ # Ensure we are running within Blender\r\n+ try:\r\n+ import bpy\r\n+ import base64 # Ensure base64 is imported here too if needed globally\r\n+ import sys\r\n+ except ImportError:\r\n+ print(\"!!! ERROR: This script must be run from within Blender. !!!\")\r\n+ else:\r\n+ # --- Argument Parsing for Asset Library Root ---\r\n+ asset_root_arg = None\r\n+ try:\r\n+ # Blender arguments passed after '--' appear in sys.argv\r\n+ if \"--\" in sys.argv:\r\n+ args_after_dash = sys.argv[sys.argv.index(\"--\") + 1:]\r\n+ if len(args_after_dash) >= 1:\r\n+ asset_root_arg = args_after_dash[0]\r\n+ print(f\"Found asset library root argument: {asset_root_arg}\")\r\n+ else:\r\n+ print(\"Info: '--' found but no arguments after it.\")\r\n+ # else: print(\"Info: No '--' found in arguments.\") # Optional debug\r\n+ except Exception as e:\r\n+ print(f\"Error parsing command line arguments: {e}\")\r\n+ # --- End Argument Parsing ---\r\n+\r\n+ process_library(bpy.context, asset_library_root_override=asset_root_arg)\r\n" }, { "date": 1745346278448, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -492,9 +492,9 @@\n # --- Extract Key Info ---\r\n asset_name = metadata.get(\"asset_name\")\r\n supplier_name = metadata.get(\"supplier_name\")\r\n archetype = metadata.get(\"archetype\")\r\n- category = metadata.get(\"category\", \"Unknown\") # <<< ADDED: Extract category, default to \"Unknown\"\r\n+ asset_category = metadata.get(\"asset_category\", \"Unknown\") # Read asset_category instead of category\r\n # Get map info from the correct keys\r\n processed_resolutions = metadata.get(\"processed_map_resolutions\", {}) # Default to empty dict\r\n merged_resolutions = metadata.get(\"merged_map_resolutions\", {}) # Get merged maps too\r\n map_details = metadata.get(\"map_details\", {}) # Default to empty dict\r\n@@ -636,16 +636,16 @@\n if supplier_name:\r\n add_tag_if_new(parent_group.asset_data, supplier_name)\r\n if archetype:\r\n add_tag_if_new(parent_group.asset_data, archetype)\r\n- if category: # <<< ADDED: Add category tag\r\n- add_tag_if_new(parent_group.asset_data, category)\r\n+ if asset_category: # Use asset_category for tagging\r\n+ add_tag_if_new(parent_group.asset_data, asset_category)\r\n # Add other tags if needed\r\n # else: print(f\" Warn: Cannot apply tags, asset_data not available for '{parent_group.name}'.\") # Optional warning\r\n \r\n- # <<< ADDED: Conditional skip based on category >>>\r\n- if category not in CATEGORIES_FOR_NODEGROUP_GENERATION: # <<< MODIFIED: Use config variable\r\n- print(f\" Skipping nodegroup content generation for asset '{asset_name}' (Category: '{category}'). Tag added.\")\r\n+ # Conditional skip based on asset_category\r\n+ if asset_category not in CATEGORIES_FOR_NODEGROUP_GENERATION: # Check asset_category\r\n+ print(f\" Skipping nodegroup content generation for asset '{asset_name}' (Category: '{asset_category}'). Tag added.\") # Use asset_category in log\r\n assets_processed += 1 # Still count as processed for summary, even if skipped\r\n continue # Skip the rest of the processing for this asset\r\n \r\n # Apply Aspect Ratio Correction\r\n@@ -1028,1035 +1028,4 @@\n print(f\"Error parsing command line arguments: {e}\")\r\n # --- End Argument Parsing ---\r\n \r\n process_library(bpy.context, asset_library_root_override=asset_root_arg)\r\n-# Blender Script: Create/Update Node Groups from Asset Processor Output\r\n-# Version: 1.5\r\n-# Description: Scans a library processed by the Asset Processor Tool,\r\n-# reads metadata.json files, and creates/updates corresponding\r\n-# PBR node groups in the active Blender file.\r\n-# Changes v1.5:\r\n-# - Corrected aspect ratio calculation (`calculate_aspect_correction_factor`)\r\n-# to use actual image dimensions from a loaded reference image and the\r\n-# `aspect_ratio_change_string`, mirroring original script logic for\r\n-# \"EVEN\", \"Xnnn\", \"Ynnn\" formats.\r\n-# - Added logic in main loop to load reference image for dimensions.\r\n-# Changes v1.3:\r\n-# - Added logic to find the highest resolution present for an asset.\r\n-# - Added logic to set a \"HighestResolution\" Value node in the parent group\r\n-# (maps 1K->1.0, 2K->2.0, 4K->3.0, 8K->4.0).\r\n-# Changes v1.2:\r\n-# - Added Base64 encoding for child node group names (PBRTYPE_...).\r\n-# - Added fallback logic for reconstructing image paths with different extensions.\r\n-# - Added logic to set custom asset preview for new parent groups (using lowest res COL map).\r\n-# Changes v1.1:\r\n-# - Updated metadata parsing to match actual structure (using processed_map_resolutions, image_stats_1k, map_details).\r\n-# - Added logic to reconstruct image file paths based on metadata and assumed naming convention.\r\n-\r\n-import bpy\r\n-import os\r\n-import json\r\n-from pathlib import Path\r\n-import time\r\n-import re # For parsing aspect ratio string\r\n-import base64 # For encoding node group names\r\n-import sys # <<< ADDED IMPORT\r\n-\r\n-# --- USER CONFIGURATION ---\r\n-\r\n-# Path to the root output directory of the Asset Processor Tool\r\n-# Example: r\"G:\\Assets\\Processed\"\r\n-# IMPORTANT: This should point to the base directory containing supplier folders (e.g., Poliigon)\r\n-# This will be overridden by command-line arguments if provided.\r\n-PROCESSED_ASSET_LIBRARY_ROOT = None # Set to None initially\r\n-\r\n-# Names of the required node group templates in the Blender file\r\n-PARENT_TEMPLATE_NAME = \"Template_PBRSET\"\r\n-CHILD_TEMPLATE_NAME = \"Template_PBRTYPE\"\r\n-\r\n-# Labels of specific nodes within the PARENT template\r\n-ASPECT_RATIO_NODE_LABEL = \"AspectRatioCorrection\" # Value node for UV X-scaling factor\r\n-STATS_NODE_PREFIX = \"Histogram-\" # Prefix for Combine XYZ nodes storing stats (e.g., \"Histogram-ROUGH\")\r\n-HIGHEST_RESOLUTION_NODE_LABEL = \"HighestResolution\" # Value node to store highest res index\r\n-\r\n-# Enable/disable the manifest system to track processed assets/maps\r\n-# If enabled, requires the blend file to be saved.\r\n-ENABLE_MANIFEST = False # Disabled based on user feedback in previous run\r\n-\r\n-# Assumed filename pattern for processed images.\r\n-# {asset_name}, {map_type}, {resolution}, {format} will be replaced.\r\n-# Check Asset Processor Tool's config.py (TARGET_FILENAME_PATTERN) if this is wrong.\r\n-IMAGE_FILENAME_PATTERN = \"{asset_name}_{map_type}_{resolution}.{format}\"\r\n-\r\n-# Fallback extensions to try if the primary format from metadata is not found\r\n-# Order matters - first found will be used.\r\n-FALLBACK_IMAGE_EXTENSIONS = ['png', 'jpg', 'exr', 'tif']\r\n-\r\n-# Map type(s) to use for generating the asset preview AND for aspect ratio calculation reference\r\n-# The script will look for these in order and use the first one found.\r\n-REFERENCE_MAP_TYPES = [\"COL\", \"COL-1\", \"COL-2\"] # Used for preview and aspect calc\r\n-# Preferred resolution order for reference image (lowest first is often faster)\r\n-REFERENCE_RESOLUTION_ORDER = [\"1K\", \"512\", \"2K\", \"4K\"] # Adjust as needed\r\n-\r\n-# Mapping from resolution string to numerical value for the HighestResolution node\r\n-RESOLUTION_VALUE_MAP = {\"1K\": 1.0, \"2K\": 2.0, \"4K\": 3.0, \"8K\": 4.0}\r\n-# Order to check resolutions to find the highest present (highest value first)\r\n-RESOLUTION_ORDER_DESC = [\"8K\", \"4K\", \"2K\", \"1K\"] # Add others like \"512\" if needed and map them in RESOLUTION_VALUE_MAP\r\n-\r\n-# Map PBR type strings (from metadata) to Blender color spaces\r\n-# Add more mappings as needed based on your metadata types\r\n-PBR_COLOR_SPACE_MAP = {\r\n- \"AO\": \"Non-Color\", # Usually Non-Color, but depends on workflow\r\n- \"COL\": \"sRGB\",\r\n- \"COL-1\": \"sRGB\", # Handle variants if present in metadata\r\n- \"COL-2\": \"sRGB\",\r\n- \"COL-3\": \"sRGB\",\r\n- \"DISP\": \"Non-Color\",\r\n- \"NRM\": \"Non-Color\",\r\n- \"REFL\": \"Non-Color\", # Reflection/Specular\r\n- \"ROUGH\": \"Non-Color\",\r\n- \"METAL\": \"Non-Color\",\r\n- \"OPC\": \"Non-Color\", # Opacity/Alpha\r\n- \"TRN\": \"Non-Color\", # Transmission\r\n- \"SSS\": \"sRGB\", # Subsurface Color\r\n- \"EMISS\": \"sRGB\", # Emission Color\r\n- \"NRMRGH\": \"Non-Color\", # Added for merged map\r\n- \"FUZZ\": \"Non-Color\",\r\n- # Add other types like GLOSS, HEIGHT, etc. if needed\r\n-}\r\n-DEFAULT_COLOR_SPACE = \"sRGB\" # Fallback if map type not in the dictionary\r\n-\r\n-# Map types for which stats should be applied (if found in metadata and node exists)\r\n-# Reads stats from the 'image_stats_1k' section of metadata.json\r\n-APPLY_STATS_FOR_MAP_TYPES = [\"ROUGH\", \"DISP\", \"METAL\", \"AO\", \"REFL\"] # Add others if needed\r\n-\r\n-# Categories for which full nodegroup generation should occur\r\n-CATEGORIES_FOR_NODEGROUP_GENERATION = [\"Surface\", \"Decal\"]\r\n-\r\n-# --- END USER CONFIGURATION ---\r\n-\r\n-\r\n-# --- Helper Functions ---\r\n-\r\n-def encode_name_b64(name_str):\r\n- \"\"\"Encodes a string using URL-safe Base64 for node group names.\"\"\"\r\n- try:\r\n- # Ensure the input is a string\r\n- name_str = str(name_str)\r\n- return base64.urlsafe_b64encode(name_str.encode('utf-8')).decode('ascii')\r\n- except Exception as e:\r\n- print(f\" Error base64 encoding '{name_str}': {e}\")\r\n- return name_str # Fallback to original name on error\r\n-\r\n-def find_nodes_by_label(node_tree, label, node_type=None):\r\n- \"\"\"Finds ALL nodes in a node tree matching the label and optionally type.\"\"\"\r\n- if not node_tree:\r\n- return []\r\n- matching_nodes = []\r\n- for node in node_tree.nodes:\r\n- # Use node.label for labeled nodes, node.name for non-labeled (like Group Input/Output)\r\n- node_identifier = node.label if node.label else node.name\r\n- if node_identifier and node_identifier == label:\r\n- if node_type is None or node.bl_idname == node_type or node.type == node_type: # Check bl_idname and type for flexibility\r\n- matching_nodes.append(node)\r\n- return matching_nodes\r\n-\r\n-def add_tag_if_new(asset_data, tag_name):\r\n- \"\"\"Adds a tag to the asset data if it's not None/empty and doesn't already exist.\"\"\"\r\n- if not asset_data or not tag_name or not isinstance(tag_name, str):\r\n- return False\r\n- cleaned_tag_name = tag_name.strip()\r\n- if not cleaned_tag_name:\r\n- return False\r\n-\r\n- # Check if tag already exists (case-insensitive check might be better sometimes)\r\n- if cleaned_tag_name not in [t.name for t in asset_data.tags]:\r\n- try:\r\n- asset_data.tags.new(cleaned_tag_name)\r\n- print(f\" + Added Asset Tag: '{cleaned_tag_name}'\")\r\n- return True\r\n- except Exception as e:\r\n- print(f\" Error adding tag '{cleaned_tag_name}': {e}\")\r\n- return False\r\n- return False # Tag already existed\r\n-\r\n-def get_color_space(map_type):\r\n- \"\"\"Returns the appropriate Blender color space name for a given map type string.\"\"\"\r\n- # Handle potential numbered variants like COL-1, COL-2\r\n- base_map_type = map_type.split('-')[0]\r\n- return PBR_COLOR_SPACE_MAP.get(map_type.upper(), # Check full name first (e.g., NRMRGH)\r\n- PBR_COLOR_SPACE_MAP.get(base_map_type.upper(), DEFAULT_COLOR_SPACE)) # Fallback to base type\r\n-\r\n-def calculate_aspect_correction_factor(image_width, image_height, aspect_string):\r\n- \"\"\"\r\n- Calculates the UV X-axis scaling factor needed to correct distortion,\r\n- based on image dimensions and the aspect_ratio_change_string (\"EVEN\", \"Xnnn\", \"Ynnn\").\r\n- Mirrors the logic from the original POC script.\r\n- Returns 1.0 if dimensions are invalid or string is \"EVEN\" or invalid.\r\n- \"\"\"\r\n- if image_height <= 0 or image_width <= 0:\r\n- print(\" Warn: Invalid image dimensions for aspect ratio calculation. Returning 1.0.\")\r\n- return 1.0\r\n-\r\n- # Calculate the actual aspect ratio of the image file\r\n- current_aspect_ratio = image_width / image_height\r\n-\r\n- if not aspect_string or aspect_string.upper() == \"EVEN\":\r\n- # If scaling was even, the correction factor is just the image's aspect ratio\r\n- # to make UVs match the image proportions.\r\n- # print(f\" Aspect string is EVEN. Correction factor = current aspect ratio: {current_aspect_ratio:.4f}\")\r\n- return current_aspect_ratio\r\n-\r\n- # Handle non-uniform scaling cases (\"Xnnn\", \"Ynnn\")\r\n- # Use search instead of match to find anywhere in string (though unlikely needed based on format)\r\n- match = re.search(r\"([XY])(\\d+)\", aspect_string, re.IGNORECASE)\r\n- if not match:\r\n- print(f\" Warn: Invalid Scaling string format '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f} as fallback.\")\r\n- return current_aspect_ratio # Fallback to the image's own ratio\r\n-\r\n- axis = match.group(1).upper()\r\n- try:\r\n- amount = int(match.group(2))\r\n- if amount <= 0:\r\n- print(f\" Warn: Zero or negative Amount in Scaling string '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n- return current_aspect_ratio\r\n- except ValueError:\r\n- print(f\" Warn: Invalid Amount in Scaling string '{aspect_string}'. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n- return current_aspect_ratio\r\n-\r\n- # Apply the non-uniform correction formula based on original script logic\r\n- scaling_factor_percent = amount / 100.0\r\n- correction_factor = current_aspect_ratio # Default\r\n-\r\n- try:\r\n- if axis == 'X':\r\n- if scaling_factor_percent == 0: raise ZeroDivisionError(\"X scaling factor is zero\")\r\n- # If image was stretched horizontally (X > 1), divide UV.x by factor\r\n- correction_factor = current_aspect_ratio / scaling_factor_percent\r\n- elif axis == 'Y':\r\n- # If image was stretched vertically (Y > 1), multiply UV.x by factor\r\n- correction_factor = current_aspect_ratio * scaling_factor_percent\r\n- # No 'else' needed as regex ensures X or Y\r\n-\r\n- except ZeroDivisionError as e:\r\n- print(f\" Warn: Division by zero during aspect factor calculation ({e}). Returning current ratio {current_aspect_ratio:.4f}.\")\r\n- return current_aspect_ratio\r\n- except Exception as e:\r\n- print(f\" Error calculating aspect correction factor: {e}. Returning current ratio {current_aspect_ratio:.4f}.\")\r\n- return current_aspect_ratio\r\n-\r\n- # print(f\" Calculated aspect correction factor: {correction_factor:.4f} (from {image_width}x{image_height}, Scaling='{aspect_string}')\")\r\n- return correction_factor\r\n-\r\n-\r\n-def reconstruct_image_path_with_fallback(asset_dir_path, asset_name, map_type, resolution, primary_format=None):\r\n- \"\"\"\r\n- Constructs the expected image file path.\r\n- If primary_format is provided, tries that first.\r\n- Then falls back to common extensions if the path doesn't exist or primary_format was None.\r\n- Returns the found path as a string, or None if not found.\r\n- \"\"\"\r\n- if not all([asset_dir_path, asset_name, map_type, resolution]):\r\n- print(f\" !!! ERROR: Missing data for path reconstruction ({asset_name}/{map_type}/{resolution}).\")\r\n- return None\r\n-\r\n- found_path = None\r\n-\r\n- # 1. Try the primary format if provided\r\n- if primary_format:\r\n- try:\r\n- filename = IMAGE_FILENAME_PATTERN.format(\r\n- asset_name=asset_name,\r\n- map_type=map_type,\r\n- resolution=resolution,\r\n- format=primary_format.lower() # Ensure format is lowercase\r\n- )\r\n- primary_path = asset_dir_path / filename\r\n- if primary_path.is_file():\r\n- # print(f\" Found primary path: {str(primary_path)}\") # Verbose\r\n- return str(primary_path)\r\n- # else: print(f\" Primary path not found: {str(primary_path)}\") # Verbose\r\n- except KeyError as e:\r\n- print(f\" !!! ERROR: Missing key '{e}' in IMAGE_FILENAME_PATTERN. Cannot reconstruct path.\")\r\n- return None # Cannot proceed without valid pattern\r\n- except Exception as e:\r\n- print(f\" !!! ERROR reconstructing primary image path: {e}\")\r\n- # Continue to fallback\r\n-\r\n- # 2. Try fallback extensions\r\n- # print(f\" Trying fallback extensions for {map_type}/{resolution}...\") # Verbose\r\n- for ext in FALLBACK_IMAGE_EXTENSIONS:\r\n- # Skip if we already tried this extension as primary (and it failed)\r\n- if primary_format and ext.lower() == primary_format.lower():\r\n- continue\r\n- try:\r\n- fallback_filename = IMAGE_FILENAME_PATTERN.format(\r\n- asset_name=asset_name,\r\n- map_type=map_type,\r\n- resolution=resolution,\r\n- format=ext.lower()\r\n- )\r\n- fallback_path = asset_dir_path / fallback_filename\r\n- if fallback_path.is_file():\r\n- print(f\" Found fallback path: {str(fallback_path)}\")\r\n- return str(fallback_path) # Found it!\r\n- except KeyError:\r\n- # Should not happen if primary format worked, but handle defensively\r\n- print(f\" !!! ERROR: Missing key in IMAGE_FILENAME_PATTERN during fallback. Cannot reconstruct path.\")\r\n- return None\r\n- except Exception as e_fallback:\r\n- print(f\" !!! ERROR reconstructing fallback image path ({ext}): {e_fallback}\")\r\n- continue # Try next extension\r\n-\r\n- # If we get here, neither primary nor fallbacks worked\r\n- if primary_format:\r\n- print(f\" !!! ERROR: Could not find image file for {map_type}/{resolution} using primary format '{primary_format}' or fallbacks {FALLBACK_IMAGE_EXTENSIONS}.\")\r\n- else:\r\n- print(f\" !!! ERROR: Could not find image file for {map_type}/{resolution} using fallbacks {FALLBACK_IMAGE_EXTENSIONS}.\")\r\n- return None # Not found after all checks\r\n-\r\n-\r\n-# --- Manifest Functions ---\r\n-\r\n-def get_manifest_path(context):\r\n- \"\"\"Gets the expected path for the manifest JSON file.\"\"\"\r\n- if not context or not context.blend_data or not context.blend_data.filepath:\r\n- return None # Cannot determine path if blend file is not saved\r\n- blend_path = Path(context.blend_data.filepath)\r\n- manifest_filename = f\"{blend_path.stem}_manifest.json\"\r\n- return blend_path.parent / manifest_filename\r\n-\r\n-def load_manifest(context):\r\n- \"\"\"Loads the manifest data from the JSON file.\"\"\"\r\n- if not ENABLE_MANIFEST:\r\n- return {} # Manifest disabled\r\n-\r\n- manifest_path = get_manifest_path(context)\r\n- if not manifest_path:\r\n- print(\" Manifest Info: Blend file not saved. Cannot load manifest.\")\r\n- return {} # Cannot load without a path\r\n-\r\n- if not manifest_path.exists():\r\n- print(f\" Manifest Info: No manifest file found at '{manifest_path.name}'. Starting fresh.\")\r\n- return {} # No manifest file exists yet\r\n-\r\n- try:\r\n- with open(manifest_path, 'r', encoding='utf-8') as f:\r\n- data = json.load(f)\r\n- print(f\" Manifest Loaded from: {manifest_path.name}\")\r\n- # Basic validation (check if it's a dictionary)\r\n- if not isinstance(data, dict):\r\n- print(f\"!!! WARNING: Manifest file '{manifest_path.name}' has invalid format (not a dictionary). Starting fresh. !!!\")\r\n- return {}\r\n- return data\r\n- except json.JSONDecodeError:\r\n- print(f\"!!! WARNING: Manifest file '{manifest_path.name}' is corrupted. Starting fresh. !!!\")\r\n- return {}\r\n- except Exception as e:\r\n- print(f\"!!! ERROR: Could not load manifest file '{manifest_path.name}': {e} !!!\")\r\n- return {} # Treat as starting fresh on error\r\n-\r\n-def save_manifest(context, manifest_data):\r\n- \"\"\"Saves the manifest data to the JSON file.\"\"\"\r\n- if not ENABLE_MANIFEST or not manifest_data: # Don't save if disabled or empty\r\n- return False\r\n-\r\n- manifest_path = get_manifest_path(context)\r\n- if not manifest_path:\r\n- print(\" Manifest Error: Blend file not saved. Cannot save manifest.\")\r\n- return False\r\n-\r\n- try:\r\n- with open(manifest_path, 'w', encoding='utf-8') as f:\r\n- json.dump(manifest_data, f, indent=2, sort_keys=True) # Use indent and sort for readability\r\n- print(f\" Manifest Saved to: {manifest_path.name}\")\r\n- return True\r\n- except Exception as e:\r\n- print(f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\\n\"\r\n- f\"!!! Manifest save FAILED to '{manifest_path.name}': {e} !!!\\n\"\r\n- f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\")\r\n- return False\r\n-\r\n-def is_asset_processed(manifest_data, asset_name):\r\n- \"\"\"Checks if the entire asset (all its maps/resolutions) is marked as processed.\"\"\"\r\n- if not ENABLE_MANIFEST: return False\r\n- # Basic check if asset entry exists. Detailed check happens at map level.\r\n- return asset_name in manifest_data\r\n-\r\n-def is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- \"\"\"Checks if a specific map type and resolution for an asset is processed.\"\"\"\r\n- if not ENABLE_MANIFEST: return False\r\n- return resolution in manifest_data.get(asset_name, {}).get(map_type, [])\r\n-\r\n-def update_manifest(manifest_data, asset_name, map_type=None, resolution=None):\r\n- \"\"\"Updates the manifest dictionary in memory.\"\"\"\r\n- if not ENABLE_MANIFEST: return False\r\n-\r\n- # Ensure asset entry exists\r\n- if asset_name not in manifest_data:\r\n- manifest_data[asset_name] = {}\r\n-\r\n- # If map_type and resolution are provided, update the specific map entry\r\n- if map_type and resolution:\r\n- if map_type not in manifest_data[asset_name]:\r\n- manifest_data[asset_name][map_type] = []\r\n-\r\n- if resolution not in manifest_data[asset_name][map_type]:\r\n- manifest_data[asset_name][map_type].append(resolution)\r\n- manifest_data[asset_name][map_type].sort() # Keep sorted\r\n- return True # Indicate that a change was made\r\n- return False # No change made to this specific map/res\r\n-\r\n-\r\n-# --- Core Logic ---\r\n-\r\n-def process_library(context, asset_library_root_override=None): # Add override parameter\r\n- global ENABLE_MANIFEST # Declare intent to modify global if needed\r\n- global PROCESSED_ASSET_LIBRARY_ROOT # Allow modification of global\r\n- \"\"\"Scans the library, reads metadata, creates/updates node groups.\"\"\"\r\n- start_time = time.time()\r\n- print(f\"\\n--- Starting Node Group Processing ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---\")\r\n- print(f\" DEBUG: Received asset_library_root_override: {asset_library_root_override}\") # DEBUG LOG (Indented)\r\n-\r\n- # --- Determine Asset Library Root ---\r\n- if asset_library_root_override:\r\n- PROCESSED_ASSET_LIBRARY_ROOT = asset_library_root_override\r\n- print(f\"Using asset library root from argument: '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n- elif not PROCESSED_ASSET_LIBRARY_ROOT:\r\n- print(\"!!! ERROR: Processed asset library root not set in script and not provided via argument.\")\r\n- print(\"--- Script aborted. ---\")\r\n- return False\r\n- print(f\" DEBUG: Using final PROCESSED_ASSET_LIBRARY_ROOT: {PROCESSED_ASSET_LIBRARY_ROOT}\") # DEBUG LOG (Indented)\r\n-\r\n- # --- Pre-run Checks ---\r\n- print(\"Performing pre-run checks...\")\r\n- valid_setup = True\r\n- # 1. Check Library Root Path\r\n- root_path = Path(PROCESSED_ASSET_LIBRARY_ROOT)\r\n- if not root_path.is_dir():\r\n- print(f\"!!! ERROR: Processed asset library root directory not found or not a directory:\")\r\n- print(f\"!!! '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n- valid_setup = False\r\n- else:\r\n- print(f\" Asset Library Root: '{root_path}'\")\r\n- print(f\" DEBUG: Checking for templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\") # DEBUG LOG (Indented)\r\n-\r\n- # 2. Check Templates\r\n- template_parent = bpy.data.node_groups.get(PARENT_TEMPLATE_NAME)\r\n- template_child = bpy.data.node_groups.get(CHILD_TEMPLATE_NAME)\r\n- if not template_parent:\r\n- print(f\"!!! ERROR: Parent template node group '{PARENT_TEMPLATE_NAME}' not found in this Blender file.\")\r\n- valid_setup = False\r\n- if not template_child:\r\n- print(f\"!!! ERROR: Child template node group '{CHILD_TEMPLATE_NAME}' not found in this Blender file.\")\r\n- valid_setup = False\r\n- if template_parent and template_child:\r\n- print(f\" Found Templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\")\r\n- print(f\" DEBUG: Template Parent Found: {template_parent is not None}\") # DEBUG LOG (Indented)\r\n- print(f\" DEBUG: Template Child Found: {template_child is not None}\") # DEBUG LOG (Indented)\r\n-\r\n- # 3. Check Blend File Saved (if manifest enabled)\r\n- if ENABLE_MANIFEST and not context.blend_data.filepath:\r\n- print(f\"!!! WARNING: Manifest is enabled, but the current Blender file is not saved.\")\r\n- print(f\"!!! Manifest cannot be loaded or saved. Processing will continue without manifest checks.\")\r\n- ENABLE_MANIFEST = False # Disable manifest for this run\r\n-\r\n- if not valid_setup:\r\n- print(\"\\n--- Script aborted due to configuration errors. Please fix the issues above. ---\")\r\n- return False\r\n- print(\"Pre-run checks passed.\")\r\n- # --- End Pre-run Checks ---\r\n-\r\n- manifest_data = load_manifest(context)\r\n- manifest_needs_saving = False\r\n-\r\n- # --- Initialize Counters ---\r\n- metadata_files_found = 0\r\n- assets_processed = 0\r\n- assets_skipped_manifest = 0\r\n- parent_groups_created = 0\r\n- parent_groups_updated = 0\r\n- child_groups_created = 0\r\n- child_groups_updated = 0\r\n- images_loaded = 0\r\n- images_assigned = 0\r\n- maps_processed = 0\r\n- maps_skipped_manifest = 0\r\n- errors_encountered = 0\r\n- previews_set = 0\r\n- highest_res_set = 0\r\n- aspect_ratio_set = 0\r\n- # --- End Counters ---\r\n-\r\n- print(f\"\\nScanning for metadata files in '{root_path}'...\")\r\n-\r\n- # --- Scan for metadata.json ---\r\n- # Scan one level deeper for supplier folders (e.g., Poliigon)\r\n- # Then scan within each supplier for asset folders containing metadata.json\r\n- metadata_paths = []\r\n- for supplier_dir in root_path.iterdir():\r\n- if supplier_dir.is_dir():\r\n- # Now look for asset folders inside the supplier directory\r\n- for asset_dir in supplier_dir.iterdir():\r\n- if asset_dir.is_dir():\r\n- metadata_file = asset_dir / 'metadata.json'\r\n- if metadata_file.is_file():\r\n- metadata_paths.append(metadata_file)\r\n-\r\n- metadata_files_found = len(metadata_paths)\r\n- print(f\"Found {metadata_files_found} metadata.json files.\")\r\n- print(f\" DEBUG: Metadata paths found: {metadata_paths}\") # DEBUG LOG (Indented)\r\n-\r\n- if metadata_files_found == 0:\r\n- print(\"No metadata files found. Nothing to process.\")\r\n- print(\"--- Script Finished ---\")\r\n- return True # No work needed is considered success\r\n-\r\n- # --- Process Each Metadata File ---\r\n- for metadata_path in metadata_paths:\r\n- asset_dir_path = metadata_path.parent # Get the directory containing the metadata file\r\n- print(f\"\\n--- Processing Metadata: {metadata_path.relative_to(root_path)} ---\")\r\n- print(f\" DEBUG: Processing file: {metadata_path}\") # DEBUG LOG (Indented)\r\n- try:\r\n- with open(metadata_path, 'r', encoding='utf-8') as f:\r\n- metadata = json.load(f)\r\n-\r\n- # --- Extract Key Info ---\r\n- asset_name = metadata.get(\"asset_name\")\r\n- supplier_name = metadata.get(\"supplier_name\")\r\n- archetype = metadata.get(\"archetype\")\r\n- category = metadata.get(\"category\", \"Unknown\") # <<< ADDED: Extract category, default to \"Unknown\"\r\n- # Get map info from the correct keys\r\n- processed_resolutions = metadata.get(\"processed_map_resolutions\", {}) # Default to empty dict\r\n- merged_resolutions = metadata.get(\"merged_map_resolutions\", {}) # Get merged maps too\r\n- map_details = metadata.get(\"map_details\", {}) # Default to empty dict\r\n- image_stats_1k = metadata.get(\"image_stats_1k\") # Dict: {map_type: {stats}}\r\n- aspect_string = metadata.get(\"aspect_ratio_change_string\")\r\n-\r\n- # Combine processed and merged maps for iteration\r\n- all_map_resolutions = {**processed_resolutions, **merged_resolutions}\r\n-\r\n- # Validate essential data\r\n- if not asset_name:\r\n- print(f\" !!! ERROR: Metadata file is missing 'asset_name'. Skipping.\")\r\n- errors_encountered += 1\r\n- continue\r\n- if not all_map_resolutions:\r\n- print(f\" !!! ERROR: Metadata file has no 'processed_map_resolutions' or 'merged_map_resolutions'. Skipping asset '{asset_name}'.\")\r\n- errors_encountered += 1\r\n- continue\r\n- # map_details check remains a warning as merged maps won't be in it\r\n- print(f\" DEBUG: Valid metadata loaded for asset: {asset_name}\") # DEBUG LOG (Indented)\r\n-\r\n- print(f\" Asset Name: {asset_name}\")\r\n-\r\n- # --- Determine Highest Resolution ---\r\n- highest_resolution_value = 0.0\r\n- highest_resolution_str = \"Unknown\"\r\n- all_resolutions_present = set()\r\n- if all_map_resolutions: # Check combined dict\r\n- for res_list in all_map_resolutions.values():\r\n- if isinstance(res_list, list):\r\n- all_resolutions_present.update(res_list)\r\n-\r\n- if all_resolutions_present:\r\n- for res_str in RESOLUTION_ORDER_DESC:\r\n- if res_str in all_resolutions_present:\r\n- highest_resolution_value = RESOLUTION_VALUE_MAP.get(res_str, 0.0)\r\n- highest_resolution_str = res_str\r\n- if highest_resolution_value > 0.0:\r\n- break # Found the highest valid resolution\r\n-\r\n- print(f\" Highest resolution found: {highest_resolution_str} (Value: {highest_resolution_value})\")\r\n-\r\n- # --- Load Reference Image for Aspect Ratio ---\r\n- ref_image_path = None\r\n- ref_image_width = 0\r\n- ref_image_height = 0\r\n- ref_image_loaded = False\r\n- # Use combined resolutions dict to find reference map\r\n- for ref_map_type in REFERENCE_MAP_TYPES:\r\n- if ref_map_type in all_map_resolutions:\r\n- available_resolutions = all_map_resolutions[ref_map_type]\r\n- lowest_res = None\r\n- for res_pref in REFERENCE_RESOLUTION_ORDER:\r\n- if res_pref in available_resolutions:\r\n- lowest_res = res_pref\r\n- break\r\n- if lowest_res:\r\n- # Get format from map_details if available, otherwise None\r\n- ref_map_details = map_details.get(ref_map_type, {})\r\n- ref_format = ref_map_details.get(\"output_format\")\r\n- ref_image_path = reconstruct_image_path_with_fallback(\r\n- asset_dir_path=asset_dir_path,\r\n- asset_name=asset_name,\r\n- map_type=ref_map_type,\r\n- resolution=lowest_res,\r\n- primary_format=ref_format # Pass None if not in map_details\r\n- )\r\n- if ref_image_path:\r\n- break # Found a suitable reference image path\r\n-\r\n- if ref_image_path:\r\n- print(f\" Loading reference image for aspect ratio: {Path(ref_image_path).name}\")\r\n- try:\r\n- # Load image temporarily\r\n- ref_img = bpy.data.images.load(ref_image_path, check_existing=True)\r\n- if ref_img:\r\n- ref_image_width = ref_img.size[0]\r\n- ref_image_height = ref_img.size[1]\r\n- ref_image_loaded = True\r\n- print(f\" Reference image dimensions: {ref_image_width}x{ref_image_height}\")\r\n- # Remove the temporary image datablock to save memory\r\n- bpy.data.images.remove(ref_img)\r\n- else:\r\n- print(f\" !!! ERROR: Failed loading reference image via bpy.data.images.load: {ref_image_path}\")\r\n- except Exception as e_ref_load:\r\n- print(f\" !!! ERROR loading reference image '{ref_image_path}': {e_ref_load}\")\r\n- else:\r\n- print(f\" !!! WARNING: Could not find suitable reference image ({REFERENCE_MAP_TYPES} at {REFERENCE_RESOLUTION_ORDER}) for aspect ratio calculation.\")\r\n-\r\n-\r\n- # --- Manifest Check (Asset Level - Basic) ---\r\n- if ENABLE_MANIFEST and is_asset_processed(manifest_data, asset_name):\r\n- # Perform a quick check if *any* map needs processing for this asset\r\n- needs_processing = False\r\n- for map_type, resolutions in all_map_resolutions.items(): # Check combined maps\r\n- for resolution in resolutions:\r\n- if not is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- needs_processing = True\r\n- break\r\n- if needs_processing:\r\n- break\r\n- if not needs_processing:\r\n- print(f\" Skipping asset '{asset_name}' (already fully processed according to manifest).\")\r\n- assets_skipped_manifest += 1\r\n- continue # Skip to next metadata file\r\n-\r\n- # --- Parent Group Handling ---\r\n- target_parent_name = f\"PBRSET_{asset_name}\"\r\n- parent_group = bpy.data.node_groups.get(target_parent_name)\r\n- is_new_parent = False\r\n-\r\n- if parent_group is None:\r\n- print(f\" Creating new parent group: '{target_parent_name}'\")\r\n- print(f\" DEBUG: Copying parent template '{PARENT_TEMPLATE_NAME}'\") # DEBUG LOG (Indented)\r\n- parent_group = template_parent.copy()\r\n- if not parent_group:\r\n- print(f\" !!! ERROR: Failed to copy parent template '{PARENT_TEMPLATE_NAME}'. Skipping asset '{asset_name}'.\")\r\n- errors_encountered += 1\r\n- continue\r\n- parent_group.name = target_parent_name\r\n- parent_groups_created += 1\r\n- is_new_parent = True\r\n- else:\r\n- print(f\" Updating existing parent group: '{target_parent_name}'\")\r\n- print(f\" DEBUG: Found existing parent group.\") # DEBUG LOG (Indented)\r\n- parent_groups_updated += 1\r\n-\r\n- # Ensure marked as asset\r\n- if not parent_group.asset_data:\r\n- try:\r\n- parent_group.asset_mark()\r\n- print(f\" Marked '{parent_group.name}' as asset.\")\r\n- except Exception as e_mark:\r\n- print(f\" !!! ERROR: Failed to mark group '{parent_group.name}' as asset: {e_mark}\")\r\n- # Continue processing other parts if possible\r\n-\r\n- # Apply Asset Tags\r\n- if parent_group.asset_data:\r\n- if supplier_name:\r\n- add_tag_if_new(parent_group.asset_data, supplier_name)\r\n- if archetype:\r\n- add_tag_if_new(parent_group.asset_data, archetype)\r\n- if category: # <<< ADDED: Add category tag\r\n- add_tag_if_new(parent_group.asset_data, category)\r\n- # Add other tags if needed\r\n- # else: print(f\" Warn: Cannot apply tags, asset_data not available for '{parent_group.name}'.\") # Optional warning\r\n-\r\n- # <<< ADDED: Conditional skip based on category >>>\r\n- if category not in CATEGORIES_FOR_NODEGROUP_GENERATION: # <<< MODIFIED: Use config variable\r\n- print(f\" Skipping nodegroup content generation for asset '{asset_name}' (Category: '{category}'). Tag added.\")\r\n- assets_processed += 1 # Still count as processed for summary, even if skipped\r\n- continue # Skip the rest of the processing for this asset\r\n-\r\n- # Apply Aspect Ratio Correction\r\n- aspect_nodes = find_nodes_by_label(parent_group, ASPECT_RATIO_NODE_LABEL, 'ShaderNodeValue')\r\n- if aspect_nodes:\r\n- aspect_node = aspect_nodes[0]\r\n- correction_factor = 1.0 # Default if ref image fails\r\n- if ref_image_loaded:\r\n- correction_factor = calculate_aspect_correction_factor(ref_image_width, ref_image_height, aspect_string)\r\n- print(f\" Calculated aspect correction factor: {correction_factor:.4f}\")\r\n- else:\r\n- print(f\" !!! WARNING: Using default aspect ratio correction (1.0) due to missing reference image.\")\r\n-\r\n- # Check if update is needed\r\n- current_val = aspect_node.outputs[0].default_value\r\n- if abs(current_val - correction_factor) > 0.0001:\r\n- aspect_node.outputs[0].default_value = correction_factor\r\n- print(f\" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f} (was {current_val:.4f})\")\r\n- aspect_ratio_set += 1\r\n- # else: print(f\" Warn: Aspect ratio node '{ASPECT_RATIO_NODE_LABEL}' not found in parent group.\") # Optional\r\n-\r\n- # Apply Highest Resolution Value\r\n- hr_nodes = find_nodes_by_label(parent_group, HIGHEST_RESOLUTION_NODE_LABEL, 'ShaderNodeValue')\r\n- if hr_nodes:\r\n- hr_node = hr_nodes[0]\r\n- current_hr_val = hr_node.outputs[0].default_value\r\n- if highest_resolution_value > 0.0 and abs(current_hr_val - highest_resolution_value) > 0.001:\r\n- hr_node.outputs[0].default_value = highest_resolution_value\r\n- print(f\" Set '{HIGHEST_RESOLUTION_NODE_LABEL}' value to {highest_resolution_value} ({highest_resolution_str}) (was {current_hr_val:.1f})\")\r\n- highest_res_set += 1 # Count successful sets\r\n- # else: print(f\" Warn: Highest resolution node '{HIGHEST_RESOLUTION_NODE_LABEL}' not found in parent group.\") # Optional\r\n-\r\n-\r\n- # Apply Stats (using image_stats_1k)\r\n- if image_stats_1k and isinstance(image_stats_1k, dict):\r\n- for map_type_to_stat in APPLY_STATS_FOR_MAP_TYPES:\r\n- if map_type_to_stat in image_stats_1k:\r\n- # Find the stats node in the parent group\r\n- stats_node_label = f\"{STATS_NODE_PREFIX}{map_type_to_stat}\"\r\n- stats_nodes = find_nodes_by_label(parent_group, stats_node_label, 'ShaderNodeCombineXYZ')\r\n- if stats_nodes:\r\n- stats_node = stats_nodes[0]\r\n- stats = image_stats_1k[map_type_to_stat] # Get stats dict for this map type\r\n-\r\n- if stats and isinstance(stats, dict):\r\n- # Handle potential list format for RGB stats (use first value) or direct float\r\n- def get_stat_value(stat_val):\r\n- if isinstance(stat_val, list):\r\n- return stat_val[0] if stat_val else None\r\n- return stat_val\r\n-\r\n- min_val = get_stat_value(stats.get(\"min\"))\r\n- max_val = get_stat_value(stats.get(\"max\"))\r\n- mean_val = get_stat_value(stats.get(\"mean\")) # Often stored as 'mean' or 'avg'\r\n-\r\n- updated_stat = False\r\n- # Check inputs exist before assigning\r\n- input_x = stats_node.inputs.get(\"X\")\r\n- input_y = stats_node.inputs.get(\"Y\")\r\n- input_z = stats_node.inputs.get(\"Z\")\r\n-\r\n- if input_x and min_val is not None and abs(input_x.default_value - min_val) > 0.0001:\r\n- input_x.default_value = min_val\r\n- updated_stat = True\r\n- if input_y and max_val is not None and abs(input_y.default_value - max_val) > 0.0001:\r\n- input_y.default_value = max_val\r\n- updated_stat = True\r\n- if input_z and mean_val is not None and abs(input_z.default_value - mean_val) > 0.0001:\r\n- input_z.default_value = mean_val\r\n- updated_stat = True\r\n-\r\n- if updated_stat:\r\n- print(f\" Set stats in '{stats_node_label}': Min={min_val:.4f}, Max={max_val:.4f}, Mean={mean_val:.4f}\")\r\n- # else: print(f\" Info: No valid 'stats' dictionary found for map type '{map_type_to_stat}' in image_stats_1k.\") # Optional\r\n- # else: print(f\" Warn: Stats node '{stats_node_label}' not found in parent group.\") # Optional\r\n- # else: print(f\" Info: Map type '{map_type_to_stat}' not present in image_stats_1k for stats application.\") # Optional\r\n- # else: print(f\" Warn: 'image_stats_1k' missing or invalid in metadata.\") # Optional\r\n-\r\n- # --- Set Asset Preview (only for new parent groups) ---\r\n- # Use the reference image path found earlier if available\r\n- if is_new_parent and parent_group.asset_data:\r\n- if ref_image_loaded and ref_image_path: # Check if ref image was successfully loaded earlier\r\n- print(f\" Attempting to set preview from reference image: {Path(ref_image_path).name}\")\r\n- try:\r\n- # Ensure the ID (node group) is the active one for the operator context\r\n- with context.temp_override(id=parent_group):\r\n- bpy.ops.ed.lib_id_load_custom_preview(filepath=ref_image_path)\r\n- print(f\" Successfully set custom preview.\")\r\n- previews_set += 1\r\n- except Exception as e_preview:\r\n- print(f\" !!! ERROR setting custom preview: {e_preview}\")\r\n- errors_encountered += 1\r\n- else:\r\n- print(f\" Info: Could not set preview for '{asset_name}' as reference image was not found or loaded.\")\r\n-\r\n-\r\n- # --- Child Group Handling ---\r\n- # Iterate through the COMBINED map types\r\n- print(f\" DEBUG: Starting child group loop for asset '{asset_name}'. Map types: {list(all_map_resolutions.keys())}\") # DEBUG LOG (Indented)\r\n- for map_type, resolutions in all_map_resolutions.items():\r\n- print(f\" Processing Map Type: {map_type}\")\r\n-\r\n- # Determine if this is a merged map (not in map_details)\r\n- is_merged_map = map_type not in map_details\r\n-\r\n- # Get details for this map type if available\r\n- current_map_details = map_details.get(map_type, {})\r\n- # For merged maps, primary_format will be None\r\n- output_format = current_map_details.get(\"output_format\")\r\n-\r\n- if not output_format and not is_merged_map:\r\n- # This case should ideally not happen if metadata is well-formed\r\n- # but handle defensively for processed maps.\r\n- print(f\" !!! WARNING: Missing 'output_format' in map_details for processed map '{map_type}'. Path reconstruction might fail.\")\r\n- # We will rely solely on fallback for this map type\r\n-\r\n- # Find placeholder node in parent\r\n- holder_nodes = find_nodes_by_label(parent_group, map_type, 'ShaderNodeGroup')\r\n- if not holder_nodes:\r\n- print(f\" !!! WARNING: No placeholder node labeled '{map_type}' found in parent group '{parent_group.name}'. Skipping this map type.\")\r\n- continue\r\n- holder_node = holder_nodes[0] # Assume first is correct\r\n- print(f\" DEBUG: Found placeholder node '{holder_node.label}' for map type '{map_type}'.\") # DEBUG LOG (Indented)\r\n-\r\n- # Determine child group name (LOGICAL and ENCODED)\r\n- logical_child_name = f\"{asset_name}_{map_type}\"\r\n- target_child_name_b64 = encode_name_b64(logical_child_name) # Use Base64 name\r\n-\r\n- child_group = bpy.data.node_groups.get(target_child_name_b64) # Find using encoded name\r\n- is_new_child = False\r\n-\r\n- if child_group is None:\r\n- print(f\" DEBUG: Child group '{target_child_name_b64}' not found. Creating new one.\") # DEBUG LOG (Indented)\r\n- # print(f\" Creating new child group: '{target_child_name_b64}' (logical: '{logical_child_name}')\") # Verbose\r\n- child_group = template_child.copy()\r\n- if not child_group:\r\n- print(f\" !!! ERROR: Failed to copy child template '{CHILD_TEMPLATE_NAME}'. Skipping map type '{map_type}'.\")\r\n- errors_encountered += 1\r\n- continue\r\n- child_group.name = target_child_name_b64 # Set encoded name\r\n- child_groups_created += 1\r\n- is_new_child = True\r\n- else:\r\n- print(f\" DEBUG: Found existing child group '{target_child_name_b64}'.\") # DEBUG LOG (Indented)\r\n- # print(f\" Updating existing child group: '{target_child_name_b64}'\") # Verbose\r\n- child_groups_updated += 1\r\n-\r\n- # Assign child group to placeholder if needed\r\n- if holder_node.node_tree != child_group:\r\n- try:\r\n- holder_node.node_tree = child_group\r\n- print(f\" Assigned child group '{child_group.name}' to placeholder '{holder_node.label}'.\")\r\n- except TypeError as e_assign: # Catch potential type errors if placeholder isn't a group node\r\n- print(f\" !!! ERROR: Could not assign child group to placeholder '{holder_node.label}'. Is it a Group Node? Error: {e_assign}\")\r\n- continue # Skip this map type if assignment fails\r\n-\r\n- # Link placeholder output to parent output socket\r\n- try:\r\n- # Find parent's output node\r\n- group_output_node = next((n for n in parent_group.nodes if n.type == 'GROUP_OUTPUT'), None)\r\n- if group_output_node:\r\n- # Get the specific output socket on the placeholder (usually index 0 or named 'Color'/'Value')\r\n- source_socket = holder_node.outputs.get(\"Color\") or holder_node.outputs.get(\"Value\") or holder_node.outputs[0]\r\n- # Get the specific input socket on the parent output node (matching map_type)\r\n- target_socket = group_output_node.inputs.get(map_type)\r\n-\r\n- if source_socket and target_socket:\r\n- # Check if link already exists\r\n- link_exists = any(link.from_socket == source_socket and link.to_socket == target_socket for link in parent_group.links)\r\n- if not link_exists:\r\n- parent_group.links.new(source_socket, target_socket)\r\n- print(f\" Linked '{holder_node.label}' output to parent output socket '{map_type}'.\")\r\n- # else: # Optional warnings\r\n- # if not source_socket: print(f\" Warn: Could not find suitable output socket on placeholder '{holder_node.label}'.\")\r\n- # if not target_socket: print(f\" Warn: Could not find input socket '{map_type}' on parent output node.\")\r\n- # else: print(f\" Warn: Parent group '{parent_group.name}' has no Group Output node.\")\r\n-\r\n- except Exception as e_link:\r\n- print(f\" !!! ERROR linking sockets for '{map_type}': {e_link}\")\r\n-\r\n- # Ensure parent output socket type is Color (if it exists)\r\n- try:\r\n- # Use the interface API for modern Blender versions\r\n- item = parent_group.interface.items_tree.get(map_type)\r\n- if item and item.item_type == 'SOCKET' and item.in_out == 'OUTPUT':\r\n- # Common useful types: 'NodeSocketColor', 'NodeSocketVector', 'NodeSocketFloat'\r\n- # Defaulting to Color seems reasonable for most PBR outputs\r\n- if item.socket_type != 'NodeSocketColor':\r\n- item.socket_type = 'NodeSocketColor'\r\n- # print(f\" Set parent output socket '{map_type}' type to Color.\") # Optional info\r\n- except Exception as e_sock_type:\r\n- print(f\" Warn: Could not verify/set socket type for '{map_type}': {e_sock_type}\")\r\n-\r\n-\r\n- # --- Image Node Handling (Inside Child Group) ---\r\n- if not isinstance(resolutions, list):\r\n- print(f\" !!! ERROR: Invalid format for resolutions list for map type '{map_type}'. Skipping.\")\r\n- continue\r\n-\r\n- for resolution in resolutions:\r\n- # --- Manifest Check (Map/Resolution Level) ---\r\n- if ENABLE_MANIFEST and is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n- # print(f\" Skipping {resolution} (Manifest)\") # Verbose\r\n- maps_skipped_manifest += 1\r\n- continue\r\n- print(f\" DEBUG: Processing map '{map_type}' resolution '{resolution}'. Manifest skip check passed.\") # DEBUG LOG (Indented)\r\n-\r\n- print(f\" Processing Resolution: {resolution}\")\r\n-\r\n- # Reconstruct the image path using fallback logic\r\n- # Pass output_format (which might be None for merged maps)\r\n- image_path_str = reconstruct_image_path_with_fallback(\r\n- asset_dir_path=asset_dir_path,\r\n- asset_name=asset_name,\r\n- map_type=map_type,\r\n- resolution=resolution,\r\n- primary_format=output_format\r\n- )\r\n- print(f\" DEBUG: Reconstructed image path for {map_type}/{resolution}: {image_path_str}\") # DEBUG LOG (Indented)\r\n-\r\n- if not image_path_str:\r\n- # Error already printed by reconstruct function\r\n- errors_encountered += 1\r\n- continue # Skip this resolution if path not found\r\n-\r\n- # Find image texture node within the CHILD group (labeled by resolution, e.g., \"4K\")\r\n- image_nodes = find_nodes_by_label(child_group, resolution, 'ShaderNodeTexImage')\r\n- if not image_nodes:\r\n- print(f\" !!! WARNING: No Image Texture node labeled '{resolution}' found in child group '{child_group.name}'. Cannot assign image.\")\r\n- continue # Skip this resolution if node not found\r\n- print(f\" DEBUG: Found {len(image_nodes)} image node(s) labeled '{resolution}' in child group '{child_group.name}'.\") # DEBUG LOG (Indented)\r\n-\r\n- # --- Load Image ---\r\n- img = None\r\n- image_load_failed = False\r\n- try:\r\n- image_path = Path(image_path_str) # Path object created from already found path string\r\n- # Use check_existing=True to reuse existing datablocks if path matches\r\n- img = bpy.data.images.load(str(image_path), check_existing=True)\r\n- if not img:\r\n- print(f\" !!! ERROR: Failed loading image via bpy.data.images.load: {image_path_str}\")\r\n- image_load_failed = True\r\n- else:\r\n- # Only count as loaded if bpy.data.images.load succeeded\r\n- # Check if it's newly loaded or reused\r\n- is_newly_loaded = img.library is None # Newly loaded images don't have a library initially\r\n- if is_newly_loaded: images_loaded += 1\r\n-\r\n- except RuntimeError as e_runtime_load:\r\n- # Catch specific Blender runtime errors (e.g., unsupported format)\r\n- print(f\" !!! ERROR loading image '{image_path_str}': {e_runtime_load}\")\r\n- image_load_failed = True\r\n- except Exception as e_gen_load:\r\n- print(f\" !!! UNEXPECTED ERROR loading image '{image_path_str}': {e_gen_load}\")\r\n- image_load_failed = True\r\n- errors_encountered += 1\r\n-\r\n- # --- Assign Image & Set Color Space ---\r\n- if not image_load_failed and img:\r\n- assigned_count_this_res = 0\r\n- for image_node in image_nodes:\r\n- if image_node.image != img:\r\n- image_node.image = img\r\n- assigned_count_this_res += 1\r\n-\r\n- if assigned_count_this_res > 0:\r\n- images_assigned += assigned_count_this_res\r\n- print(f\" Assigned image '{img.name}' to {assigned_count_this_res} node(s).\")\r\n-\r\n- # Set Color Space\r\n- correct_color_space = get_color_space(map_type)\r\n- try:\r\n- if img.colorspace_settings.name != correct_color_space:\r\n- img.colorspace_settings.name = correct_color_space\r\n- print(f\" Set '{img.name}' color space -> {correct_color_space}\")\r\n- except TypeError as e_cs: # Handle case where colorspace name is invalid\r\n- print(f\" !!! WARNING: Could not set color space '{correct_color_space}' for image '{img.name}'. Is the color space available in Blender? Error: {e_cs}\")\r\n- except Exception as e_cs_gen:\r\n- print(f\" !!! ERROR setting color space for image '{img.name}': {e_cs_gen}\")\r\n-\r\n-\r\n- # --- Update Manifest (Map/Resolution Level) ---\r\n- if update_manifest(manifest_data, asset_name, map_type, resolution):\r\n- manifest_needs_saving = True\r\n- # print(f\" Marked {map_type}/{resolution} processed in manifest.\") # Verbose\r\n- maps_processed += 1\r\n-\r\n- else:\r\n- # Increment error count if loading failed\r\n- if image_load_failed: errors_encountered += 1\r\n-\r\n- # --- End Resolution Loop ---\r\n- # --- End Map Type Loop ---\r\n-\r\n- assets_processed += 1\r\n-\r\n- except FileNotFoundError:\r\n- print(f\" !!! ERROR: Metadata file not found (should not happen if scan worked): {metadata_path}\")\r\n- errors_encountered += 1\r\n- except json.JSONDecodeError:\r\n- print(f\" !!! ERROR: Invalid JSON in metadata file: {metadata_path}\")\r\n- errors_encountered += 1\r\n- except Exception as e_main_loop:\r\n- print(f\" !!! UNEXPECTED ERROR processing asset from {metadata_path}: {e_main_loop}\")\r\n- import traceback\r\n- traceback.print_exc() # Print detailed traceback for debugging\r\n- errors_encountered += 1\r\n- # Continue to the next asset\r\n-\r\n- # --- End Metadata File Loop ---\r\n-\r\n- # --- Final Manifest Save ---\r\n- if ENABLE_MANIFEST and manifest_needs_saving:\r\n- print(\"\\nAttempting final manifest save...\")\r\n- save_manifest(context, manifest_data)\r\n- elif ENABLE_MANIFEST:\r\n- print(\"\\nManifest is enabled, but no changes require saving.\")\r\n- # --- End Final Manifest Save ---\r\n-\r\n- # --- Final Summary ---\r\n- end_time = time.time()\r\n- duration = end_time - start_time\r\n- print(\"\\n--- Script Run Finished ---\")\r\n- print(f\"Duration: {duration:.2f} seconds\")\r\n- print(f\"Metadata Files Found: {metadata_files_found}\")\r\n- print(f\"Assets Processed/Attempted: {assets_processed}\")\r\n- if ENABLE_MANIFEST:\r\n- print(f\"Assets Skipped (Manifest): {assets_skipped_manifest}\")\r\n- print(f\"Maps Skipped (Manifest): {maps_skipped_manifest}\")\r\n- print(f\"Parent Groups Created: {parent_groups_created}\")\r\n- print(f\"Parent Groups Updated: {parent_groups_updated}\")\r\n- print(f\"Child Groups Created: {child_groups_created}\")\r\n- print(f\"Child Groups Updated: {child_groups_updated}\")\r\n- print(f\"Images Loaded: {images_loaded}\")\r\n- print(f\"Image Nodes Assigned: {images_assigned}\")\r\n- print(f\"Individual Maps Processed: {maps_processed}\")\r\n- print(f\"Asset Previews Set: {previews_set}\")\r\n- print(f\"Highest Resolution Nodes Set: {highest_res_set}\")\r\n- print(f\"Aspect Ratio Nodes Set: {aspect_ratio_set}\") # Added counter\r\n- if errors_encountered > 0:\r\n- print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n- print(\"---------------------------\")\r\n-\r\n- # --- Explicit Save ---\r\n- print(f\" DEBUG: Attempting explicit save for file: {bpy.data.filepath}\") # DEBUG LOG (Indented)\r\n- try:\r\n- bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)\r\n- print(\"\\n--- Explicitly saved the .blend file. ---\")\r\n- except Exception as e_save:\r\n- print(f\"\\n!!! ERROR explicitly saving .blend file: {e_save} !!!\")\r\n- errors_encountered += 1 # Count save errors\r\n-\r\n- return True\r\n-\r\n-\r\n-# --- Execution Block ---\r\n-\r\n-if __name__ == \"__main__\":\r\n- # Ensure we are running within Blender\r\n- try:\r\n- import bpy\r\n- import base64 # Ensure base64 is imported here too if needed globally\r\n- import sys\r\n- except ImportError:\r\n- print(\"!!! ERROR: This script must be run from within Blender. !!!\")\r\n- else:\r\n- # --- Argument Parsing for Asset Library Root ---\r\n- asset_root_arg = None\r\n- try:\r\n- # Blender arguments passed after '--' appear in sys.argv\r\n- if \"--\" in sys.argv:\r\n- args_after_dash = sys.argv[sys.argv.index(\"--\") + 1:]\r\n- if len(args_after_dash) >= 1:\r\n- asset_root_arg = args_after_dash[0]\r\n- print(f\"Found asset library root argument: {asset_root_arg}\")\r\n- else:\r\n- print(\"Info: '--' found but no arguments after it.\")\r\n- # else: print(\"Info: No '--' found in arguments.\") # Optional debug\r\n- except Exception as e:\r\n- print(f\"Error parsing command line arguments: {e}\")\r\n- # --- End Argument Parsing ---\r\n-\r\n- process_library(bpy.context, asset_library_root_override=asset_root_arg)\r\n" }, { "date": 1745348312679, "content": "Index: \n===================================================================\n--- \n+++ \n@@ -600,8 +600,15 @@\n print(f\" Skipping asset '{asset_name}' (already fully processed according to manifest).\")\r\n assets_skipped_manifest += 1\r\n continue # Skip to next metadata file\r\n \r\n+\r\n+ # Conditional skip based on asset_category\r\n+ if asset_category not in CATEGORIES_FOR_NODEGROUP_GENERATION: # Check asset_category\r\n+ print(f\" Skipping nodegroup content generation for asset '{asset_name}' (Category: '{asset_category}'). Tag added.\") # Use asset_category in log\r\n+ assets_processed += 1 # Still count as processed for summary, even if skipped\r\n+ continue # Skip the rest of the processing for this asset\r\n+\r\n # --- Parent Group Handling ---\r\n target_parent_name = f\"PBRSET_{asset_name}\"\r\n parent_group = bpy.data.node_groups.get(target_parent_name)\r\n is_new_parent = False\r\n@@ -641,13 +648,8 @@\n add_tag_if_new(parent_group.asset_data, asset_category)\r\n # Add other tags if needed\r\n # else: print(f\" Warn: Cannot apply tags, asset_data not available for '{parent_group.name}'.\") # Optional warning\r\n \r\n- # Conditional skip based on asset_category\r\n- if asset_category not in CATEGORIES_FOR_NODEGROUP_GENERATION: # Check asset_category\r\n- print(f\" Skipping nodegroup content generation for asset '{asset_name}' (Category: '{asset_category}'). Tag added.\") # Use asset_category in log\r\n- assets_processed += 1 # Still count as processed for summary, even if skipped\r\n- continue # Skip the rest of the processing for this asset\r\n \r\n # Apply Aspect Ratio Correction\r\n aspect_nodes = find_nodes_by_label(parent_group, ASPECT_RATIO_NODE_LABEL, 'ShaderNodeValue')\r\n if aspect_nodes:\r\n" } ], "date": 1745229222683, "name": "Commit-0", "content": "# Blender Script: Create/Update Node Groups from Asset Processor Output\r\n# Version: 1.0\r\n# Description: Scans a library processed by the Asset Processor Tool,\r\n# reads metadata.json files, and creates/updates corresponding\r\n# PBR node groups in the active Blender file.\r\n\r\nimport bpy\r\nimport os\r\nimport json\r\nfrom pathlib import Path\r\nimport time\r\nimport re # For parsing aspect ratio string\r\n\r\n# --- USER CONFIGURATION ---\r\n\r\n# Path to the root output directory of the Asset Processor Tool\r\n# Example: r\"G:\\Assets\\Processed\"\r\nPROCESSED_ASSET_LIBRARY_ROOT = r\"G:\\02 Content\\10-19 Content\\13 Textures Power of Two\\13.00\" # <<< CHANGE THIS PATH!\r\n\r\n# Names of the required node group templates in the Blender file\r\nPARENT_TEMPLATE_NAME = \"Template_PBRSET\"\r\nCHILD_TEMPLATE_NAME = \"Template_PBRTYPE\"\r\n\r\n# Labels of specific nodes within the PARENT template\r\nASPECT_RATIO_NODE_LABEL = \"AspectRatioCorrection\" # Value node for UV X-scaling factor\r\nSTATS_NODE_PREFIX = \"Histogram-\" # Prefix for Combine XYZ nodes storing stats (e.g., \"Histogram-ROUGH\")\r\n\r\n# Enable/disable the manifest system to track processed assets/maps\r\n# If enabled, requires the blend file to be saved.\r\nENABLE_MANIFEST = True\r\n\r\n# Map PBR type strings (from metadata) to Blender color spaces\r\n# Add more mappings as needed based on your metadata types\r\nPBR_COLOR_SPACE_MAP = {\r\n \"AO\": \"Non-Color\", # Usually Non-Color, but depends on workflow\r\n \"COL\": \"sRGB\",\r\n \"DISP\": \"Non-Color\",\r\n \"NRM\": \"Non-Color\",\r\n \"REFL\": \"Non-Color\", # Reflection/Specular\r\n \"ROUGH\": \"Non-Color\",\r\n \"METAL\": \"Non-Color\",\r\n \"OPC\": \"Non-Color\", # Opacity/Alpha\r\n \"TRN\": \"Non-Color\", # Transmission\r\n \"SSS\": \"sRGB\", # Subsurface Color\r\n \"EMISS\": \"sRGB\", # Emission Color\r\n # Add other types like GLOSS, HEIGHT, etc. if needed\r\n}\r\nDEFAULT_COLOR_SPACE = \"sRGB\" # Fallback if map type not in the dictionary\r\n\r\n# Map types for which stats should be applied (if found in metadata and node exists)\r\nAPPLY_STATS_FOR_MAP_TYPES = [\"ROUGH\", \"DISP\", \"METAL\"] # Add others if needed\r\n\r\n# --- END USER CONFIGURATION ---\r\n\r\n\r\n# --- Helper Functions ---\r\n\r\ndef find_nodes_by_label(node_tree, label, node_type=None):\r\n \"\"\"Finds ALL nodes in a node tree matching the label and optionally type.\"\"\"\r\n if not node_tree:\r\n return []\r\n matching_nodes = []\r\n for node in node_tree.nodes:\r\n # Use node.label for labeled nodes, node.name for non-labeled (like Group Input/Output)\r\n node_identifier = node.label if node.label else node.name\r\n if node_identifier and node_identifier == label:\r\n if node_type is None or node.bl_idname == node_type or node.type == node_type: # Check bl_idname and type for flexibility\r\n matching_nodes.append(node)\r\n return matching_nodes\r\n\r\ndef add_tag_if_new(asset_data, tag_name):\r\n \"\"\"Adds a tag to the asset data if it's not None/empty and doesn't already exist.\"\"\"\r\n if not asset_data or not tag_name or not isinstance(tag_name, str):\r\n return False\r\n cleaned_tag_name = tag_name.strip()\r\n if not cleaned_tag_name:\r\n return False\r\n\r\n # Check if tag already exists (case-insensitive check might be better sometimes)\r\n if cleaned_tag_name not in [t.name for t in asset_data.tags]:\r\n try:\r\n asset_data.tags.new(cleaned_tag_name)\r\n print(f\" + Added Asset Tag: '{cleaned_tag_name}'\")\r\n return True\r\n except Exception as e:\r\n print(f\" Error adding tag '{cleaned_tag_name}': {e}\")\r\n return False\r\n return False # Tag already existed\r\n\r\ndef get_color_space(map_type):\r\n \"\"\"Returns the appropriate Blender color space name for a given map type string.\"\"\"\r\n return PBR_COLOR_SPACE_MAP.get(map_type.upper(), DEFAULT_COLOR_SPACE)\r\n\r\ndef calculate_factor_from_string(aspect_string):\r\n \"\"\"\r\n Parses the aspect_ratio_change_string from metadata and returns the\r\n appropriate UV X-scaling factor needed to correct distortion.\r\n Assumes the string format documented in Asset Processor Tool readme.md:\r\n \"EVEN\", \"Xnnn\", \"Ynnn\", \"XnnnYnnn\".\r\n Returns 1.0 if the string is invalid or \"EVEN\".\r\n \"\"\"\r\n if not aspect_string or aspect_string.upper() == \"EVEN\":\r\n return 1.0\r\n\r\n x_factor = 1.0\r\n y_factor = 1.0\r\n\r\n # Regex to find X and Y scaling parts\r\n match_x = re.search(r\"X(\\d+)\", aspect_string, re.IGNORECASE)\r\n match_y = re.search(r\"Y(\\d+)\", aspect_string, re.IGNORECASE)\r\n\r\n try:\r\n if match_x:\r\n amount_x = int(match_x.group(1))\r\n if amount_x > 0:\r\n x_factor = amount_x / 100.0\r\n else:\r\n print(f\" Warn: Invalid X amount in aspect string '{aspect_string}'. Using 1.0.\")\r\n\r\n if match_y:\r\n amount_y = int(match_y.group(1))\r\n if amount_y > 0:\r\n y_factor = amount_y / 100.0\r\n else:\r\n print(f\" Warn: Invalid Y amount in aspect string '{aspect_string}'. Using 1.0.\")\r\n\r\n # The correction factor for the U (X) coordinate is Y/X\r\n # If X was scaled by 1.5 (X150), U needs to be divided by 1.5 (multiplied by 1/1.5)\r\n # If Y was scaled by 1.5 (Y150), U needs to be multiplied by 1.5\r\n if x_factor == 0: # Avoid division by zero\r\n print(f\" Warn: X factor is zero in aspect string '{aspect_string}'. Cannot calculate correction. Returning 1.0.\")\r\n return 1.0\r\n\r\n correction_factor = y_factor / x_factor\r\n return correction_factor\r\n\r\n except ValueError:\r\n print(f\" Warn: Invalid number in aspect string '{aspect_string}'. Returning 1.0.\")\r\n return 1.0\r\n except Exception as e:\r\n print(f\" Error parsing aspect string '{aspect_string}': {e}. Returning 1.0.\")\r\n return 1.0\r\n\r\n\r\n# --- Manifest Functions ---\r\n\r\ndef get_manifest_path(context):\r\n \"\"\"Gets the expected path for the manifest JSON file.\"\"\"\r\n if not context or not context.blend_data or not context.blend_data.filepath:\r\n return None # Cannot determine path if blend file is not saved\r\n blend_path = Path(context.blend_data.filepath)\r\n manifest_filename = f\"{blend_path.stem}_manifest.json\"\r\n return blend_path.parent / manifest_filename\r\n\r\ndef load_manifest(context):\r\n \"\"\"Loads the manifest data from the JSON file.\"\"\"\r\n if not ENABLE_MANIFEST:\r\n return {} # Manifest disabled\r\n\r\n manifest_path = get_manifest_path(context)\r\n if not manifest_path:\r\n print(\" Manifest Info: Blend file not saved. Cannot load manifest.\")\r\n return {} # Cannot load without a path\r\n\r\n if not manifest_path.exists():\r\n print(f\" Manifest Info: No manifest file found at '{manifest_path.name}'. Starting fresh.\")\r\n return {} # No manifest file exists yet\r\n\r\n try:\r\n with open(manifest_path, 'r', encoding='utf-8') as f:\r\n data = json.load(f)\r\n print(f\" Manifest Loaded from: {manifest_path.name}\")\r\n # Basic validation (check if it's a dictionary)\r\n if not isinstance(data, dict):\r\n print(f\"!!! WARNING: Manifest file '{manifest_path.name}' has invalid format (not a dictionary). Starting fresh. !!!\")\r\n return {}\r\n return data\r\n except json.JSONDecodeError:\r\n print(f\"!!! WARNING: Manifest file '{manifest_path.name}' is corrupted. Starting fresh. !!!\")\r\n return {}\r\n except Exception as e:\r\n print(f\"!!! ERROR: Could not load manifest file '{manifest_path.name}': {e} !!!\")\r\n return {} # Treat as starting fresh on error\r\n\r\ndef save_manifest(context, manifest_data):\r\n \"\"\"Saves the manifest data to the JSON file.\"\"\"\r\n if not ENABLE_MANIFEST or not manifest_data: # Don't save if disabled or empty\r\n return False\r\n\r\n manifest_path = get_manifest_path(context)\r\n if not manifest_path:\r\n print(\" Manifest Error: Blend file not saved. Cannot save manifest.\")\r\n return False\r\n\r\n try:\r\n with open(manifest_path, 'w', encoding='utf-8') as f:\r\n json.dump(manifest_data, f, indent=2, sort_keys=True) # Use indent and sort for readability\r\n print(f\" Manifest Saved to: {manifest_path.name}\")\r\n return True\r\n except Exception as e:\r\n print(f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\\n\"\r\n f\"!!! Manifest save FAILED to '{manifest_path.name}': {e} !!!\\n\"\r\n f\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\")\r\n return False\r\n\r\ndef is_asset_processed(manifest_data, asset_name):\r\n \"\"\"Checks if the entire asset (all its maps/resolutions) is marked as processed.\"\"\"\r\n if not ENABLE_MANIFEST: return False\r\n # For now, we process map-by-map. An asset is considered processed\r\n # if its entry exists, but we rely on map checks.\r\n # This could be enhanced later if needed.\r\n return asset_name in manifest_data\r\n\r\ndef is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n \"\"\"Checks if a specific map type and resolution for an asset is processed.\"\"\"\r\n if not ENABLE_MANIFEST: return False\r\n return resolution in manifest_data.get(asset_name, {}).get(map_type, [])\r\n\r\ndef update_manifest(manifest_data, asset_name, map_type=None, resolution=None):\r\n \"\"\"Updates the manifest dictionary in memory.\"\"\"\r\n if not ENABLE_MANIFEST: return False\r\n\r\n # Ensure asset entry exists\r\n if asset_name not in manifest_data:\r\n manifest_data[asset_name] = {}\r\n\r\n # If map_type and resolution are provided, update the specific map entry\r\n if map_type and resolution:\r\n if map_type not in manifest_data[asset_name]:\r\n manifest_data[asset_name][map_type] = []\r\n\r\n if resolution not in manifest_data[asset_name][map_type]:\r\n manifest_data[asset_name][map_type].append(resolution)\r\n manifest_data[asset_name][map_type].sort() # Keep sorted\r\n return True # Indicate that a change was made\r\n return False # No change made to this specific map/res\r\n\r\n\r\n# --- Core Logic ---\r\n\r\ndef process_library(context):\r\n \"\"\"Scans the library, reads metadata, creates/updates node groups.\"\"\"\r\n start_time = time.time()\r\n print(f\"\\n--- Starting Node Group Processing ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---\")\r\n\r\n # --- Pre-run Checks ---\r\n print(\"Performing pre-run checks...\")\r\n valid_setup = True\r\n # 1. Check Library Root Path\r\n root_path = Path(PROCESSED_ASSET_LIBRARY_ROOT)\r\n if not root_path.is_dir():\r\n print(f\"!!! ERROR: Processed asset library root directory not found or not a directory:\")\r\n print(f\"!!! '{PROCESSED_ASSET_LIBRARY_ROOT}'\")\r\n valid_setup = False\r\n else:\r\n print(f\" Asset Library Root: '{root_path}'\")\r\n\r\n # 2. Check Templates\r\n template_parent = bpy.data.node_groups.get(PARENT_TEMPLATE_NAME)\r\n template_child = bpy.data.node_groups.get(CHILD_TEMPLATE_NAME)\r\n if not template_parent:\r\n print(f\"!!! ERROR: Parent template node group '{PARENT_TEMPLATE_NAME}' not found in this Blender file.\")\r\n valid_setup = False\r\n if not template_child:\r\n print(f\"!!! ERROR: Child template node group '{CHILD_TEMPLATE_NAME}' not found in this Blender file.\")\r\n valid_setup = False\r\n if template_parent and template_child:\r\n print(f\" Found Templates: '{PARENT_TEMPLATE_NAME}', '{CHILD_TEMPLATE_NAME}'\")\r\n\r\n # 3. Check Blend File Saved (if manifest enabled)\r\n if ENABLE_MANIFEST and not context.blend_data.filepath:\r\n print(f\"!!! WARNING: Manifest is enabled, but the current Blender file is not saved.\")\r\n print(f\"!!! Manifest cannot be loaded or saved. Processing will continue without manifest checks.\")\r\n global ENABLE_MANIFEST # Allow modification of global\r\n ENABLE_MANIFEST = False # Disable manifest for this run\r\n\r\n if not valid_setup:\r\n print(\"\\n--- Script aborted due to configuration errors. Please fix the issues above. ---\")\r\n return False\r\n print(\"Pre-run checks passed.\")\r\n # --- End Pre-run Checks ---\r\n\r\n manifest_data = load_manifest(context)\r\n manifest_needs_saving = False\r\n\r\n # --- Initialize Counters ---\r\n metadata_files_found = 0\r\n assets_processed = 0\r\n assets_skipped_manifest = 0\r\n parent_groups_created = 0\r\n parent_groups_updated = 0\r\n child_groups_created = 0\r\n child_groups_updated = 0\r\n images_loaded = 0\r\n images_assigned = 0\r\n maps_processed = 0\r\n maps_skipped_manifest = 0\r\n errors_encountered = 0\r\n # --- End Counters ---\r\n\r\n print(f\"\\nScanning for metadata files in '{root_path}'...\")\r\n\r\n # --- Scan for metadata.json ---\r\n # Using rglob to find all metadata.json files recursively\r\n metadata_paths = list(root_path.rglob('metadata.json'))\r\n metadata_files_found = len(metadata_paths)\r\n print(f\"Found {metadata_files_found} metadata.json files.\")\r\n\r\n if metadata_files_found == 0:\r\n print(\"No metadata files found. Nothing to process.\")\r\n print(\"--- Script Finished ---\")\r\n return True # No work needed is considered success\r\n\r\n # --- Process Each Metadata File ---\r\n for metadata_path in metadata_paths:\r\n print(f\"\\n--- Processing Metadata: {metadata_path.relative_to(root_path)} ---\")\r\n try:\r\n with open(metadata_path, 'r', encoding='utf-8') as f:\r\n metadata = json.load(f)\r\n\r\n # --- Extract Key Info ---\r\n asset_name = metadata.get(\"asset_name\")\r\n supplier_name = metadata.get(\"supplier_name\")\r\n archetype = metadata.get(\"archetype\")\r\n maps_data = metadata.get(\"maps\")\r\n aspect_string = metadata.get(\"aspect_ratio_change_string\")\r\n\r\n if not asset_name or not maps_data:\r\n print(f\" !!! ERROR: Metadata file is missing 'asset_name' or 'maps' data. Skipping.\")\r\n errors_encountered += 1\r\n continue\r\n\r\n print(f\" Asset Name: {asset_name}\")\r\n\r\n # --- Manifest Check (Asset Level) ---\r\n # We primarily check maps, but can skip the whole asset if needed later\r\n if ENABLE_MANIFEST and is_asset_processed(manifest_data, asset_name):\r\n # Check if ALL maps/resolutions are actually done, otherwise proceed\r\n all_maps_done = True\r\n for map_type, res_data in maps_data.items():\r\n for resolution in res_data.keys():\r\n if not is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n all_maps_done = False\r\n break\r\n if not all_maps_done:\r\n break\r\n if all_maps_done:\r\n print(f\" Skipping asset '{asset_name}' (already fully processed according to manifest).\")\r\n assets_skipped_manifest += 1\r\n continue # Skip to next metadata file\r\n\r\n # --- Parent Group Handling ---\r\n target_parent_name = f\"PBRSET_{asset_name}\"\r\n parent_group = bpy.data.node_groups.get(target_parent_name)\r\n is_new_parent = False\r\n\r\n if parent_group is None:\r\n print(f\" Creating new parent group: '{target_parent_name}'\")\r\n parent_group = template_parent.copy()\r\n if not parent_group:\r\n print(f\" !!! ERROR: Failed to copy parent template '{PARENT_TEMPLATE_NAME}'. Skipping asset '{asset_name}'.\")\r\n errors_encountered += 1\r\n continue\r\n parent_group.name = target_parent_name\r\n parent_groups_created += 1\r\n is_new_parent = True\r\n else:\r\n print(f\" Updating existing parent group: '{target_parent_name}'\")\r\n parent_groups_updated += 1\r\n\r\n # Ensure marked as asset\r\n if not parent_group.asset_data:\r\n try:\r\n parent_group.asset_mark()\r\n print(f\" Marked '{parent_group.name}' as asset.\")\r\n except Exception as e_mark:\r\n print(f\" !!! ERROR: Failed to mark group '{parent_group.name}' as asset: {e_mark}\")\r\n # Continue processing other parts if possible\r\n\r\n # Apply Asset Tags\r\n if parent_group.asset_data:\r\n if supplier_name:\r\n add_tag_if_new(parent_group.asset_data, supplier_name)\r\n if archetype:\r\n add_tag_if_new(parent_group.asset_data, archetype)\r\n # Add other tags if needed\r\n # else: print(f\" Warn: Cannot apply tags, asset_data not available for '{parent_group.name}'.\") # Optional warning\r\n\r\n # Apply Aspect Ratio Correction\r\n aspect_nodes = find_nodes_by_label(parent_group, ASPECT_RATIO_NODE_LABEL, 'ShaderNodeValue')\r\n if aspect_nodes:\r\n aspect_node = aspect_nodes[0]\r\n correction_factor = calculate_factor_from_string(aspect_string)\r\n # Check if update is needed (avoids unnecessary console spam)\r\n if abs(aspect_node.outputs[0].default_value - correction_factor) > 0.0001:\r\n aspect_node.outputs[0].default_value = correction_factor\r\n print(f\" Set '{ASPECT_RATIO_NODE_LABEL}' value to {correction_factor:.4f} (from string '{aspect_string}')\")\r\n # else: print(f\" Warn: Aspect ratio node '{ASPECT_RATIO_NODE_LABEL}' not found in parent group.\") # Optional\r\n\r\n # Apply Stats\r\n for map_type_to_stat in APPLY_STATS_FOR_MAP_TYPES:\r\n if map_type_to_stat in maps_data:\r\n # Find the stats node in the parent group\r\n stats_node_label = f\"{STATS_NODE_PREFIX}{map_type_to_stat}\"\r\n stats_nodes = find_nodes_by_label(parent_group, stats_node_label, 'ShaderNodeCombineXYZ')\r\n if stats_nodes:\r\n stats_node = stats_nodes[0]\r\n # Find the stats data in the metadata (usually from a specific resolution)\r\n # Let's assume the stats are stored directly under the map_type entry\r\n map_metadata = maps_data[map_type_to_stat]\r\n stats = map_metadata.get(\"stats\") # Expecting {\"min\": float, \"max\": float, \"mean\": float}\r\n if stats and isinstance(stats, dict):\r\n min_val = stats.get(\"min\")\r\n max_val = stats.get(\"max\")\r\n mean_val = stats.get(\"mean\") # Often stored as 'mean' or 'avg'\r\n\r\n updated_stat = False\r\n if min_val is not None and abs(stats_node.inputs[0].default_value - min_val) > 0.0001:\r\n stats_node.inputs[0].default_value = min_val\r\n updated_stat = True\r\n if max_val is not None and abs(stats_node.inputs[1].default_value - max_val) > 0.0001:\r\n stats_node.inputs[1].default_value = max_val\r\n updated_stat = True\r\n if mean_val is not None and abs(stats_node.inputs[2].default_value - mean_val) > 0.0001:\r\n stats_node.inputs[2].default_value = mean_val\r\n updated_stat = True\r\n\r\n if updated_stat:\r\n print(f\" Set stats in '{stats_node_label}': Min={min_val:.4f}, Max={max_val:.4f}, Mean={mean_val:.4f}\")\r\n # else: print(f\" Info: No 'stats' dictionary found for map type '{map_type_to_stat}' in metadata.\") # Optional\r\n # else: print(f\" Warn: Stats node '{stats_node_label}' not found in parent group.\") # Optional\r\n # else: print(f\" Info: Map type '{map_type_to_stat}' not present in metadata for stats application.\") # Optional\r\n\r\n\r\n # --- Child Group Handling ---\r\n processed_asset_flag = True # Track if any map within the asset was actually processed in this run\r\n for map_type, type_data in maps_data.items():\r\n print(f\" Processing Map Type: {map_type}\")\r\n\r\n # Find placeholder node in parent\r\n # Placeholders might be labeled directly with the map type (e.g., \"NRM\", \"COL\")\r\n holder_nodes = find_nodes_by_label(parent_group, map_type, 'ShaderNodeGroup')\r\n if not holder_nodes:\r\n print(f\" !!! WARNING: No placeholder node labeled '{map_type}' found in parent group '{parent_group.name}'. Skipping this map type.\")\r\n continue\r\n holder_node = holder_nodes[0] # Assume first is correct\r\n\r\n # Determine child group name\r\n target_child_name = f\"PBRTYPE_{asset_name}_{map_type}\"\r\n child_group = bpy.data.node_groups.get(target_child_name)\r\n is_new_child = False\r\n\r\n if child_group is None:\r\n # print(f\" Creating new child group: '{target_child_name}'\") # Verbose\r\n child_group = template_child.copy()\r\n if not child_group:\r\n print(f\" !!! ERROR: Failed to copy child template '{CHILD_TEMPLATE_NAME}'. Skipping map type '{map_type}'.\")\r\n errors_encountered += 1\r\n continue\r\n child_group.name = target_child_name\r\n child_groups_created += 1\r\n is_new_child = True\r\n else:\r\n # print(f\" Updating existing child group: '{target_child_name}'\") # Verbose\r\n child_groups_updated += 1\r\n\r\n # Assign child group to placeholder if needed\r\n if holder_node.node_tree != child_group:\r\n try:\r\n holder_node.node_tree = child_group\r\n print(f\" Assigned child group '{child_group.name}' to placeholder '{holder_node.label}'.\")\r\n except TypeError as e_assign: # Catch potential type errors if placeholder isn't a group node\r\n print(f\" !!! ERROR: Could not assign child group to placeholder '{holder_node.label}'. Is it a Group Node? Error: {e_assign}\")\r\n continue # Skip this map type if assignment fails\r\n\r\n # Link placeholder output to parent output socket\r\n try:\r\n # Find parent's output node\r\n group_output_node = next((n for n in parent_group.nodes if n.type == 'GROUP_OUTPUT'), None)\r\n if group_output_node:\r\n # Get the specific output socket on the placeholder (usually index 0 or named 'Color'/'Value')\r\n source_socket = holder_node.outputs.get(\"Color\") or holder_node.outputs.get(\"Value\") or holder_node.outputs[0]\r\n # Get the specific input socket on the parent output node (matching map_type)\r\n target_socket = group_output_node.inputs.get(map_type)\r\n\r\n if source_socket and target_socket:\r\n # Check if link already exists\r\n link_exists = any(link.from_socket == source_socket and link.to_socket == target_socket for link in parent_group.links)\r\n if not link_exists:\r\n parent_group.links.new(source_socket, target_socket)\r\n print(f\" Linked '{holder_node.label}' output to parent output socket '{map_type}'.\")\r\n # else: # Optional warnings\r\n # if not source_socket: print(f\" Warn: Could not find suitable output socket on placeholder '{holder_node.label}'.\")\r\n # if not target_socket: print(f\" Warn: Could not find input socket '{map_type}' on parent output node.\")\r\n # else: print(f\" Warn: Parent group '{parent_group.name}' has no Group Output node.\")\r\n\r\n except Exception as e_link:\r\n print(f\" !!! ERROR linking sockets for '{map_type}': {e_link}\")\r\n\r\n # Ensure parent output socket type is Color (if it exists)\r\n try:\r\n # Use the interface API for modern Blender versions\r\n item = parent_group.interface.items_tree.get(map_type)\r\n if item and item.item_type == 'SOCKET' and item.in_out == 'OUTPUT':\r\n # Common useful types: 'NodeSocketColor', 'NodeSocketVector', 'NodeSocketFloat'\r\n # Defaulting to Color seems reasonable for most PBR outputs\r\n if item.socket_type != 'NodeSocketColor':\r\n item.socket_type = 'NodeSocketColor'\r\n # print(f\" Set parent output socket '{map_type}' type to Color.\") # Optional info\r\n except Exception as e_sock_type:\r\n print(f\" Warn: Could not verify/set socket type for '{map_type}': {e_sock_type}\")\r\n\r\n\r\n # --- Image Node Handling (Inside Child Group) ---\r\n # 'type_data' should be the dictionary containing resolutions and paths for this map_type\r\n if not isinstance(type_data, dict):\r\n print(f\" !!! ERROR: Invalid format for map type '{map_type}' data in metadata. Skipping.\")\r\n continue\r\n\r\n for resolution, map_info in type_data.items():\r\n # 'map_info' should be the dictionary containing 'path', 'stats', etc. for this resolution\r\n if not isinstance(map_info, dict) or \"path\" not in map_info:\r\n print(f\" !!! WARNING: Invalid or missing path data for {map_type}/{resolution}. Skipping.\")\r\n continue\r\n\r\n image_path_str = map_info[\"path\"]\r\n\r\n # --- Manifest Check (Map/Resolution Level) ---\r\n if ENABLE_MANIFEST and is_map_processed(manifest_data, asset_name, map_type, resolution):\r\n # print(f\" Skipping {resolution} (Manifest)\") # Verbose\r\n maps_skipped_manifest += 1\r\n continue\r\n\r\n print(f\" Processing Resolution: {resolution}\")\r\n\r\n # Find image texture node within the CHILD group (labeled by resolution, e.g., \"4K\")\r\n image_nodes = find_nodes_by_label(child_group, resolution, 'ShaderNodeTexImage')\r\n if not image_nodes:\r\n print(f\" !!! WARNING: No Image Texture node labeled '{resolution}' found in child group '{child_group.name}'. Cannot assign image.\")\r\n continue # Skip this resolution if node not found\r\n\r\n # --- Load Image ---\r\n img = None\r\n image_load_failed = False\r\n try:\r\n image_path = Path(image_path_str)\r\n if not image_path.is_file():\r\n print(f\" !!! ERROR: Image file not found: {image_path_str}\")\r\n image_load_failed = True\r\n else:\r\n # Use check_existing=True to reuse existing datablocks if path matches\r\n img = bpy.data.images.load(str(image_path), check_existing=True)\r\n if not img:\r\n print(f\" !!! ERROR: Failed loading image via bpy.data.images.load: {image_path_str}\")\r\n image_load_failed = True\r\n else:\r\n images_loaded += 1 # Count successful loads\r\n except RuntimeError as e_runtime_load:\r\n # Catch specific Blender runtime errors (e.g., unsupported format)\r\n print(f\" !!! ERROR loading image '{image_path_str}': {e_runtime_load}\")\r\n image_load_failed = True\r\n except Exception as e_gen_load:\r\n print(f\" !!! UNEXPECTED ERROR loading image '{image_path_str}': {e_gen_load}\")\r\n image_load_failed = True\r\n errors_encountered += 1\r\n\r\n # --- Assign Image & Set Color Space ---\r\n if not image_load_failed and img:\r\n assigned_count_this_res = 0\r\n for image_node in image_nodes:\r\n if image_node.image != img:\r\n image_node.image = img\r\n assigned_count_this_res += 1\r\n\r\n if assigned_count_this_res > 0:\r\n images_assigned += assigned_count_this_res\r\n print(f\" Assigned image '{img.name}' to {assigned_count_this_res} node(s).\")\r\n\r\n # Set Color Space\r\n correct_color_space = get_color_space(map_type)\r\n try:\r\n if img.colorspace_settings.name != correct_color_space:\r\n img.colorspace_settings.name = correct_color_space\r\n print(f\" Set '{img.name}' color space -> {correct_color_space}\")\r\n except TypeError as e_cs: # Handle case where colorspace name is invalid\r\n print(f\" !!! WARNING: Could not set color space '{correct_color_space}' for image '{img.name}'. Is the color space available in Blender? Error: {e_cs}\")\r\n except Exception as e_cs_gen:\r\n print(f\" !!! ERROR setting color space for image '{img.name}': {e_cs_gen}\")\r\n\r\n\r\n # --- Update Manifest (Map/Resolution Level) ---\r\n if update_manifest(manifest_data, asset_name, map_type, resolution):\r\n manifest_needs_saving = True\r\n # print(f\" Marked {map_type}/{resolution} processed in manifest.\") # Verbose\r\n maps_processed += 1\r\n processed_asset_flag = False # Mark that something was processed for this asset\r\n\r\n else:\r\n errors_encountered += 1\r\n processed_asset_flag = False # Still counts as an attempt for this asset\r\n\r\n # --- End Resolution Loop ---\r\n # --- End Map Type Loop ---\r\n\r\n # --- Update Manifest (Asset Level - if fully processed) ---\r\n # This logic might be redundant if we always check map level, but can be added\r\n # if needed to mark the whole asset as 'touched' or 'completed'.\r\n # For now, we rely on map-level updates.\r\n # if not processed_asset_flag: # If any map was processed (or attempted)\r\n # update_manifest(manifest_data, asset_name) # Mark asset as processed\r\n # manifest_needs_saving = True\r\n\r\n assets_processed += 1\r\n\r\n except FileNotFoundError:\r\n print(f\" !!! ERROR: Metadata file not found (should not happen if scan worked): {metadata_path}\")\r\n errors_encountered += 1\r\n except json.JSONDecodeError:\r\n print(f\" !!! ERROR: Invalid JSON in metadata file: {metadata_path}\")\r\n errors_encountered += 1\r\n except Exception as e_main_loop:\r\n print(f\" !!! UNEXPECTED ERROR processing asset from {metadata_path}: {e_main_loop}\")\r\n import traceback\r\n traceback.print_exc() # Print detailed traceback for debugging\r\n errors_encountered += 1\r\n # Continue to the next asset\r\n\r\n # --- End Metadata File Loop ---\r\n\r\n # --- Final Manifest Save ---\r\n if ENABLE_MANIFEST and manifest_needs_saving:\r\n print(\"\\nAttempting final manifest save...\")\r\n save_manifest(context, manifest_data)\r\n elif ENABLE_MANIFEST:\r\n print(\"\\nManifest is enabled, but no changes require saving.\")\r\n # --- End Final Manifest Save ---\r\n\r\n # --- Final Summary ---\r\n end_time = time.time()\r\n duration = end_time - start_time\r\n print(\"\\n--- Script Run Finished ---\")\r\n print(f\"Duration: {duration:.2f} seconds\")\r\n print(f\"Metadata Files Found: {metadata_files_found}\")\r\n print(f\"Assets Processed/Attempted: {assets_processed}\")\r\n if ENABLE_MANIFEST:\r\n print(f\"Assets Skipped (Manifest): {assets_skipped_manifest}\")\r\n print(f\"Maps Skipped (Manifest): {maps_skipped_manifest}\")\r\n print(f\"Parent Groups Created: {parent_groups_created}\")\r\n print(f\"Parent Groups Updated: {parent_groups_updated}\")\r\n print(f\"Child Groups Created: {child_groups_created}\")\r\n print(f\"Child Groups Updated: {child_groups_updated}\")\r\n print(f\"Images Loaded: {images_loaded}\")\r\n print(f\"Image Nodes Assigned: {images_assigned}\")\r\n print(f\"Individual Maps Processed: {maps_processed}\")\r\n if errors_encountered > 0:\r\n print(f\"!!! Errors Encountered: {errors_encountered} !!!\")\r\n print(\"---------------------------\")\r\n\r\n return True\r\n\r\n\r\n# --- Execution Block ---\r\n\r\nif __name__ == \"__main__\":\r\n # Ensure we are running within Blender\r\n try:\r\n import bpy\r\n except ImportError:\r\n print(\"!!! ERROR: This script must be run from within Blender. !!!\")\r\n else:\r\n process_library(bpy.context)" } ] }