2025-07-01
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
# #### BEGIN GPL LICENSE BLOCK #####
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation,
|
||||
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
#
|
||||
# ##### END GPL LICENSE BLOCK #####
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def f_Ex(vPath):
|
||||
return os.path.exists(vPath)
|
||||
|
||||
|
||||
def f_FName(vPath):
|
||||
return os.path.splitext(os.path.basename(vPath))[0]
|
||||
|
||||
|
||||
def f_FExt(vPath):
|
||||
return os.path.splitext(os.path.basename(vPath))[1].lower()
|
||||
|
||||
|
||||
def f_FNameExt(vPath):
|
||||
vSplit = list(os.path.splitext(os.path.basename(vPath)))
|
||||
vSplit[1] = vSplit[1].lower()
|
||||
return vSplit
|
||||
|
||||
|
||||
def f_FSplit(vPath):
|
||||
vSplit = list(os.path.splitext(vPath))
|
||||
vSplit[1] = vSplit[1].lower()
|
||||
return vSplit
|
||||
|
||||
|
||||
def f_MDir(vPath):
|
||||
if f_Ex(vPath):
|
||||
return
|
||||
|
||||
try:
|
||||
os.makedirs(vPath)
|
||||
except Exception as e:
|
||||
print("Failed to create directory: ", e)
|
||||
|
||||
|
||||
def timer(fn):
|
||||
def wrapper(*args, **kwargs):
|
||||
start_time = time.perf_counter()
|
||||
|
||||
result = fn(*args, **kwargs)
|
||||
|
||||
end_time = time.perf_counter()
|
||||
duration = round(end_time - start_time, 2)
|
||||
if duration > 60:
|
||||
msec = str(duration - int(duration)).split('.')[1]
|
||||
duration = f"{time.strftime('%M:%S', time.gmtime(duration))}.{msec}"
|
||||
|
||||
print(f"{fn.__name__} : {duration}s")
|
||||
|
||||
return result
|
||||
return wrapper
|
||||
|
||||
|
||||
def construct_model_name(asset_name, size, lod):
|
||||
"""Constructs the model name from the given inputs."""
|
||||
|
||||
if lod:
|
||||
model_name = f"{asset_name}_{size}_{lod}"
|
||||
else:
|
||||
model_name = f"{asset_name}_{size}"
|
||||
return model_name
|
||||
|
||||
|
||||
def shorten_label(label: str, max_len: int) -> str:
|
||||
"""Shortens the given label by replacing the center part with '...'"""
|
||||
|
||||
if len(label) <= max_len:
|
||||
return label
|
||||
|
||||
overlen = (len(label) - (max_len - 3)) // 2
|
||||
center = len(label) // 2
|
||||
return label[:center - overlen] + "..." + label[center + overlen:]
|
||||
|
||||
|
||||
def open_dir(directory: str) -> bool:
|
||||
"""Attempts to open a directory in a cross-platform way."""
|
||||
|
||||
directory_norm = os.path.normpath(directory)
|
||||
did_open = False
|
||||
try:
|
||||
os.startfile(directory_norm)
|
||||
did_open = True
|
||||
except Exception:
|
||||
# TODO(Andreas): I somehow doubt, the following is really necessary.
|
||||
# Neither P4Max nor P4Cinema jump through such hoops.
|
||||
try:
|
||||
subprocess.Popen(("open", directory_norm))
|
||||
did_open = True
|
||||
except Exception:
|
||||
try:
|
||||
subprocess.Popen(("xdg-open", directory_norm))
|
||||
did_open = True
|
||||
except Exception:
|
||||
pass
|
||||
return did_open
|
||||
|
||||
|
||||
def is_cycles() -> bool:
|
||||
"""Returns True, if Cycles render engine is enabled."""
|
||||
|
||||
return bpy.context.scene.render.engine == "CYCLES"
|
||||
|
||||
|
||||
def is_eevee() -> bool:
|
||||
"""Returns True, if Eevee render engine is enabled."""
|
||||
|
||||
return bpy.context.scene.render.engine == "BLENDER_EEVEE"
|
||||
|
||||
|
||||
def is_eevee_next() -> bool:
|
||||
"""Returns True, if Eevee render engine is enabled."""
|
||||
|
||||
# Blender 4.2's Eevee Next supports for example displacement
|
||||
# (but not adaptive)
|
||||
return bpy.context.scene.render.engine == "BLENDER_EEVEE_NEXT"
|
||||
|
||||
|
||||
def set_colorspace(img: bpy.types.Image, colorspace: str = "") -> None:
|
||||
"""Sets an image's colorspace.
|
||||
|
||||
It does so in a try statement, because some people might actually
|
||||
replace the default colorspace settings, and it literally can't be
|
||||
guessed what these people use, even if it will mostly be the filmic addon.
|
||||
"""
|
||||
try:
|
||||
if colorspace == "":
|
||||
colorspace = guess_colorspace()
|
||||
|
||||
if colorspace == "Non-Color":
|
||||
img.colorspace_settings.is_data = True
|
||||
else:
|
||||
img.colorspace_settings.name = colorspace
|
||||
except Exception as e:
|
||||
print(f"Colorspace {colorspace} not found: {e}")
|
||||
|
||||
|
||||
def guess_colorspace() -> str:
|
||||
display_device = bpy.context.scene.display_settings.display_device
|
||||
if display_device == "sRGB":
|
||||
return "sRGB"
|
||||
if display_device == "ACES":
|
||||
return "aces"
|
||||
|
||||
|
||||
def img_to_preview(img: bpy.types.Image, copy_original: bool = False) -> None:
|
||||
"""Ensures an image's preview is identical to the image."""
|
||||
|
||||
if bpy.app.version[0] >= 3:
|
||||
img.preview_ensure()
|
||||
if not copy_original:
|
||||
return
|
||||
if img.preview.image_size != img.size:
|
||||
img.preview.image_size = (img.size[0], img.size[1])
|
||||
img.preview.image_pixels_float = img.pixels[:]
|
||||
# for _idx in range(3, len(img.preview.image_pixels_float), 4):
|
||||
# img.preview.image_pixels_float[_idx] = 0.0
|
||||
|
||||
|
||||
def remove_alpha(img: bpy.types.Image, color_bg: List[float]) -> None:
|
||||
"""Replaces transparent parts of an image with a new background color."""
|
||||
|
||||
pixels = list(img.pixels)
|
||||
for _idx in range(0, len(pixels), 4):
|
||||
idx_alpha = _idx + 3
|
||||
alpha = pixels[idx_alpha]
|
||||
if alpha < 0.95:
|
||||
r = min(color_bg[0] * (1 - alpha) + pixels[_idx] * alpha, 1.0)
|
||||
g = min(color_bg[1] * (1 - alpha) + pixels[_idx + 1] * alpha, 1.0)
|
||||
b = min(color_bg[2] * (1 - alpha) + pixels[_idx + 2] * alpha, 1.0)
|
||||
|
||||
pixels[_idx:_idx + 3] = [r, g, b]
|
||||
pixels[idx_alpha] = 1.0 # no transparency
|
||||
img.pixels = pixels
|
||||
img.update()
|
||||
|
||||
|
||||
def load_image(
|
||||
name: str,
|
||||
path: str,
|
||||
*,
|
||||
hidden: bool = True,
|
||||
do_identical_preview: bool = True,
|
||||
do_set_colorspace: bool = True,
|
||||
do_remove_alpha: bool = False,
|
||||
color_bg: List[float] = [0.0, 0.0, 0.0, 0.0],
|
||||
force: bool = False
|
||||
) -> bpy.types.Image:
|
||||
"""Loads an image from disk.
|
||||
|
||||
Arguments:
|
||||
hidden: Mark the image data block as hidden (prepending . to its name)
|
||||
do_identical_preview: Copies the original image into image's preview.
|
||||
do_set_colorspace: Sets color space of image to sRGB or ACES, if none.
|
||||
do_remove_alpha: Replaces transparent parts with a new background color.
|
||||
force: Do load the image, even if a data block with that name already
|
||||
exists.
|
||||
"""
|
||||
|
||||
if hidden:
|
||||
name = f".{name}" # hidden
|
||||
img = bpy.data.images.get(name)
|
||||
if img is not None and not force:
|
||||
return img
|
||||
|
||||
img = bpy.data.images.load(path, check_existing=True)
|
||||
if img is None:
|
||||
return None
|
||||
|
||||
img.name = name
|
||||
if do_set_colorspace:
|
||||
set_colorspace(img)
|
||||
if do_remove_alpha:
|
||||
remove_alpha(img, color_bg)
|
||||
if do_identical_preview:
|
||||
img_to_preview(img, copy_original=True)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def copy_simple_property_group(
|
||||
source: bpy.types.PropertyGroup,
|
||||
target: bpy.types.PropertyGroup
|
||||
) -> None:
|
||||
"""Copies property values from one PropertyGroup to another.
|
||||
|
||||
From: https://blenderartists.org/t/duplicating-pointerproperty-propertygroup-and-collectionproperty/1419096
|
||||
"""
|
||||
|
||||
if not hasattr(source, "__annotations__"):
|
||||
return
|
||||
for prop_name in source.__annotations__.keys():
|
||||
try:
|
||||
setattr(target, prop_name, getattr(source, prop_name))
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
|
||||
def compare_simple_property_group(
|
||||
group_a: bpy.types.PropertyGroup,
|
||||
group_b: bpy.types.PropertyGroup
|
||||
) -> bool:
|
||||
"""Compares property values of two PropertyGroups and
|
||||
returns True if equal.
|
||||
"""
|
||||
|
||||
if not hasattr(group_a, "__annotations__"):
|
||||
return False
|
||||
if not hasattr(group_b, "__annotations__"):
|
||||
return False
|
||||
|
||||
for prop_name in group_a.__annotations__.keys():
|
||||
try:
|
||||
val_a = getattr(group_a, prop_name)
|
||||
val_b = getattr(group_b, prop_name)
|
||||
if val_a != val_b:
|
||||
return False
|
||||
except (AttributeError, TypeError):
|
||||
return False
|
||||
return True
|
||||
Reference in New Issue
Block a user