2025-07-01

This commit is contained in:
2026-03-17 14:30:01 -06:00
parent f9a22056dd
commit 62b5978595
4579 changed files with 1257472 additions and 0 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,70 @@
import bpy
from ..ops.NoSubdiv import NoSubdiv
from ..ops.remove_custom_split_normals import RemoveCustomSplitNormals
from ..ops.create_ortho_camera import CreateOrthoCamera
from ..ops.spawn_scene_structure import SpawnSceneStructure
class BulkSceneGeneral(bpy.types.Panel):
"""Bulk Scene General Panel"""
bl_label = "Scene General"
bl_idname = "VIEW3D_PT_bulk_scene_general"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = 'Edit'
bl_parent_id = "VIEW3D_PT_bulk_scene_tools"
bl_order = 0 # This will make it appear at the very top of the main panel
def draw(self, context):
layout = self.layout
# Scene Structure section
box = layout.box()
box.label(text="Scene Structure")
row = box.row()
row.scale_y = 1.2
row.operator("bst.spawn_scene_structure", text="Spawn Scene Structure", icon='OUTLINER_COLLECTION')
# Mesh section
box = layout.box()
box.label(text="Mesh")
# Add checkbox for only_selected property
row = box.row()
row.prop(context.window_manager, "bst_no_subdiv_only_selected", text="Selected Only")
row = box.row(align=True)
row.operator("bst.no_subdiv", text="No Subdiv", icon='MOD_SUBSURF').only_selected = context.window_manager.bst_no_subdiv_only_selected
row.operator("bst.remove_custom_split_normals", text="Remove Custom Split Normals", icon='X').only_selected = context.window_manager.bst_no_subdiv_only_selected
row = box.row(align=True)
row.operator("bst.create_ortho_camera", text="Create Ortho Camera", icon='OUTLINER_DATA_CAMERA')
row = box.row(align=True)
row.operator("bst.free_gpu", text="Free GPU", icon='MEMORY')
# List of all classes in this module
classes = (
BulkSceneGeneral,
NoSubdiv, # Add NoSubdiv operator class
RemoveCustomSplitNormals,
CreateOrthoCamera,
SpawnSceneStructure,
)
# Registration
def register():
for cls in classes:
bpy.utils.register_class(cls)
# Register the window manager property for the checkbox
bpy.types.WindowManager.bst_no_subdiv_only_selected = bpy.props.BoolProperty(
name="Selected Only",
description="Apply only to selected objects",
default=True
)
def unregister():
for cls in reversed(classes):
try:
bpy.utils.unregister_class(cls)
except RuntimeError:
pass
# Unregister the window manager property
if hasattr(bpy.types.WindowManager, "bst_no_subdiv_only_selected"):
del bpy.types.WindowManager.bst_no_subdiv_only_selected
@@ -0,0 +1,963 @@
import bpy # type: ignore
import numpy as np
from time import time
import os
from enum import Enum
import colorsys # Add colorsys for RGB to HSV conversion
from ..ops.select_diffuse_nodes import select_diffuse_nodes # Import the specific function
# Material processing status enum
class MaterialStatus(Enum):
PENDING = 0
PROCESSING = 1
COMPLETED = 2
FAILED = 3
PREVIEW_BASED = 4
# Global variables to store results and track progress
material_results = {} # {material_name: (color, status)}
current_material = ""
processed_count = 0
total_materials = 0
start_time = 0
is_processing = False
material_queue = []
current_index = 0
# Scene properties for viewport display settings
def register_viewport_properties():
bpy.types.Scene.viewport_colors_selected_only = bpy.props.BoolProperty( # type: ignore
name="Selected Objects Only",
description="Apply viewport colors only to materials in selected objects",
default=False
)
bpy.types.Scene.viewport_colors_batch_size = bpy.props.IntProperty( # type: ignore
name="Batch Size",
description="Number of materials to process in each batch",
default=50,
min=1,
max=50
)
bpy.types.Scene.viewport_colors_use_vectorized = bpy.props.BoolProperty( # type: ignore
name="Use Vectorized Processing",
description="Use vectorized operations for image processing (faster but uses more memory)",
default=True
)
bpy.types.Scene.viewport_colors_darken_amount = bpy.props.FloatProperty( # type: ignore
name="Color Adjustment",
description="Adjust viewport colors by ±10% (+1 = +10% lighter, 0 = no change, -1 = -10% darker)",
default=0.0,
min=-1.0,
max=1.0,
subtype='FACTOR'
)
bpy.types.Scene.viewport_colors_value_amount = bpy.props.FloatProperty( # type: ignore
name="Saturation Adjustment",
description="Adjust color saturation by ±10% (+1 = +10% more saturated, 0 = no change, -1 = -10% less saturated)",
default=1.0,
min=-1.0,
max=1.0,
subtype='FACTOR'
)
bpy.types.Scene.viewport_colors_progress = bpy.props.FloatProperty( # type: ignore
name="Progress",
description="Progress of the viewport color setting operation",
default=0.0,
min=0.0,
max=100.0,
subtype='PERCENTAGE'
)
bpy.types.Scene.viewport_colors_show_advanced = bpy.props.BoolProperty( # type: ignore
name="Show Advanced Options",
description="Show advanced options for viewport color extraction",
default=False
)
# New properties for thumbnail-based color extraction
bpy.types.Scene.viewport_colors_use_preview = bpy.props.BoolProperty( # type: ignore
name="Use Material Thumbnails",
description="Use Blender's material thumbnails for color extraction (faster and more reliable)",
default=True
)
bpy.types.Scene.show_material_results = bpy.props.BoolProperty(
name="",
description="Show material results in the viewport display panel",
default=True
)
def unregister_viewport_properties():
del bpy.types.Scene.viewport_colors_use_preview
del bpy.types.Scene.viewport_colors_batch_size
del bpy.types.Scene.viewport_colors_use_vectorized
del bpy.types.Scene.viewport_colors_darken_amount
del bpy.types.Scene.viewport_colors_value_amount
del bpy.types.Scene.viewport_colors_progress
del bpy.types.Scene.viewport_colors_selected_only
del bpy.types.Scene.viewport_colors_show_advanced
del bpy.types.Scene.show_material_results
class VIEWPORT_OT_SetViewportColors(bpy.types.Operator):
"""Set Viewport Display colors from BSDF base color or texture"""
bl_idname = "bst.set_viewport_colors"
bl_label = "Set Viewport Colors"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
global material_results, current_material, processed_count, total_materials, start_time, is_processing, material_queue, current_index
# Reset global variables
material_results = {}
current_material = ""
processed_count = 0
is_processing = True
start_time = time()
current_index = 0
# Get materials based on selection mode
if context.scene.viewport_colors_selected_only:
# Get materials from selected objects only
materials = []
for obj in context.selected_objects:
if obj.type == 'MESH' and obj.data.materials:
for mat in obj.data.materials:
if mat and not mat.is_grease_pencil and mat not in materials:
materials.append(mat)
else:
# Get all materials in the scene
materials = [mat for mat in bpy.data.materials if not mat.is_grease_pencil]
total_materials = len(materials)
material_queue = materials.copy()
if total_materials == 0:
self.report({'WARNING'}, "No materials found to process")
is_processing = False
return {'CANCELLED'}
# Reset progress
context.scene.viewport_colors_progress = 0.0
# Start a timer to process materials in batches
bpy.app.timers.register(self._process_batch)
return {'FINISHED'}
def _process_batch(self):
global material_results, current_material, processed_count, total_materials, is_processing, material_queue, current_index
if not is_processing or len(material_queue) == 0:
is_processing = False
self.report_info()
return None
# Get the batch size from scene properties
batch_size = bpy.context.scene.viewport_colors_batch_size
use_vectorized = bpy.context.scene.viewport_colors_use_vectorized
# Process a batch of materials
batch_end = min(current_index + batch_size, len(material_queue))
batch = material_queue[current_index:batch_end]
for material in batch:
# Skip if material is invalid or has been deleted
if material is None or material.name not in bpy.data.materials:
processed_count += 1
continue
current_material = material.name
# Process the material
color, status = process_material(material, use_vectorized)
# Apply the color to the material
if color:
# Store the color change to apply later in main thread
material_results[material.name] = (color, status)
# Mark this material for color application
if not hasattr(self, 'pending_color_changes'):
self.pending_color_changes = []
self.pending_color_changes.append((material, color))
else:
# Store the result without color change
material_results[material.name] = (None, status)
# Update processed count
processed_count += 1
# Update progress
if total_materials > 0:
bpy.context.scene.viewport_colors_progress = (processed_count / total_materials) * 100
# Update the current index
current_index = batch_end
# Force a redraw of the UI
for area in bpy.context.screen.areas:
area.tag_redraw()
# Check if we're done
if current_index >= len(material_queue):
is_processing = False
# Apply pending color changes in main thread
if hasattr(self, 'pending_color_changes') and self.pending_color_changes:
bpy.app.timers.register(self._apply_color_changes)
self.report_info()
return None
# Continue processing
return 0.1 # Check again in 0.1 seconds
def _apply_color_changes(self):
"""Apply pending color changes in the main thread"""
if not hasattr(self, 'pending_color_changes') or not self.pending_color_changes:
return None
# Apply a batch of color changes
batch_size = 10 # Process 10 materials at a time
batch = self.pending_color_changes[:batch_size]
for material, color in batch:
try:
if material and material.name in bpy.data.materials:
material.diffuse_color = (*color, 1.0)
except Exception as e:
print(f"Could not set diffuse_color for {material.name if material else 'Unknown'}: {e}")
# Remove processed items
self.pending_color_changes = self.pending_color_changes[batch_size:]
# Continue if there are more to process
if self.pending_color_changes:
return 0.01 # Process next batch in 0.01 seconds
# All done
print(f"Applied viewport colors to {len(batch)} materials")
return None
def report_info(self):
global processed_count, start_time
elapsed_time = time() - start_time
# Count materials by status
preview_count = 0
node_count = 0
failed_count = 0
for _, status in material_results.values():
if status == MaterialStatus.PREVIEW_BASED:
preview_count += 1
elif status == MaterialStatus.COMPLETED:
node_count += 1
elif status == MaterialStatus.FAILED:
failed_count += 1
# Use a popup menu instead of self.report since this might be called from a timer
def draw_popup(self, context):
self.layout.label(text=f"Processed {processed_count} materials in {elapsed_time:.2f} seconds")
self.layout.label(text=f"Thumbnail-based: {preview_count}, Node-based: {node_count}")
self.layout.label(text=f"Failed: {failed_count}")
bpy.context.window_manager.popup_menu(draw_popup, title="Processing Complete", icon='INFO')
def correct_viewport_color(color):
"""Adjust viewport colors by color intensity and saturation"""
r, g, b = color
# Get the color adjustment amount (-1 to +1) and scale it to ±10%
color_adjustment = bpy.context.scene.viewport_colors_darken_amount * 0.1
# Get the saturation adjustment amount (-1 to +1) and scale it to ±10%
saturation_adjustment = bpy.context.scene.viewport_colors_value_amount * 0.1
# First apply the color adjustment (RGB)
r = r + color_adjustment
g = g + color_adjustment
b = b + color_adjustment
# Clamp RGB values after color adjustment
r = max(0.0, min(1.0, r))
g = max(0.0, min(1.0, g))
b = max(0.0, min(1.0, b))
# Then apply the saturation adjustment using HSV
if saturation_adjustment != 0:
# Convert to HSV
h, s, v = colorsys.rgb_to_hsv(r, g, b)
# Adjust saturation while preserving hue and value
s = s + saturation_adjustment
s = max(0.0, min(1.0, s))
# Convert back to RGB
r, g, b = colorsys.hsv_to_rgb(h, s, v)
return (r, g, b)
def process_material(material, use_vectorized=True):
"""Process a material to determine its viewport color"""
if not material:
print(f"Material is None, using fallback color")
return (1, 1, 1), MaterialStatus.PREVIEW_BASED
if material.is_grease_pencil:
print(f"Material {material.name}: is a grease pencil material, using fallback color")
return (1, 1, 1), MaterialStatus.PREVIEW_BASED
try:
# Get color from material thumbnail
print(f"Material {material.name}: Attempting to extract color from thumbnail")
# Get color from the material thumbnail
color = get_color_from_preview(material, use_vectorized)
if color:
print(f"Material {material.name}: Thumbnail color = {color}")
# Correct color for viewport display
corrected_color = correct_viewport_color(color)
print(f"Material {material.name}: Corrected thumbnail color = {corrected_color}")
return corrected_color, MaterialStatus.PREVIEW_BASED
else:
print(f"Material {material.name}: Could not extract color from thumbnail, using fallback color")
return (1, 1, 1), MaterialStatus.PREVIEW_BASED
except Exception as e:
print(f"Error processing material {material.name}: {e}")
return (1, 1, 1), MaterialStatus.FAILED
def get_average_color(image, use_vectorized=True):
"""Calculate the average color of an image"""
if not image or not image.has_data:
return None
# Get image pixels
pixels = list(image.pixels)
if use_vectorized and np is not None:
# Use NumPy for faster processing
pixels_np = np.array(pixels)
# Reshape to RGBA format
pixels_np = pixels_np.reshape(-1, 4)
# Calculate average color (ignoring alpha)
avg_color = pixels_np[:, :3].mean(axis=0)
return avg_color.tolist()
else:
# Fallback to pure Python
total_r, total_g, total_b = 0, 0, 0
pixel_count = len(pixels) // 4
for i in range(0, len(pixels), 4):
total_r += pixels[i]
total_g += pixels[i+1]
total_b += pixels[i+2]
if pixel_count > 0:
return [total_r / pixel_count, total_g / pixel_count, total_b / pixel_count]
else:
return None
def find_image_node(node, visited=None):
"""Find the first image node connected to the given node"""
if visited is None:
visited = set()
if node in visited:
return None
visited.add(node)
# Check if this is an image node
if node.type == 'TEX_IMAGE' and node.image:
return node
# Check input connections
for input_socket in node.inputs:
for link in input_socket.links:
from_node = link.from_node
result = find_image_node(from_node, visited)
if result:
return result
return None
def find_color_source(node, socket_name=None, visited=None):
"""
Recursively trace color data through nodes to find the source
This is an enhanced version that handles mix nodes and node groups
"""
if visited is None:
visited = set()
# Avoid infinite recursion
node_id = (node, socket_name)
if node_id in visited:
return None, None
visited.add(node_id)
# Handle different node types
if node.type == 'TEX_IMAGE' and node.image:
# Direct image texture
return node, 'Color'
elif node.type == 'RGB':
# Direct RGB color
return node, 'Color'
elif node.type == 'VALTORGB': # Color Ramp
return node, 'Color'
elif node.type == 'MIX_RGB' or node.type == 'MIX':
# For mix nodes, check the factor to determine which input to prioritize
factor = 0.5 # Default to equal mix
# Try to get the factor value
if len(node.inputs) >= 1:
if hasattr(node.inputs[0], 'default_value'):
factor = node.inputs[0].default_value
# If factor is close to 0, prioritize the first color input
# If factor is close to 1, prioritize the second color input
# Otherwise, check both with second having slightly higher priority
if factor < 0.1: # Strongly favor first input
if len(node.inputs) >= 2 and node.inputs[1].links:
color1_node = node.inputs[1].links[0].from_node
result_node, result_socket = find_color_source(color1_node, None, visited)
if result_node:
return result_node, result_socket
# Fallback to second input
if len(node.inputs) >= 3 and node.inputs[2].links:
color2_node = node.inputs[2].links[0].from_node
result_node, result_socket = find_color_source(color2_node, None, visited)
if result_node:
return result_node, result_socket
elif factor > 0.9: # Strongly favor second input
if len(node.inputs) >= 3 and node.inputs[2].links:
color2_node = node.inputs[2].links[0].from_node
result_node, result_socket = find_color_source(color2_node, None, visited)
if result_node:
return result_node, result_socket
# Fallback to first input
if len(node.inputs) >= 2 and node.inputs[1].links:
color1_node = node.inputs[1].links[0].from_node
result_node, result_socket = find_color_source(color1_node, None, visited)
if result_node:
return result_node, result_socket
else:
# Check both inputs with slight preference for the second input (usually the main color)
# First try Color2 (second input)
if len(node.inputs) >= 3 and node.inputs[2].links:
color2_node = node.inputs[2].links[0].from_node
result_node, result_socket = find_color_source(color2_node, None, visited)
if result_node:
return result_node, result_socket
# Then try Color1 (first input)
if len(node.inputs) >= 2 and node.inputs[1].links:
color1_node = node.inputs[1].links[0].from_node
result_node, result_socket = find_color_source(color1_node, None, visited)
if result_node:
return result_node, result_socket
elif node.type == 'GROUP':
# Handle node groups by finding the group output node and tracing back
if node.node_tree:
# Find output node in the group
for group_node in node.node_tree.nodes:
if group_node.type == 'GROUP_OUTPUT':
# Find which input socket corresponds to the color output
for i, output in enumerate(node.outputs):
if output.links and (socket_name is None or output.name == socket_name):
# Find the corresponding input in the group output node
if i < len(group_node.inputs) and group_node.inputs[i].links:
input_link = group_node.inputs[i].links[0]
source_node = input_link.from_node
source_socket = input_link.from_socket.name
return find_color_source(source_node, source_socket, visited)
elif node.type == 'BSDF_PRINCIPLED':
# If we somehow got to a principled BSDF node, check its base color input
base_color_input = node.inputs.get('Base Color')
if base_color_input and base_color_input.links:
connected_node = base_color_input.links[0].from_node
return find_color_source(connected_node, None, visited)
# For shader nodes, try to find color inputs
elif 'BSDF' in node.type or 'SHADER' in node.type:
# Look for color inputs in shader nodes
color_input_names = ['Color', 'Base Color', 'Diffuse Color', 'Tint']
for name in color_input_names:
input_socket = node.inputs.get(name)
if input_socket and input_socket.links:
connected_node = input_socket.links[0].from_node
result_node, result_socket = find_color_source(connected_node, None, visited)
if result_node:
return result_node, result_socket
# For other node types, check all inputs
for input_socket in node.inputs:
if input_socket.links:
from_node = input_socket.links[0].from_node
result_node, result_socket = find_color_source(from_node, None, visited)
if result_node:
return result_node, result_socket
# If we get here, no color source was found
return None, None
def get_final_color(material):
"""Get the final color for a material"""
if not material or not material.use_nodes:
print(f"Material {material.name if material else 'None'} has no nodes")
return None
# Find the Principled BSDF node
principled_node = None
for node in material.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED':
principled_node = node
break
if not principled_node:
print(f"Material {material.name}: No Principled BSDF node found")
return None
# Get the Base Color input
base_color_input = principled_node.inputs.get('Base Color')
if not base_color_input:
print(f"Material {material.name}: No Base Color input found")
return None
# Check if there's a texture connected to the Base Color input
if base_color_input.links:
connected_node = base_color_input.links[0].from_node
print(f"Material {material.name}: Base Color connected to {connected_node.name} of type {connected_node.type}")
# Use the enhanced color source finding function
source_node, source_socket = find_color_source(connected_node)
if source_node:
print(f"Material {material.name}: Found color source node {source_node.name} of type {source_node.type}")
# Handle different source node types
if source_node.type == 'TEX_IMAGE' and source_node.image:
print(f"Material {material.name}: Using image texture {source_node.image.name}")
color = get_average_color(source_node.image)
if color:
print(f"Material {material.name}: Image average color = {color}")
return color
else:
print(f"Material {material.name}: Could not calculate image average color")
# If it's a color ramp, get the average color from the ramp
elif source_node.type == 'VALTORGB': # Color Ramp node
print(f"Material {material.name}: Using color ramp")
# Get the average of the color stops
elements = source_node.color_ramp.elements
if elements:
avg_color = [0, 0, 0]
for element in elements:
color = element.color[:3] # Ignore alpha
avg_color[0] += color[0]
avg_color[1] += color[1]
avg_color[2] += color[2]
avg_color[0] /= len(elements)
avg_color[1] /= len(elements)
avg_color[2] /= len(elements)
print(f"Material {material.name}: Color ramp average = {avg_color}")
return avg_color
# If it's an RGB node, use its color
elif source_node.type == 'RGB':
color = list(source_node.outputs[0].default_value)[:3]
print(f"Material {material.name}: RGB node color = {color}")
return color
# For other node types, try to get color from the output socket
elif source_socket and hasattr(source_node.outputs, '__getitem__'):
for output in source_node.outputs:
if output.name == source_socket:
if hasattr(output, 'default_value') and len(output.default_value) >= 3:
color = list(output.default_value)[:3]
print(f"Material {material.name}: Node output socket color = {color}")
return color
print(f"Material {material.name}: Could not extract color from source node {source_node.name} of type {source_node.type}")
else:
print(f"Material {material.name}: Could not find color source node in the node tree")
# Debug: Print the node tree structure to help diagnose the issue
print(f"Material {material.name}: Node tree structure:")
for node in material.node_tree.nodes:
print(f" - Node: {node.name}, Type: {node.type}")
for input_socket in node.inputs:
if input_socket.links:
print(f" - Input: {input_socket.name} connected to {input_socket.links[0].from_node.name}")
# If no texture or couldn't get texture color, use the base color value
color = list(base_color_input.default_value)[:3]
print(f"Material {material.name}: Using base color value = {color}")
return color
def find_diffuse_texture(material):
"""Find the diffuse texture in a material"""
if not material or not material.use_nodes:
return None
# Find the principled BSDF node
principled_node = None
for node in material.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED':
principled_node = node
break
if not principled_node:
return None
# Find the base color input
base_color_input = principled_node.inputs.get('Base Color')
if not base_color_input or not base_color_input.links:
return None
# Get the connected node
connected_node = base_color_input.links[0].from_node
# Use the enhanced color source finding function
source_node, _ = find_color_source(connected_node)
# Check if we found an image texture
if source_node and source_node.type == 'TEX_IMAGE' and source_node.image:
return source_node.image
return None
def get_status_icon(status):
"""Get the icon for a material status"""
if status == MaterialStatus.PENDING:
return 'TRIA_RIGHT'
elif status == MaterialStatus.PROCESSING:
return 'SORTTIME'
elif status == MaterialStatus.COMPLETED:
return 'CHECKMARK'
elif status == MaterialStatus.PREVIEW_BASED:
return 'IMAGE_DATA'
elif status == MaterialStatus.FAILED:
return 'ERROR'
else:
return 'QUESTION'
def get_status_text(status):
"""Get the text for a material status"""
if status == MaterialStatus.PENDING:
return "Pending"
elif status == MaterialStatus.PROCESSING:
return "Processing"
elif status == MaterialStatus.COMPLETED:
return "Node-based"
elif status == MaterialStatus.PREVIEW_BASED:
return "Thumbnail-based"
elif status == MaterialStatus.FAILED:
return "Failed"
else:
return "Unknown"
class VIEW3D_PT_BulkViewportDisplay(bpy.types.Panel):
"""Bulk Viewport Display Panel"""
bl_label = "Bulk Viewport Display"
bl_idname = "VIEW3D_PT_bulk_viewport_display"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = 'Edit'
bl_parent_id = "VIEW3D_PT_bulk_scene_tools"
bl_order = 3
def draw(self, context):
layout = self.layout
# Viewport Colors section
box = layout.box()
box.label(text="Viewport Colors")
# Add description
col = box.column()
col.label(text="Set viewport colors from material thumbnails")
# Add primary settings
col = box.column(align=True)
col.prop(context.scene, "viewport_colors_selected_only")
# Add advanced options in a collapsible section
row = box.row()
row.prop(context.scene, "viewport_colors_show_advanced",
icon='DISCLOSURE_TRI_DOWN' if context.scene.viewport_colors_show_advanced else 'DISCLOSURE_TRI_RIGHT',
emboss=False)
row.label(text="Advanced Options")
if context.scene.viewport_colors_show_advanced:
adv_col = box.column(align=True)
adv_col.prop(context.scene, "viewport_colors_batch_size")
adv_col.prop(context.scene, "viewport_colors_use_vectorized")
adv_col.prop(context.scene, "viewport_colors_darken_amount")
adv_col.prop(context.scene, "viewport_colors_value_amount")
# Add the operator button
row = box.row()
row.scale_y = 1.5
row.operator("bst.set_viewport_colors")
# Show progress if processing
if is_processing:
row = box.row()
row.label(text=f"Processing: {processed_count}/{total_materials}")
# Add a progress bar
row = box.row()
row.prop(context.scene, "viewport_colors_progress", text="")
# Show material results if available
if material_results:
box.separator()
row = box.row()
row.prop(context.scene, "show_material_results",
icon='DISCLOSURE_TRI_DOWN' if context.scene.show_material_results else 'DISCLOSURE_TRI_RIGHT',
emboss=False)
row.label(text="Material Results:")
if context.scene.show_material_results:
# Create a scrollable list
material_box = box.box()
row = material_box.row()
col = row.column()
# Collect materials to remove
materials_to_remove = []
# Count materials by status
preview_count = 0
failed_count = 0
# Display material results - use a copy of the keys to avoid modification during iteration
for material_name in list(material_results.keys()):
color, status = material_results[material_name]
# Update counts
if status == MaterialStatus.PREVIEW_BASED:
preview_count += 1
elif status == MaterialStatus.FAILED:
failed_count += 1
row = col.row(align=True)
# Add status icon
row.label(text="", icon=get_status_icon(status))
# Add material name with operator to select it
op = row.operator("bst.select_in_editor", text=material_name)
op.material_name = material_name
# Add color preview
if color:
material = bpy.data.materials.get(material_name)
if material: # Check if material still exists
row.prop(material, "diffuse_color", text="")
else:
# Material no longer exists, show a placeholder color
row.label(text="", icon='ERROR')
# Mark for removal
materials_to_remove.append(material_name)
# Remove materials that no longer exist
for material_name in materials_to_remove:
material_results.pop(material_name, None)
# Show statistics
if len(material_results) > 0:
material_box.separator()
stats_col = material_box.column(align=True)
stats_col.label(text=f"Total: {len(material_results)} materials")
stats_col.label(text=f"Thumbnail-based: {preview_count}")
stats_col.label(text=f"Failed: {failed_count}")
# Add the select diffuse nodes button at the bottom
layout.separator()
layout.operator("bst.select_diffuse_nodes", icon='NODE_TEXTURE')
class MATERIAL_OT_SelectInEditor(bpy.types.Operator):
"""Select this material in the editor"""
bl_idname = "bst.select_in_editor"
bl_label = "Select Material"
bl_options = {'REGISTER', 'UNDO'}
material_name: bpy.props.StringProperty( # type: ignore
name="Material Name",
description="Name of the material to select",
default=""
)
def execute(self, context):
# Find the material
material = bpy.data.materials.get(self.material_name)
if not material:
# Remove this entry from material_results to avoid future errors
if self.material_name in material_results:
material_results.pop(self.material_name, None)
# Force a redraw of the UI
for area in context.screen.areas:
area.tag_redraw()
self.report({'ERROR'}, f"Material '{self.material_name}' not found")
return {'CANCELLED'}
# Find an object using this material
for obj in bpy.data.objects:
if obj.type == 'MESH' and obj.data.materials:
for i, mat in enumerate(obj.data.materials):
if mat == material:
# Select the object
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
context.view_layer.objects.active = obj
# Set the active material index
obj.active_material_index = i
# Switch to material properties
for area in context.screen.areas:
if area.type == 'PROPERTIES':
for space in area.spaces:
if space.type == 'PROPERTIES':
space.context = 'MATERIAL'
return {'FINISHED'}
self.report({'WARNING'}, f"No object using material '{self.material_name}' found")
return {'CANCELLED'}
def get_color_from_preview(material, use_vectorized=True):
"""Extract the average color from a material thumbnail"""
if not material:
return None
# Force Blender to generate the material preview if it doesn't exist
# This uses Blender's internal preview generation system
preview = material.preview
if not preview:
return None
# Ensure the preview is generated (this should be very fast as Blender maintains these)
if preview.icon_id == 0:
# Use Blender's standard preview size
preview.icon_size = (128, 128)
# This triggers Blender's internal preview generation
icon_id = preview.icon_id # Store in variable instead of just accessing
# Access the preview image data - these are the same thumbnails shown in the material panel
preview_image = preview.icon_pixels_float
if not preview_image or len(preview_image) == 0:
return None
if use_vectorized and np is not None:
# Use NumPy for faster processing
pixels_np = np.array(preview_image)
# Reshape to RGBA format (preview is stored as a flat RGBA array)
pixels_np = pixels_np.reshape(-1, 4)
# Calculate average color (ignoring alpha and any pure black pixels which are often the background)
# Filter out black pixels (background) by checking if R+G+B is very small
non_black_mask = np.sum(pixels_np[:, :3], axis=1) > 0.05
if np.any(non_black_mask):
# Only use non-black pixels for the average
avg_color = pixels_np[non_black_mask][:, :3].mean(axis=0)
return avg_color.tolist()
else:
# If all pixels are black, return the average of all pixels
avg_color = pixels_np[:, :3].mean(axis=0)
return avg_color.tolist()
else:
# Fallback to pure Python
total_r, total_g, total_b = 0, 0, 0
pixel_count = 0
non_black_count = 0
# Process pixels in groups of 4 (RGBA)
for i in range(0, len(preview_image), 4):
r, g, b, a = preview_image[i:i+4]
# Skip black pixels (background)
if r + g + b > 0.05:
total_r += r
total_g += g
total_b += b
non_black_count += 1
pixel_count += 1
# If we found non-black pixels, use their average
if non_black_count > 0:
return [total_r / non_black_count, total_g / non_black_count, total_b / non_black_count]
# Otherwise, use the average of all pixels
elif pixel_count > 0:
return [total_r / pixel_count, total_g / pixel_count, total_b / pixel_count]
else:
return None
class VIEWPORT_OT_SelectDiffuseNodes(bpy.types.Operator):
bl_idname = "bst.select_diffuse_nodes"
bl_label = "Set Texture Display"
bl_description = "Select the most relevant diffuse/base color image texture node in each material"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
if select_diffuse_nodes:
select_diffuse_nodes()
self.report({'INFO'}, "Diffuse/BaseColor image nodes selected.")
else:
self.report({'ERROR'}, "select_diffuse_nodes function not found.")
return {'FINISHED'}
# List of all classes in this module
classes = (
VIEWPORT_OT_SetViewportColors,
VIEW3D_PT_BulkViewportDisplay,
MATERIAL_OT_SelectInEditor,
VIEWPORT_OT_SelectDiffuseNodes,
)
# Registration
def register():
for cls in classes:
bpy.utils.register_class(cls)
# Register properties
register_viewport_properties()
def unregister():
# Unregister properties
try:
unregister_viewport_properties()
except Exception:
pass
# Unregister classes
for cls in reversed(classes):
try:
bpy.utils.unregister_class(cls)
except RuntimeError:
pass