Files
Bioxel/src/bioxelnodes/operators/layer.py
MaNan ced0898f84 Add unit tests for bioxel module and documentation
- Created README.md for test instructions and categories.
- Implemented comprehensive unit tests for the Data, Layer, and utility functions in the bioxel module.
- Added tests for reading NRRD and DICOM formats, data properties, and edge cases.
- Included tests for real data files to ensure functionality with actual datasets.
2026-01-22 09:38:07 +08:00

315 lines
11 KiB
Python

from pathlib import Path
import shutil
import bpy
from ..node import add_bioxel_node, get_layer_nodes, get_main_node_group
from ..utils import refresh_bioxel_panels
from ..layer import get_layer_caches, set_layer_caches
class RenameLayer(bpy.types.Operator):
"""Rename a cached Bioxel layer"""
bl_idname = "bioxel.rename_layer"
bl_label = "Rename Layer"
bl_options = {"REGISTER", "UNDO"}
cache_id: bpy.props.StringProperty(options={"HIDDEN"}) # type: ignore
new_name: bpy.props.StringProperty(name="New name", default="") # type: ignore
def invoke(self, context, event):
caches = get_layer_caches()
entry = next((c for c in caches if str(c.get("id", "")) == self.cache_id), None)
if entry:
self.new_name = str(entry.get("name", ""))
return context.window_manager.invoke_props_dialog(self)
def execute(self, context):
if not self.new_name:
self.report({"ERROR"}, "Name cannot be empty")
return {"CANCELLED"}
caches = get_layer_caches()
for c in caches:
if str(c.get("id", "")) == self.cache_id:
c["name"] = self.new_name
break
try:
set_layer_caches(caches)
refresh_bioxel_panels(context)
self.report({"INFO"}, "Layer renamed")
return {"FINISHED"}
except Exception as e:
self.report({"ERROR"}, f"Failed to save changes: {e}")
return {"CANCELLED"}
class DeleteLayer(bpy.types.Operator):
"""Delete a saved Bioxel layer"""
bl_idname = "bioxel.delete_layer"
bl_label = "Delete Layer"
bl_options = {"REGISTER", "UNDO"}
cache_id: bpy.props.StringProperty() # type: ignore
def invoke(self, context, event):
return context.window_manager.invoke_confirm(self, event)
def execute(self, context):
if not self.cache_id:
self.report({"ERROR"}, "Missing cache id")
return {"CANCELLED"}
caches = get_layer_caches()
new_list = [c for c in caches if str(c.get("id", "")) != self.cache_id]
removed = next(
(c for c in caches if str(c.get("id", "")) == self.cache_id), None
)
try:
set_layer_caches(new_list)
refresh_bioxel_panels(context)
except Exception as e:
self.report({"ERROR"}, f"Failed to update layer list: {e}")
return {"CANCELLED"}
self.report({"INFO"}, "Layer removed")
return {"FINISHED"}
# 添加图层到节点图的操作器
class AddLayerNode(bpy.types.Operator):
"""将选中的图层添加到几何节点图"""
bl_idname = "bioxel.add_layer_node"
bl_label = "添加图层节点"
bl_options = {"REGISTER", "UNDO"}
cache_id: bpy.props.StringProperty() # type: ignore # 图层ID
def execute(self, context):
# 获取图层数据
caches = get_layer_caches()
# 查找目标图层
entry = next(
(c for c in caches if str(c.get("id", "")) == str(self.cache_id)), None
)
if not entry:
self.report({"ERROR"}, "Layer not found")
return {"CANCELLED"}
# 创建并设置节点属性
try:
layer_node = add_bioxel_node("O Layer")
except:
self.report({"ERROR"}, "Fail to add layer node")
return {"CANCELLED"}
layer_node.node_tree.make_local()
layer_node.inputs["Path"].default_value = entry["path"]
layer_node.inputs["Name"].default_value = entry["name"]
layer_node.inputs["Shape"].default_value = entry["shape"]
layer_node.inputs["Min"].default_value = entry["min"]
layer_node.inputs["Max"].default_value = entry["max"]
layer_node.inputs["ID"].default_value = entry["id"]
# 将特定属性隐藏到hidden面板
hidden_sockets = ["Path", "Shape", "Min", "Max", "ID", "Animation"]
if entry["frame_count"] > 1:
layer_node.inputs["Frame Count"].default_value = entry["frame_count"]
layer_node.inputs["Animation"].default_value = True
else:
hidden_sockets = hidden_sockets + ["Frame Count", "Frame Offset", "Cycle"]
# 将特定属性隐藏到hidden面板
for socket_name in hidden_sockets:
if socket_name in layer_node.inputs:
layer_node.inputs[socket_name].hide = True
for socket_name in layer_node.outputs.keys():
if socket_name != entry["kind"].capitalize():
layer_node.outputs[socket_name].hide = True
self.report({"INFO"}, f"Successfully created node: {entry['name']}")
return {"FINISHED"}
class RelocatePath(bpy.types.Operator):
"""Locate and update the missing layer folder, and update all related nodes"""
bl_idname = "bioxel.find_layer_folder"
bl_label = "Relocate"
bl_options = {"REGISTER", "UNDO"}
cache_id: bpy.props.StringProperty(options={"HIDDEN"}) # type: ignore
use_relative: bpy.props.BoolProperty(
name="Relative Path",
description="Store the path relative to the current Blender file",
default=True,
) # type: ignore
directory: bpy.props.StringProperty(
name="Layer Folder",
description="Select the correct folder for this layer",
subtype="DIR_PATH",
) # type: ignore
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {"RUNNING_MODAL"}
def execute(self, context):
if not self.cache_id:
self.report({"ERROR"}, "Missing cache id")
return {"CANCELLED"}
if not self.directory:
self.report({"ERROR"}, "Please select a folder")
return {"CANCELLED"}
caches = get_layer_caches()
entry = next((c for c in caches if str(c.get("id", "")) == self.cache_id), None)
if not entry:
self.report({"ERROR"}, "Layer not found")
return {"CANCELLED"}
old_path = entry["path"]
new_path = (
bpy.path.relpath(self.directory)
if self.use_relative and bpy.data.filepath
else bpy.path.abspath(self.directory)
)
entry["path"] = new_path
set_layer_caches(caches)
refresh_bioxel_panels(context)
# 更新所有 node group 中 O Layer 节点的 Path
for node_group in bpy.data.node_groups:
for node in get_layer_nodes(node_group):
node_path = getattr(node.inputs.get("Path"), "default_value")
if node_path and node_path == old_path:
node.inputs["Path"].default_value = new_path
self.report({"INFO"}, f"Cache path updated: {new_path}")
return {"FINISHED"}
class SaveLayer(bpy.types.Operator):
"""Copy the entire layer folder (named by id) to a target folder and update its path in the cache"""
bl_idname = "bioxel.cache_layer_to_folder"
bl_label = "Save Layer"
bl_options = {"REGISTER", "UNDO"}
cache_id: bpy.props.StringProperty(options={"HIDDEN"}) # type: ignore
use_relative: bpy.props.BoolProperty(
name="Relative Path",
description="Store the path relative to the current Blender file",
default=True,
) # type: ignore
directory: bpy.props.StringProperty(
name="Target Folder",
description="Select a folder to cache the layer files",
subtype="DIR_PATH",
) # type: ignore
def invoke(self, context, event):
# Default to Blender file directory if possible
blend_dir = Path(bpy.data.filepath).parent if bpy.data.filepath else Path.cwd()
context.window_manager.fileselect_add(self)
if not self.directory:
self.directory = str(blend_dir)
return {"RUNNING_MODAL"}
def execute(self, context):
if not self.cache_id:
self.report({"ERROR"}, "Missing cache id")
return {"CANCELLED"}
if not self.directory:
self.report({"ERROR"}, "Please select a target folder")
return {"CANCELLED"}
caches = get_layer_caches()
entry = next((c for c in caches if str(c.get("id")) == self.cache_id), None)
cache_id = entry.get("id")
if not entry:
self.report({"ERROR"}, "Layer not found")
return {"CANCELLED"}
old_path = entry["path"]
src_path = bpy.path.abspath(old_path)
if not src_path or not Path(src_path).is_dir():
self.report({"ERROR"}, "Source folder not found")
return {"CANCELLED"}
src_dir = Path(src_path).resolve()
dst_root = Path(self.directory)
# copy as a subfolder named by id
dst_dir = (dst_root / src_dir.name).resolve()
try:
if src_dir != dst_dir:
if dst_dir.exists():
shutil.rmtree(dst_dir)
shutil.copytree(src_dir, dst_dir)
# Save as relative path if requested and possible
new_path = (
bpy.path.relpath(str(dst_dir))
if self.use_relative and bpy.data.filepath
else bpy.path.abspath(str(dst_dir))
)
entry["path"] = new_path
set_layer_caches(caches)
refresh_bioxel_panels(context)
# 遍历所有 node group
for node_group in bpy.data.node_groups:
for node in get_layer_nodes(node_group):
if cache_id == getattr(node.inputs.get("ID"), "default_value"):
node.inputs["Path"].default_value = new_path
self.report({"INFO"}, f"Layer cached to {dst_dir}")
return {"FINISHED"}
except Exception as e:
self.report({"ERROR"}, f"Failed to cache layer: {e}")
return {"CANCELLED"}
class SelectAndFocusNode(bpy.types.Operator):
"""Select and focus the specified node in the current node tree"""
bl_idname = "bioxel.select_and_focus_node"
bl_label = "Select and Focus Node"
node_name: bpy.props.StringProperty() # type: ignore
def execute(self, context):
node_group = get_main_node_group(context)
if not node_group:
self.report({"ERROR"}, "No node group found.")
return {"CANCELLED"}
node = node_group.nodes.get(self.node_name)
if not node:
self.report({"ERROR"}, f"Node '{self.node_name}' not found.")
return {"CANCELLED"}
# Deselect all, select and focus this node
for n in node_group.nodes:
n.select = False
node.select = True
node_group.nodes.active = node
bpy.ops.node.view_selected()
return {"FINISHED"}