- 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.
315 lines
11 KiB
Python
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"}
|