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