Files
Bioxel/src/bioxelnodes/utils.py
MaNan a558d1ae30 feat: Add Bioxel asset catalog and layer management functionality
- Introduced a new asset catalog definition file for Blender to manage Bioxel assets.
- Implemented layer caching and snapshot functionality in `layer.py`, allowing for VDB file generation and low-resolution snapshots.
- Created node management functions in `node.py` for handling Bioxel nodes within Blender's Geometry Node Editor.
- Added an operator to extract mesh data from selected nodes in `structure.py`.
- Developed UI panels in `panels.py` for layer management, including import options and layer library display.
2026-01-21 12:42:12 +08:00

403 lines
11 KiB
Python

from pathlib import Path
from ast import literal_eval
import shutil
import bpy
import numpy as np
from .node import get_nodes_by_type
from .constants import LATEST_NODE_LIB_PATH, NODE_LIB_DIRPATH, PREVIEW_COLLECTIONS
def copy_to_dir(source_path, dir_path, new_name=None, exist_ok=True):
source = Path(source_path)
target = Path(dir_path)
# Check if the source exists
if not source.exists():
raise FileNotFoundError
# Check if the target exists
if not target.exists():
target.mkdir(parents=True, exist_ok=True)
target_path = target / new_name if new_name else target / source.name
# If source is a file, copy it to the target directory
if source.is_file():
try:
shutil.copy(source, target_path)
except shutil.SameFileError:
if exist_ok:
pass
else:
raise shutil.SameFileError
# If source is a directory, copy its contents to the target directory
elif source.is_dir():
shutil.copytree(source, target_path, dirs_exist_ok=exist_ok)
if not target_path.exists():
raise Exception
def select_object(target_obj):
for obj in bpy.data.objects:
obj.select_set(False)
target_obj.select_set(True)
bpy.context.view_layer.objects.active = target_obj
def progress_bar(self, context):
row = self.layout.row()
row.progress(
factor=context.window_manager.bioxel_progress_factor,
type="BAR",
text=context.window_manager.bioxel_progress_text
)
row.scale_x = 2
def progress_update(context, factor, text=""):
bpy.context.window_manager.bioxel_progress_factor = factor
bpy.context.window_manager.bioxel_progress_text = text
def get_preferences():
return bpy.context.preferences.addons[__package__].preferences
def get_cache_dir():
preferences = get_preferences()
cache_path = Path(preferences.cache_dir)
cache_path.mkdir(parents=True, exist_ok=True)
return cache_path
def get_use_link():
preferences = get_preferences()
return preferences.node_import_method == "LINK"
def get_container_objs_from_selection():
container_objs = []
for obj in bpy.context.selected_objects:
if get_container_obj(obj):
container_objs.append(obj)
return list(set(container_objs))
def get_container_obj(current_obj):
if current_obj:
if current_obj.get('bioxel_container'):
return current_obj
elif current_obj.get('bioxel_layer'):
parent = current_obj.parent
return parent if parent.get('bioxel_container') else None
return None
def get_layer_prop_value(layer_obj: bpy.types.Object, prop_name: str):
node_group = layer_obj.modifiers[0].node_group
layer_node = get_nodes_by_type(node_group, "BioxelNodes__Layer")[0]
prop = layer_node.inputs.get(prop_name)
if prop is None:
return None
value = prop.default_value
if type(value).__name__ == "bpy_prop_array":
value = tuple(value)
return tuple([int(v) for v in value]) \
if prop in ["shape"] else value
elif type(value).__name__ == "str":
return str(value)
elif type(value).__name__ == "float":
value = float(value)
return round(value, 2) \
if prop in ["bioxel_size"] else value
elif type(value).__name__ == "int":
value = int(value)
return value
else:
return value
def get_layer_name(layer_obj):
return get_layer_prop_value(layer_obj, "name")
def get_layer_kind(layer_obj):
return get_layer_prop_value(layer_obj, "kind")
def get_layer_label(layer_obj):
name = get_layer_name(layer_obj)
# kind = get_layer_kind(layer_obj)
label = f"{name}"
if is_missing_layer(layer_obj):
return "**MISSING**" + label
elif is_temp_layer(layer_obj):
return "* " + label
else:
return label
def is_missing_layer(layer_obj):
cache_filepath = Path(bpy.path.abspath(
layer_obj.data.filepath)).resolve()
return not cache_filepath.is_file()
def is_temp_layer(layer_obj):
cache_filepath = Path(bpy.path.abspath(
layer_obj.data.filepath)).resolve()
cache_dirpath = Path(get_cache_dir())
return cache_dirpath in cache_filepath.parents
def set_layer_prop_value(layer_obj: bpy.types.Object, prop: str, value):
node_group = layer_obj.modifiers[0].node_group
layer_node = get_nodes_by_type(node_group, "BioxelNodes__Layer")[0]
layer_node.inputs[prop].default_value = value
def get_layer_obj(current_obj: bpy.types.Object):
if current_obj:
if current_obj.get('bioxel_layer') and current_obj.parent:
if current_obj.parent.get('bioxel_container'):
return current_obj
return None
def get_container_layer_objs(container_obj: bpy.types.Object):
layer_objs = []
for obj in bpy.data.objects:
if obj.parent == container_obj and get_layer_obj(obj):
layer_objs.append(obj)
return layer_objs
def get_all_layer_objs():
layer_objs = []
for obj in bpy.data.objects:
if get_layer_obj(obj):
layer_objs.append(obj)
return layer_objs
def add_driver(target, target_prop, var_sources, expression):
driver = target.driver_add(target_prop)
is_vector = isinstance(driver, list)
drivers = driver if is_vector else [driver]
for i, driver in enumerate(drivers):
for j, var_source in enumerate(var_sources):
source = var_source['source']
prop = var_source['prop']
var = driver.driver.variables.new()
var.name = f"var{j}"
var.targets[0].id_type = source.id_type
var.targets[0].id = source
var.targets[0].data_path = f'["{prop}"][{i}]'\
if is_vector else f'["{prop}"]'
driver.driver.expression = expression
def add_direct_driver(target, target_prop, source, source_prop):
drivers = [
{
"source": source,
"prop": source_prop
}
]
expression = "var0"
add_driver(target, target_prop, drivers, expression)
def read_file_prop(content: str):
props = {}
for line in content.split("\n"):
line = line.replace(" ", "")
p = line.split("=")[0]
if p != "":
v = line.split("=")[-1]
props[p] = v
return props
def write_file_prop(props: dict):
lines = []
for p, v in props.items():
lines.append(f"{p} = {v}")
return "\n".join(lines)
def set_file_prop(prop, value):
if bpy.data.texts.get("BioxelNodes") is None:
bpy.data.texts.new("BioxelNodes")
props = read_file_prop(bpy.data.texts["BioxelNodes"].as_string())
props[prop] = value
bpy.data.texts["BioxelNodes"].clear()
bpy.data.texts["BioxelNodes"].write(write_file_prop(props))
def get_file_prop(prop):
if bpy.data.texts.get("BioxelNodes") is None:
bpy.data.texts.new("BioxelNodes")
props = read_file_prop(bpy.data.texts["BioxelNodes"].as_string())
return props.get(prop)
def get_node_version():
node_version = get_file_prop("node_version")
return literal_eval(node_version) if node_version else None
def is_incompatible():
return False
def get_node_lib_path(node_version):
version_str = "v"+".".join([str(i) for i in list(node_version)])
lib_filename = f"BioxelNodes_{version_str}.blend"
return Path(NODE_LIB_DIRPATH,
lib_filename).resolve()
def local_lib_not_updated():
return False
def ndarray_to_png(array: np.ndarray, save_path: str):
"""
Create a PNG from a 2D or 3-channel slice array using Blender API.
slice_arr expected shape: (W, H) or (W, H, 1) or (W,H,3). Values assumed numeric.
The function:
- Converts single-channel arrays to RGB by repeating channel.
- Flips the image vertically to match Blender's origin.
- Writes the image file to save_path using a temporary Blender image datablock.
Parameters:
- array: numpy ndarray representing the slice (W,H,C) or (W,H,1) or (W,H)
- save_path: destination PNG filepath
"""
save_path = Path(save_path)
h, w = array.shape[0], array.shape[1]
if array.shape[2] == 1:
img = np.concatenate([array, array, array], axis=2)
else:
img = array[..., :3]
# Blender expects pixels as sequence of floats in RGBA, length w*h*4
# NOTE: Blender's image origin is bottom-left; we'll flip vertically for visual consistency.
img = np.flipud(img)
pixels = np.empty((h * w * 4,), dtype=np.float32)
# fill
pixels[0::4] = img[..., 0].ravel()
pixels[1::4] = img[..., 1].ravel()
pixels[2::4] = img[..., 2].ravel()
pixels[3::4] = 1.0
# create a temporary image datablock, save then remove
name = "tmp"
image = bpy.data.images.new(
name, width=w, height=h, alpha=True, float_buffer=False)
image.pixels = pixels.tolist()
# ensure format and filepath then save
image.filepath_raw = str(save_path)
image.file_format = "PNG"
image.save()
# remove from data to avoid clutter
try:
bpy.data.images.remove(image)
except Exception:
pass
def split_text_to_lines(text: str, max_chars: int = 60) -> list:
"""
Split `text` into multiple lines not exceeding `max_chars` where possible.
Breaks on whitespace; long words are chunked.
Returns list of line strings.
"""
if not text:
return []
words = text.split()
lines = []
cur = ""
for w in words:
if cur:
candidate = cur + " " + w
else:
candidate = w
if len(candidate) <= max_chars:
cur = candidate
else:
if cur:
lines.append(cur)
# if single word longer than max_chars, chunk it
if len(w) > max_chars:
for i in range(0, len(w), max_chars):
lines.append(w[i: i + max_chars])
cur = ""
else:
cur = w
if cur:
lines.append(cur)
return lines
def wrapped_label(layout, text: str, max_chars: int = 42):
"""
Helper to add multiple layout.label calls for a long text.
"""
for line in split_text_to_lines(text, max_chars=max_chars):
layout.label(text=line)
def refresh_bioxel_panels(context):
"""
Force Bioxel UI to refresh.
- Tags relevant areas for redraw.
- Nudges the bioxel_layer_gallery EnumProperty to force items() re-evaluation
when possible (helps template_icon_view pick up updated icons/paths).
Use this from operators after they change b i o x e l _ l a y e r s or previews.
"""
# tag redraw for all windows/areas
for window in context.window_manager.windows:
for area in window.screen.areas:
# target node editor and other UI areas that may show previews
if area.type in {"NODE_EDITOR", "VIEW_3D", "IMAGE_EDITOR", "OUTLINER"}:
area.tag_redraw()
def load_icon(filepath: Path, key: str):
pcoll = PREVIEW_COLLECTIONS["layers"]
try:
icon = pcoll[key].icon_id
except KeyError:
if filepath.exists():
pcoll.load(key, str(filepath), "IMAGE")
try:
icon = pcoll[key].icon_id
except KeyError:
icon = "TEXTURE"
return icon