From 9e885865f766eeb477583c60347e5cb2aa506923 Mon Sep 17 00:00:00 2001 From: Ma Nan Date: Wed, 14 Aug 2024 14:16:40 +0800 Subject: [PATCH 1/3] feat: layer list system --- .gitignore | 2 + bioxelnodes/__init__.py | 9 +- .../assets/Nodes/BioxelNodes_4.2.blend | 4 +- bioxelnodes/bioxel/layer.py | 22 +- bioxelnodes/bioxel/parse.py | 84 ++- bioxelnodes/bioxelutils/container.py | 97 ++-- bioxelnodes/bioxelutils/layer.py | 140 ++--- bioxelnodes/bioxelutils/node.py | 26 - bioxelnodes/bioxelutils/utils.py | 96 ++++ bioxelnodes/blender_manifest.toml | 5 +- bioxelnodes/customnodes/nodes.py | 12 +- bioxelnodes/menus.py | 336 ++++++++---- bioxelnodes/nodes.py | 191 +++---- bioxelnodes/operators/container.py | 187 +++++-- bioxelnodes/operators/io.py | 191 +++++-- bioxelnodes/operators/layer.py | 512 ++++++++++++++---- bioxelnodes/operators/misc.py | 156 +++--- bioxelnodes/operators/utils.py | 46 +- bioxelnodes/props.py | 37 ++ build.py | 3 +- pyproject.toml | 2 +- 21 files changed, 1392 insertions(+), 766 deletions(-) delete mode 100644 bioxelnodes/bioxelutils/node.py create mode 100644 bioxelnodes/bioxelutils/utils.py diff --git a/.gitignore b/.gitignore index cde6464..d5ad8a9 100644 --- a/.gitignore +++ b/.gitignore @@ -133,4 +133,6 @@ blendcache_* .secrets +.vdb + !scipy_ndimage/*/** \ No newline at end of file diff --git a/bioxelnodes/__init__.py b/bioxelnodes/__init__.py index 6e3ec9f..1d34f90 100644 --- a/bioxelnodes/__init__.py +++ b/bioxelnodes/__init__.py @@ -1,5 +1,6 @@ import bpy +from .props import BIOXELNODES_LayerListUL from . import auto_load from . import menus @@ -9,11 +10,17 @@ def register(): auto_load.register() - bpy.types.WindowManager.bioxelnodes_progress_factor = bpy.props.FloatProperty() + bpy.types.WindowManager.bioxelnodes_progress_factor = bpy.props.FloatProperty( + default=1.0) bpy.types.WindowManager.bioxelnodes_progress_text = bpy.props.StringProperty() + bpy.types.WindowManager.bioxelnodes_layer_list_UL = bpy.props.PointerProperty( + type=BIOXELNODES_LayerListUL) menus.add() def unregister(): menus.remove() + del bpy.types.WindowManager.bioxelnodes_progress_factor + del bpy.types.WindowManager.bioxelnodes_progress_text + del bpy.types.WindowManager.bioxelnodes_layer_list_UL auto_load.unregister() diff --git a/bioxelnodes/assets/Nodes/BioxelNodes_4.2.blend b/bioxelnodes/assets/Nodes/BioxelNodes_4.2.blend index 595e09b..4442571 100644 --- a/bioxelnodes/assets/Nodes/BioxelNodes_4.2.blend +++ b/bioxelnodes/assets/Nodes/BioxelNodes_4.2.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:87831f56b9d23b8ee3eccfea02c27e8ff305723a079e2c115a7b059fb5e1cb24 -size 6803344 +oid sha256:2de3498292c416d2ddc48f088c380330276bd75085f4261e8857c77160a3fa9b +size 7489655 diff --git a/bioxelnodes/bioxel/layer.py b/bioxelnodes/bioxel/layer.py index 0b46137..20365b1 100644 --- a/bioxelnodes/bioxel/layer.py +++ b/bioxelnodes/bioxel/layer.py @@ -7,7 +7,6 @@ # 3rd-party import transforms3d - # TODO: turn to dataclasses @@ -105,13 +104,13 @@ def resize(self, shape: tuple, progress_callback=None): data = self.data order = 0 if self.dtype == bool else 1 - # TXYZC > TXYZ - if self.kind in ['label', 'scalar']: - data = np.amax(data, -1) + # # TXYZC > TXYZ + # if self.kind in ['label', 'scalar']: + # data = np.amax(data, -1) # if self.kind in ['scalar']: # dtype = data.dtype - # data = data.astype(np.float32) + # data = data.astype(np.float32) data_frames = () for f in range(self.frame_count): @@ -125,8 +124,10 @@ def resize(self, shape: tuple, progress_callback=None): factors = np.divide(self.shape, shape) zoom_factors = [1 / f for f in factors] - frame = ndi.zoom(data[f, :, :, :], - zoom_factors, + frame = ndi.zoom(data[f, :, :, :, :], + zoom_factors+[1.0], + mode="nearest", + grid_mode=False, order=order) data_frames += (frame,) @@ -137,7 +138,10 @@ def resize(self, shape: tuple, progress_callback=None): # data = data.astype(dtype) # TXYZ > TXYZC - if self.kind in ['label', 'scalar']: - data = np.expand_dims(data, axis=-1) # expend channel + # if self.kind in ['label', 'scalar']: + # data = np.expand_dims(data, axis=-1) # expend channel self.data = data + + mat_scale = transforms3d.zooms.zfdir2aff(factors[0]) + self.affine = np.dot(self.affine, mat_scale) diff --git a/bioxelnodes/bioxel/parse.py b/bioxelnodes/bioxel/parse.py index 3b9f202..f2b258f 100644 --- a/bioxelnodes/bioxel/parse.py +++ b/bioxelnodes/bioxel/parse.py @@ -42,7 +42,8 @@ SEQUENCE_EXTS = ['.bmp', '.BMP', '.jpg', '.JPG', '.jpeg', '.JPEG', '.tif', '.TIF', '.tiff', '.TIFF', - '.png', '.PNG'] + '.png', '.PNG', + '.mrc'] def get_ext(filepath: Path) -> str: @@ -64,18 +65,40 @@ def get_ext(filepath: Path) -> str: return filepath.suffix -def get_file_name(filepath: Path): +def get_filename(filepath: Path): ext = get_ext(filepath) - return filepath.name.removesuffix(ext).replace(" ", "-") + return filepath.name.removesuffix(ext) -def get_file_number(filepath: Path) -> str: - name = get_file_name(filepath) +def get_filename_parts(filepath: Path) -> str: + def has_digits(s): + return any(char.isdigit() for char in s) + + name = get_filename(filepath) + parts = name.replace(".", " ").replace("_", " ").split(" ") + skip_prefixs = ["CH", "ch", "channel"] + number_part = None + number_part_i = None + + for i, part in enumerate(parts[::-1]): + if has_digits(part): + if not any([part.startswith(prefix) for prefix in skip_prefixs]): + number_part = part + number_part_i = len(parts)-i + break + + if number_part is None: + return name, "", "" + + prefix_parts = parts[:number_part_i-1] + prefix_parts_count = sum([len(part)+1 for part in prefix_parts]) + digits = "" + suffix = "" # Iterate through the characters in reverse order started = False - for char in name[::-1]: + for char in number_part[::-1]: if char.isdigit(): started = True # If the character is a digit, add it to the digits string @@ -84,20 +107,33 @@ def get_file_number(filepath: Path) -> str: if started: # If a non-digit character is encountered, stop the loop break + else: + suffix += char + + digits = digits[::-1] + + prefix_parts_count += len(number_part) - \ + len(digits) - len(suffix) # Reverse the digits string to get the correct order - return digits[::-1] + prefix = name[:prefix_parts_count] + suffix = name[prefix_parts_count+len(digits):] + return prefix, digits, suffix -def get_sequence_name(filepath: Path) -> str: - name = get_file_name(filepath) - number = get_file_number(filepath) - return name.removesuffix(number) +def get_file_no_digits_name(filepath: Path) -> str: + prefix, digits, suffix = get_filename_parts(filepath) + prefix = remove_end_str(prefix, "_") + prefix = remove_end_str(prefix, ".") + prefix = remove_end_str(prefix, "-") + prefix = remove_end_str(prefix, " ") + return prefix + suffix -def get_sequence_index(filepath: Path) -> int: - number = get_file_number(filepath) - return int(number) if number != "" else 0 + +def get_file_index(filepath: Path) -> int: + prefix, digits, suffix = get_filename_parts(filepath) + return int(digits) if digits != "" else 0 def collect_sequence(filepath: Path): @@ -105,8 +141,8 @@ def collect_sequence(filepath: Path): for f in filepath.parent.iterdir(): if f.is_file() \ and get_ext(filepath) == get_ext(f) \ - and get_sequence_name(filepath) == get_sequence_name(f): - index = get_sequence_index(f) + and get_file_no_digits_name(filepath) == get_file_no_digits_name(f): + index = get_file_index(f) file_dict[index] = f # reomve isolated seq file @@ -124,6 +160,12 @@ def collect_sequence(filepath: Path): return sequence +def remove_end_str(string: str, end: str): + while string.endswith(end) and len(string) > 0: + string = string.removesuffix(end) + return string + + def parse_volumetric_data(data_file: str, series_id="", progress_callback=None) -> Layer: """Parse any volumetric data to numpy with shap (T,X,Y,Z,C) @@ -139,7 +181,7 @@ def parse_volumetric_data(data_file: str, series_id="", progress_callback=None) ext = get_ext(data_path) if progress_callback: - progress_callback(0, "Reading the Data...") + progress_callback(0.0, "Reading the Data...") is_sequence = False if ext in SEQUENCE_EXTS: @@ -181,7 +223,7 @@ def parse_volumetric_data(data_file: str, series_id="", progress_callback=None) elif mrc.is_volume_stack(): data = np.expand_dims(data, axis=-1) # expend channel - name = get_file_name(data_path) + name = get_file_no_digits_name(data_path) spacing = (mrc.voxel_size.x, mrc.voxel_size.y, mrc.voxel_size.z) @@ -245,7 +287,7 @@ def parse_volumetric_data(data_file: str, series_id="", progress_callback=None) except: ... - name = get_file_name(data_path) + name = get_file_no_digits_name(data_path) except: ... @@ -297,10 +339,10 @@ def get_meta(key): elif ext in SEQUENCE_EXTS and is_sequence: itk_image = sitk.ReadImage(sequence) - name = get_sequence_name(data_path) + name = get_file_no_digits_name(data_path) else: itk_image = sitk.ReadImage(data_path) - name = get_file_name(data_path) + name = get_filename(data_path) # for key in itk_image.GetMetaDataKeys(): # print(f"{key},{itk_image.GetMetaData(key)}") diff --git a/bioxelnodes/bioxelutils/container.py b/bioxelnodes/bioxelutils/container.py index 1f1dd8d..b953fb7 100644 --- a/bioxelnodes/bioxelutils/container.py +++ b/bioxelnodes/bioxelutils/container.py @@ -5,9 +5,12 @@ from mathutils import Matrix, Vector -from .layer import Layer, get_container_layer_objs, layer_to_obj, obj_to_layer +from .layer import Layer, layer_to_obj, obj_to_layer from ..nodes import custom_nodes -from .node import get_nodes_by_type, move_node_to_node +from .utils import (get_container_layer_objs, + get_layer_prop_value, + get_nodes_by_type, + move_node_to_node) NODE_TYPE = { @@ -66,59 +69,39 @@ def calc_bbox_verts(origin: tuple, size: tuple): return bbox_verts -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 obj_to_container(container_obj: bpy.types.Object): + layer_objs = get_container_layer_objs(container_obj) + layers = [obj_to_layer(obj) for obj in layer_objs] + container = Container(name=container_obj.name, + layers=layers) + return container def add_layers(layers: list[Layer], container_obj: bpy.types.Object, cache_dir: str): - container_node_group = container_obj.modifiers[0].node_group + node_group = container_obj.modifiers[0].node_group + output_node = get_nodes_by_type(node_group, + 'NodeGroupOutput')[0] for i, layer in enumerate(layers): layer_obj = layer_to_obj(layer, container_obj, cache_dir) - mask_node = custom_nodes.add_node(container_node_group, - NODE_TYPE[layer.kind]) - mask_node.label = layer_obj.name - mask_node.inputs[0].default_value = layer_obj - - # Connect to output if no output linked - output_node = get_nodes_by_type(container_node_group, - 'NodeGroupOutput')[0] + fetch_node = custom_nodes.add_node(node_group, + "BioxelNodes_FetchLayer") + fetch_node.label = get_layer_prop_value(layer_obj, "name") + fetch_node.inputs[0].default_value = layer_obj + if len(output_node.inputs[0].links) == 0: - container_node_group.links.new(mask_node.outputs[0], - output_node.inputs[0]) - move_node_to_node(mask_node, output_node, (-300, 0)) + node_group.links.new(fetch_node.outputs[0], + output_node.inputs[0]) + move_node_to_node(fetch_node, output_node, (-600, 0)) else: - move_node_to_node(mask_node, output_node, (0, -100 * (i+1))) + move_node_to_node(fetch_node, output_node, (0, -100 * (i+1))) return container_obj -def obj_to_container(container_obj: bpy.types.Object): - layer_objs = get_container_layer_objs(container_obj) - layers = [obj_to_layer(obj) for obj in layer_objs] - container = Container(name=container_obj.name, - layers=layers) - return container - - def container_to_obj(container: Container, scene_scale: float, cache_dir: str): @@ -153,28 +136,16 @@ def container_to_obj(container: Container, container_obj.show_in_front = True container_obj['bioxel_container'] = True - bpy.ops.node.new_geometry_nodes_modifier() - container_node_group = container_obj.modifiers[0].node_group - input_node = get_nodes_by_type(container_node_group, - 'NodeGroupInput')[0] - container_node_group.links.remove( - input_node.outputs[0].links[0]) - - for i, layer in enumerate(container.layers): - layer_obj = layer_to_obj(layer, container_obj, cache_dir) - mask_node = custom_nodes.add_node(container_node_group, - NODE_TYPE[layer.kind]) - mask_node.label = layer_obj.name - mask_node.inputs[0].default_value = layer_obj - - # Connect to output if no output linked - output_node = get_nodes_by_type(container_node_group, - 'NodeGroupOutput')[0] - if len(output_node.inputs[0].links) == 0: - container_node_group.links.new(mask_node.outputs[0], - output_node.inputs[0]) - move_node_to_node(mask_node, output_node, (-300, 0)) - else: - move_node_to_node(mask_node, output_node, (0, -100 * (i+1))) + modifier = container_obj.modifiers.new("GeometryNodes", 'NODES') + node_group = bpy.data.node_groups.new('GeometryNodes', 'GeometryNodeTree') + node_group.interface.new_socket(name="Component", + in_out="OUTPUT", + socket_type="NodeSocketGeometry") + modifier.node_group = node_group + node_group.nodes.new("NodeGroupOutput") + + container_obj = add_layers(container.layers, + container_obj=container_obj, + cache_dir=cache_dir) return container_obj diff --git a/bioxelnodes/bioxelutils/layer.py b/bioxelnodes/bioxelutils/layer.py index 0936834..45f9207 100644 --- a/bioxelnodes/bioxelutils/layer.py +++ b/bioxelnodes/bioxelutils/layer.py @@ -9,33 +9,7 @@ from ..nodes import custom_nodes from ..bioxel.layer import Layer -from .node import get_nodes_by_type, move_node_between_nodes - - -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.context.scene.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.context.scene.objects: - if get_layer_obj(obj): - layer_objs.append(obj) - - return layer_objs +from .utils import get_layer_prop_value, move_node_between_nodes def obj_to_layer(layer_obj: bpy.types.Object): @@ -63,11 +37,16 @@ def obj_to_layer(layer_obj: bpy.types.Object): grid.copyToArray(data) data = np.expand_dims(data, axis=0) # expend frame - name = metadata["layer_name"] - kind = metadata["layer_kind"] + name = get_layer_prop_value(layer_obj, "name") \ + or metadata["layer_name"] + kind = get_layer_prop_value(layer_obj, "kind") \ + or metadata["layer_kind"] affine = metadata["layer_affine"] - dtype = metadata.get("data_dtype") or "float32" - offset = metadata.get("data_offset") or 0 + dtype = get_layer_prop_value(layer_obj, "dtype") \ + or metadata.get("data_dtype") or "float32" + offset = get_layer_prop_value(layer_obj, "offset") \ + or metadata.get("data_offset") or 0 + data = data - np.full_like(data, offset) data = data.astype(dtype) @@ -110,6 +89,7 @@ def layer_to_obj(layer: Layer, "data_offset": offset } + layer_display_name = f"{container_obj.name}_{layer.name}" if layer.frame_count > 1: print(f"Saving the Cache of {layer.name}...") vdb_name = str(uuid4()) @@ -118,8 +98,14 @@ def layer_to_obj(layer: Layer, cache_filepaths = [] for f in range(layer.frame_count): - grid = vdb.FloatGrid() - grid.copyFromArray(data[f, :, :, :].copy().astype(np.float32)) + if layer.kind in ['label', 'scalar']: + grid = vdb.FloatGrid() + grid.copyFromArray(data[f, :, :, :].copy().astype(np.float32)) + else: + # color + grid = vdb.Vec3SGrid() + grid.copyFromArray( + data[f, :, :, :, :].copy().astype(np.float32)) grid.transform = vdb.createLinearTransform( layer.affine.transpose()) grid.metadata = metadata @@ -130,17 +116,14 @@ def layer_to_obj(layer: Layer, vdb.write(str(cache_filepath), grids=[grid]) cache_filepaths.append(cache_filepath) - print(f"Loading the Cache of {layer.name}...") - files = [{"name": str(cache_filepath.name), "name": str(cache_filepath.name)} - for cache_filepath in cache_filepaths] - - bpy.ops.object.volume_import(filepath=str(cache_filepaths[0]), - directory=str(cache_filepaths[0].parent), - files=files, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1)) - else: - grid = vdb.FloatGrid() - grid.copyFromArray(data[0, :, :, :].copy().astype(np.float32)) + if layer.kind in ['label', 'scalar']: + grid = vdb.FloatGrid() + grid.copyFromArray(data[0, :, :, :].copy().astype(np.float32)) + else: + # color + grid = vdb.Vec3SGrid() + grid.copyFromArray(data[0, :, :, :, :].copy().astype(np.float32)) grid.transform = vdb.createLinearTransform( layer.affine.transpose()) grid.metadata = metadata @@ -151,52 +134,33 @@ def layer_to_obj(layer: Layer, vdb.write(str(cache_filepath), grids=[grid]) cache_filepaths = [cache_filepath] - print(f"Loading the Cache of {layer.name}...") - bpy.ops.object.volume_import(filepath=str(cache_filepaths[0]), - align='WORLD', location=(0, 0, 0), scale=(1, 1, 1)) - - layer_obj = bpy.context.active_object - layer_obj.data.sequence_mode = 'REPEAT' - - # Set props to VDB object - layer_obj.name = f"{container_obj.name}_{layer.name}" - layer_obj.data.name = f"{container_obj.name}_{layer.name}" - - layer_obj.lock_location[0] = True - layer_obj.lock_location[1] = True - layer_obj.lock_location[2] = True - layer_obj.lock_rotation[0] = True - layer_obj.lock_rotation[1] = True - layer_obj.lock_rotation[2] = True - layer_obj.lock_scale[0] = True - layer_obj.lock_scale[1] = True - layer_obj.lock_scale[2] = True - - layer_obj.visible_camera = False - layer_obj.visible_diffuse = False - layer_obj.visible_glossy = False - layer_obj.visible_transmission = False - layer_obj.visible_volume_scatter = False - layer_obj.visible_shadow = False - - layer_obj.hide_select = True - layer_obj.hide_render = True - layer_obj.hide_viewport = True + layer_data = bpy.data.volumes.new(layer_display_name) + layer_data.sequence_mode = 'REPEAT' + layer_data.filepath = str(cache_filepaths[0]) - layer_obj['bioxel_layer'] = True - layer_obj.parent = container_obj + if layer.frame_count > 1: + layer_data.is_sequence = True + layer_data.frame_duration = layer.frame_count + else: + layer_data.is_sequence = False - for collection in layer_obj.users_collection: - collection.objects.unlink(layer_obj) + layer_obj = bpy.data.objects.new(layer_display_name, layer_data) - for collection in container_obj.users_collection: - collection.objects.link(layer_obj) + layer_obj['bioxel_layer'] = True print(f"Creating Node for {layer.name}...") - bpy.ops.node.new_geometry_nodes_modifier() - node_group = layer_obj.modifiers[0].node_group + modifier = layer_obj.modifiers.new("GeometryNodes", 'NODES') + node_group = bpy.data.node_groups.new('GeometryNodes', 'GeometryNodeTree') + node_group.interface.new_socket(name="Cache", + in_out="INPUT", + socket_type="NodeSocketGeometry") + node_group.interface.new_socket(name="Layer", + in_out="OUTPUT", + socket_type="NodeSocketGeometry") + modifier.node_group = node_group + layer_node = custom_nodes.add_node(node_group, - "BioxelNodes__Layer") + "BioxelNodes__Layer") layer_node.inputs['name'].default_value = layer.name layer_node.inputs['shape'].default_value = layer.shape @@ -208,7 +172,7 @@ def layer_to_obj(layer: Layer, layer_node.inputs[affine_key].default_value = layer.affine[j, i] layer_node.inputs['id'].default_value = random.randint(-200000000, - 200000000) + 200000000) layer_node.inputs['bioxel_size'].default_value = layer.bioxel_size[0] layer_node.inputs['dtype'].default_value = layer.dtype.str layer_node.inputs['dtype_num'].default_value = layer.dtype.num @@ -216,10 +180,8 @@ def layer_to_obj(layer: Layer, layer_node.inputs['min'].default_value = layer.min layer_node.inputs['max'].default_value = layer.max - input_node = get_nodes_by_type(node_group, - 'NodeGroupInput')[0] - output_node = get_nodes_by_type(node_group, - 'NodeGroupOutput')[0] + input_node = node_group.nodes.new("NodeGroupInput") + output_node = node_group.nodes.new("NodeGroupOutput") node_group.links.new(input_node.outputs[0], layer_node.inputs[0]) @@ -229,4 +191,6 @@ def layer_to_obj(layer: Layer, move_node_between_nodes( layer_node, [input_node, output_node]) + layer_obj.parent = container_obj + return layer_obj diff --git a/bioxelnodes/bioxelutils/node.py b/bioxelnodes/bioxelutils/node.py deleted file mode 100644 index bbea981..0000000 --- a/bioxelnodes/bioxelutils/node.py +++ /dev/null @@ -1,26 +0,0 @@ -def move_node_to_node(node, target_node, offset=(0, 0)): - node.location.x = target_node.location.x + offset[0] - node.location.y = target_node.location.y + offset[1] - - -def move_node_between_nodes(node, target_nodes, offset=(0, 0)): - xs = [] - ys = [] - for target_node in target_nodes: - xs.append(target_node.location.x) - ys.append(target_node.location.y) - - node.location.x = sum(xs) / len(xs) + offset[0] - node.location.y = sum(ys) / len(ys) + offset[1] - - -def get_node_type(node): - node_type = type(node).__name__ - if node_type == "GeometryNodeGroup": - node_type = node.node_tree.name - - return node_type - - -def get_nodes_by_type(node_group, type_name: str): - return [node for node in node_group.nodes if get_node_type(node) == type_name] diff --git a/bioxelnodes/bioxelutils/utils.py b/bioxelnodes/bioxelutils/utils.py new file mode 100644 index 0000000..55823b7 --- /dev/null +++ b/bioxelnodes/bioxelutils/utils.py @@ -0,0 +1,96 @@ +import bpy + + +def move_node_to_node(node, target_node, offset=(0, 0)): + node.location.x = target_node.location.x + offset[0] + node.location.y = target_node.location.y + offset[1] + + +def move_node_between_nodes(node, target_nodes, offset=(0, 0)): + xs = [] + ys = [] + for target_node in target_nodes: + xs.append(target_node.location.x) + ys.append(target_node.location.y) + + node.location.x = sum(xs) / len(xs) + offset[0] + node.location.y = sum(ys) / len(ys) + offset[1] + + +def get_node_type(node): + node_type = type(node).__name__ + if node_type == "GeometryNodeGroup": + node_type = node.node_tree.name + + return node_type + + +def get_nodes_by_type(node_group, type_name: str): + return [node for node in node_group.nodes if get_node_type(node) == type_name] + + +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: str): + node_group = layer_obj.modifiers[0].node_group + layer_node = get_nodes_by_type(node_group, "BioxelNodes__Layer")[0] + value = layer_node.inputs[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) + if type(value).__name__ == "float": + value = float(value) + return round(value, 2) \ + if prop in ["bioxel_size"] else value + + +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 diff --git a/bioxelnodes/blender_manifest.toml b/bioxelnodes/blender_manifest.toml index 2477913..2e2f504 100644 --- a/bioxelnodes/blender_manifest.toml +++ b/bioxelnodes/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "bioxelnodes" -version = "0.3.3" +version = "0.4.0" name = "Bioxel Nodes" tagline = "For scientific volumetric data visualization in Blender" maintainer = "Ma Nan " @@ -30,9 +30,8 @@ wheels = [ "./wheels/tifffile-2024.7.24-py3-none-any.whl", "./wheels/pyometiff-1.0.0-py3-none-any.whl", "./wheels/mrcfile-1.5.1-py2.py3-none-any.whl", - "./wheels/transforms3d-0.4.2-py3-none-any.whl", + "./wheels/transforms3d-0.4.2-py3-none-any.whl" ] [permissions] files = "Import/export volume data from/to disk" - diff --git a/bioxelnodes/customnodes/nodes.py b/bioxelnodes/customnodes/nodes.py index 8f7652b..ee8cd6d 100644 --- a/bioxelnodes/customnodes/nodes.py +++ b/bioxelnodes/customnodes/nodes.py @@ -25,7 +25,7 @@ class AddCustomNode(): node_description: bpy.props.StringProperty( name="node_description", description="", - default="Add custom node group.", + default="", subtype="NONE" ) # type: ignore @@ -70,20 +70,10 @@ def get_node_tree(self, node_type, node_link): node_tree = bpy.data.node_groups.get(node_type) if node_tree: - # self.recursive_append_material(node_tree) return node_tree else: raise RuntimeError('No custom node found') - def recursive_append_material(self, node_tree): - for child in node_tree.nodes: - material_socket = child.inputs.get('Material') - if material_socket: - print(material_socket.default_value) - try: - self.recursive_append_material(child.node_tree) - except: - ... def add_node(self, node_group): # Deselect all nodes first diff --git a/bioxelnodes/menus.py b/bioxelnodes/menus.py index f289f9e..f6e65e8 100644 --- a/bioxelnodes/menus.py +++ b/bioxelnodes/menus.py @@ -1,21 +1,22 @@ +from pathlib import Path import bpy -from .bioxelutils.container import get_container_objs_from_selection -from .operators.layer import (CombineLabels, SignScalar, - FillByLabel, FillByThreshold, FillByRange) -from .operators.container import (SaveContainer, LoadContainer, +from .operators.utils import get_layer_item_label +from .bioxelutils.utils import (get_container_obj, + get_container_objs_from_selection, + get_container_layer_objs, + get_layer_prop_value) +from .operators.layer import (FetchLayerMenu, FetchLayer, + RemoveLayer, RemoveMissingLayers, RenameLayer, ResampleScalar, SaveLayerCache, + SignScalar, CombineLabels, + FillByLabel, FillByThreshold, FillByRange, get_selected_objs_in_node_tree) +from .operators.container import (SaveAllLayerCaches, SaveContainer, LoadContainer, AddPieCutter, AddPlaneCutter, AddCylinderCutter, AddCubeCutter, AddSphereCutter, PickBboxWire, PickMesh, PickVolume) -from .operators.io import (ExportVolumetricData, - ImportAsLabelLayer, ImportAsScalarLayer) +from .operators.io import (ImportAsLabel, ImportAsScalar, ImportAsColor) from .operators.misc import (CleanAllCaches, - ReLinkNodes, SaveCaches, SaveStagedData) - - -def container_is_selected(): - container_objs = get_container_objs_from_selection() - return len(container_objs) > 0 + ReLinkNodes, RenderSettingPreset, SaveStagedData) class PickFromContainerMenu(bpy.types.Menu): @@ -29,22 +30,9 @@ def draw(self, context): layout.operator(PickBboxWire.bl_idname) -class ModifyLayerMenu(bpy.types.Menu): - bl_idname = "BIOXELNODES_MT_MODIFY_LAYERS" - bl_label = "Modify Layer" - - def draw(self, context): - layout = self.layout - layout.operator(SignScalar.bl_idname) - layout.operator(FillByThreshold.bl_idname) - layout.operator(FillByRange.bl_idname) - layout.operator(FillByLabel.bl_idname) - layout.operator(CombineLabels.bl_idname) - - class AddCutterMenu(bpy.types.Menu): bl_idname = "BIOXELNODES_MT_CUTTERS" - bl_label = "Add a Cutter to Container" + bl_label = "Add a Object Cutter" def draw(self, context): layout = self.layout @@ -56,142 +44,254 @@ def draw(self, context): class ImportLayerMenu(bpy.types.Menu): - bl_idname = "BIOXELNODES_MT_LAYERS" - bl_label = "Import Volumetric Data" + bl_idname = "BIOXELNODES_MT_IMPORTLAYER" + bl_label = "Import Volumetric Data (Init)" + bl_icon = "FILE_NEW" def draw(self, context): layout = self.layout - layout.operator(ImportAsScalarLayer.bl_idname, text="as Scalar") - layout.operator(ImportAsLabelLayer.bl_idname, text="as Label") + layout.operator(ImportAsScalar.bl_idname, + text="as Scalar") + layout.operator(ImportAsLabel.bl_idname, + text="as Label") + layout.operator(ImportAsColor.bl_idname, + text="as Color") -class BioxelNodesView3DMenu(bpy.types.Menu): - bl_idname = "BIOXELNODES_MT_VIEW3D" - bl_label = "Bioxel Nodes" +class AddLayerMenu(bpy.types.Menu): + bl_idname = "BIOXELNODES_MT_ADDLAYER" + bl_label = "Import Volumetric Data (Add to)" + bl_icon = "FILE_NEW" def draw(self, context): layout = self.layout - is_selected = container_is_selected() - layout.operator(ImportAsScalarLayer.bl_idname, - text=ImportAsScalarLayer.bl_label+" (Add to)" - if is_selected else ImportAsScalarLayer.bl_label+" (Init)") - layout.operator(ImportAsLabelLayer.bl_idname, - text=ImportAsLabelLayer.bl_label+" (Add to)" - if is_selected else ImportAsLabelLayer.bl_label+" (Init)") - layout.separator() - layout.operator(AddPlaneCutter.bl_idname) - layout.operator(AddCylinderCutter.bl_idname) - layout.operator(AddCubeCutter.bl_idname) - layout.operator(AddSphereCutter.bl_idname) - layout.operator(AddPieCutter.bl_idname) - layout.separator() - layout.operator(PickMesh.bl_idname) - layout.operator(PickVolume.bl_idname) - layout.operator(PickBboxWire.bl_idname) - layout.separator() - layout.operator(SaveCaches.bl_idname) + layout.operator(ImportAsScalar.bl_idname, + text="as Scalar") + layout.operator(ImportAsLabel.bl_idname, + text="as Label") + layout.operator(ImportAsColor.bl_idname, + text="as Color") -class BioxelNodesOutlinerMenu(bpy.types.Menu): - bl_idname = "BIOXELNODES_MT_OUTLINER" - bl_label = "Bioxel Nodes" +class ModifyLayerMenu(bpy.types.Menu): + bl_idname = "BIOXELNODES_MT_MODIFYLAYER" + bl_label = "Modify Layer" + bl_icon = "FILE_NEW" def draw(self, context): + layer_objs = get_selected_objs_in_node_tree(context) + if len(layer_objs) > 0: + active_obj_name = layer_objs[0].name + else: + active_obj_name = "" + layout = self.layout - is_selected = container_is_selected() - layout.operator(ImportAsScalarLayer.bl_idname, - text=ImportAsScalarLayer.bl_label+" (Add to)" - if is_selected else ImportAsScalarLayer.bl_label+" (Init)") - layout.operator(ImportAsLabelLayer.bl_idname, - text=ImportAsLabelLayer.bl_label+" (Add to)" - if is_selected else ImportAsLabelLayer.bl_label+" (Init)") - layout.separator() - layout.operator(AddPlaneCutter.bl_idname) - layout.operator(AddCylinderCutter.bl_idname) - layout.operator(AddCubeCutter.bl_idname) - layout.operator(AddSphereCutter.bl_idname) - layout.operator(AddPieCutter.bl_idname) - layout.separator() - layout.operator(PickMesh.bl_idname) - layout.operator(PickVolume.bl_idname) - layout.operator(PickBboxWire.bl_idname) - layout.separator() - layout.operator(SaveCaches.bl_idname) - layout.separator() - layout.operator(SignScalar.bl_idname) - layout.operator(FillByThreshold.bl_idname) - layout.operator(FillByRange.bl_idname) - layout.operator(FillByLabel.bl_idname) - layout.operator(CombineLabels.bl_idname) + op = layout.operator(ResampleScalar.bl_idname, + icon=ResampleScalar.bl_icon) + op.layer_obj_name = active_obj_name + op = layout.operator(SignScalar.bl_idname, + icon=SignScalar.bl_icon) + op.layer_obj_name = active_obj_name + op = layout.operator(FillByThreshold.bl_idname, + icon=FillByThreshold.bl_icon) + op.layer_obj_name = active_obj_name + op = layout.operator(FillByRange.bl_idname, + icon=FillByRange.bl_icon) + op.layer_obj_name = active_obj_name + op = layout.operator(FillByLabel.bl_idname, + icon=FillByLabel.bl_icon) + op.layer_obj_name = active_obj_name + op = layout.operator(CombineLabels.bl_idname, + icon=CombineLabels.bl_icon) + op.layer_obj_name = active_obj_name + + +class RenderSettingMenu(bpy.types.Menu): + bl_idname = "BIOXELNODES_MT_RENDER" + bl_label = "Render Setting Presets" + def draw(self, context): + layout = self.layout + for k, v in RenderSettingPreset.PRESETS.items(): + op = layout.operator(RenderSettingPreset.bl_idname, + text=v) + op.preset = k -def TOPBAR_FILE_IMPORT(self, context): - layout = self.layout - is_selected = container_is_selected() - layout.separator() - layout.menu(ImportLayerMenu.bl_idname, text="Volumetric Data as Bioxel (Add to)" - if is_selected else "Volumetric Data as Bioxel (Init)") +class BioxelNodesTopbarMenu(bpy.types.Menu): + bl_idname = "BIOXELNODES_MT_TOPBAR" + bl_label = "Bioxel Nodes" + def draw(self, context): + layout = self.layout -def TOPBAR_FILE_EXPORT(self, context): - layout = self.layout - layout.separator() - layout.operator(ExportVolumetricData.bl_idname, - text="Bioxel Layer (.vdb)") + layout.menu(ImportLayerMenu.bl_idname, + icon=ImportLayerMenu.bl_icon) + layout.operator(LoadContainer.bl_idname) + layout.separator() + layout.operator(SaveStagedData.bl_idname) + layout.operator(ReLinkNodes.bl_idname) + layout.operator(CleanAllCaches.bl_idname) -def VIEW3D_OBJECT(self, context): - layout = self.layout - layout.separator() - layout.menu(BioxelNodesView3DMenu.bl_idname, icon="FILE_VOLUME") + layout.separator() + layout.menu(RenderSettingMenu.bl_idname) -def OUTLINER_OBJECT(self, context): +def TOPBAR(self, context): layout = self.layout - layout.separator() - layout.menu(BioxelNodesOutlinerMenu.bl_idname, icon="FILE_VOLUME") + layout.menu(BioxelNodesTopbarMenu.bl_idname) -class BioxelNodesTopbarMenu(bpy.types.Menu): - bl_idname = "BIOXELNODES_MT_TOPBAR" +class BioxelNodesNodeMenu(bpy.types.Menu): + bl_idname = "BIOXELNODES_MT_NODE" bl_label = "Bioxel Nodes" + bl_icon = "FILE_VOLUME" def draw(self, context): + layer_objs = get_selected_objs_in_node_tree(context) + if len(layer_objs) > 0: + active_obj_name = layer_objs[0].name + else: + active_obj_name = "" + layout = self.layout - is_selected = container_is_selected() - layout.menu(ImportLayerMenu.bl_idname, text=ImportLayerMenu.bl_label+" (Add to)" - if is_selected else ImportLayerMenu.bl_label+" (Init)") + layout.menu(AddLayerMenu.bl_idname, + icon=AddLayerMenu.bl_icon) + + layout.operator(RemoveMissingLayers.bl_idname, + icon=RemoveMissingLayers.bl_icon) + layout.separator() - layout.operator(LoadContainer.bl_idname) layout.operator(SaveContainer.bl_idname) - layout.separator() layout.menu(AddCutterMenu.bl_idname) layout.menu(PickFromContainerMenu.bl_idname) + layout.operator(SaveAllLayerCaches.bl_idname, + icon=SaveAllLayerCaches.bl_icon) + layout.separator() - layout.operator(SaveStagedData.bl_idname) - layout.operator(ReLinkNodes.bl_idname) + layout.menu(FetchLayerMenu.bl_idname) + op = layout.operator(SaveLayerCache.bl_idname, + icon=SaveLayerCache.bl_icon) + op.layer_obj_name = active_obj_name + op = layout.operator(RenameLayer.bl_idname, + icon=RenameLayer.bl_icon) + op.layer_obj_name = active_obj_name + + op = layout.operator(RemoveLayer.bl_idname, + icon=RemoveLayer.bl_icon) + op.layer_obj_name = active_obj_name + layout.separator() - layout.operator(CleanAllCaches.bl_idname) + layout.menu(ModifyLayerMenu.bl_idname) -def TOPBAR(self, context): +def NODE(self, context): + container_obj = context.object + is_geo_nodes = context.area.ui_type == "GeometryNodeTree" + is_container = get_container_obj(container_obj) + + if not is_geo_nodes or not is_container: + return + layout = self.layout - layout.menu(BioxelNodesTopbarMenu.bl_idname) + layout.separator() + layout.menu(BioxelNodesNodeMenu.bl_idname) + + +def NODE_PT(self, context): + container_obj = context.object + is_geo_nodes = context.area.ui_type == "GeometryNodeTree" + is_container = get_container_obj(container_obj) + self.bl_label = "Group" + + if not is_geo_nodes or not is_container: + return + + if container_obj.modifiers[0].node_group != context.space_data.edit_tree: + return + + self.bl_label = "Bioxel Nodes" + + layer_list_UL = bpy.context.window_manager.bioxelnodes_layer_list_UL + layer_list = layer_list_UL.layer_list + layer_list_active = layer_list_UL.layer_list_active + layer_list.clear() + + for layer_obj in get_container_layer_objs(container_obj): + layer_item = layer_list.add() + layer_item.label = get_layer_item_label(context, layer_obj) + layer_item.obj_name = layer_obj.name + layer_item.info_text = "\n".join([f"{prop}: {get_layer_prop_value(layer_obj, prop)}" + for prop in ["kind", "bioxel_size", "shape", "min", "max", ]]) + + if len(layer_list) > 0 and layer_list_active != -1 and layer_list_active < len(layer_list): + active_obj_name = layer_list[layer_list_active].obj_name + else: + active_obj_name = "" + + layout = self.layout + layout.label(text="Layer List") + split = layout.row() + split.template_list(listtype_name="BIOXELNODES_UL_layer_list", + list_id="layer_list", + dataptr=layer_list_UL, + propname="layer_list", + active_dataptr=layer_list_UL, + active_propname="layer_list_active", + item_dyntip_propname="info_text", + rows=20) + + sidebar = split.column(align=True) + sidebar.menu(AddLayerMenu.bl_idname, + icon=AddLayerMenu.bl_icon, text="") + + sidebar.operator(RemoveMissingLayers.bl_idname, + icon=RemoveMissingLayers.bl_icon, text="") + + sidebar.separator() + op = sidebar.operator(FetchLayer.bl_idname, + icon=FetchLayer.bl_icon, text="") + op.layer_obj_name = active_obj_name + op = sidebar.operator(SaveLayerCache.bl_idname, + icon=SaveLayerCache.bl_icon, text="") + op.layer_obj_name = active_obj_name + op = sidebar.operator(RenameLayer.bl_idname, + icon=RenameLayer.bl_icon, text="") + op.layer_obj_name = active_obj_name + op = sidebar.operator(RemoveLayer.bl_idname, + icon=RemoveLayer.bl_icon, text="") + op.layer_obj_name = active_obj_name + + sidebar.separator() + op = sidebar.operator(ResampleScalar.bl_idname, + icon=ResampleScalar.bl_icon, text="") + op.layer_obj_name = active_obj_name + op = sidebar.operator(SignScalar.bl_idname, + icon=SignScalar.bl_icon, text="") + op.layer_obj_name = active_obj_name + op = sidebar.operator(FillByThreshold.bl_idname, + icon=FillByThreshold.bl_icon, text="") + op.layer_obj_name = active_obj_name + op = sidebar.operator(FillByRange.bl_idname, + icon=FillByRange.bl_icon, text="") + op.layer_obj_name = active_obj_name + op = sidebar.operator(FillByLabel.bl_idname, + icon=FillByLabel.bl_icon, text="") + op.layer_obj_name = active_obj_name + + sidebar.separator() + layout.separator() def add(): - bpy.types.TOPBAR_MT_file_import.append(TOPBAR_FILE_IMPORT) - # bpy.types.TOPBAR_MT_file_export.append(TOPBAR_FILE_EXPORT) - bpy.types.OUTLINER_MT_object.append(OUTLINER_OBJECT) - bpy.types.VIEW3D_MT_object_context_menu.append(VIEW3D_OBJECT) + bpy.types.NODE_PT_node_tree_properties.prepend(NODE_PT) bpy.types.TOPBAR_MT_editor_menus.append(TOPBAR) + bpy.types.NODE_MT_editor_menus.append(NODE) def remove(): - bpy.types.TOPBAR_MT_file_import.remove(TOPBAR_FILE_IMPORT) - # bpy.types.TOPBAR_MT_file_export.remove(TOPBAR_FILE_EXPORT) - bpy.types.OUTLINER_MT_object.remove(OUTLINER_OBJECT) - bpy.types.VIEW3D_MT_object_context_menu.remove(VIEW3D_OBJECT) + bpy.types.NODE_PT_node_tree_properties.remove(NODE_PT) bpy.types.TOPBAR_MT_editor_menus.remove(TOPBAR) + bpy.types.NODE_MT_editor_menus.remove(NODE) diff --git a/bioxelnodes/nodes.py b/bioxelnodes/nodes.py index a63e06e..b219a85 100644 --- a/bioxelnodes/nodes.py +++ b/bioxelnodes/nodes.py @@ -6,104 +6,143 @@ MENU_ITEMS = [ { - 'label': 'Methods', + 'label': 'Component', 'icon': 'OUTLINER_DATA_VOLUME', 'items': [ { - 'label': 'Mask by Threshold', + 'label': 'Cutout by Threshold', 'icon': 'EMPTY_SINGLE_ARROW', - 'node_type': 'BioxelNodes_MaskByThreshold', + 'node_type': 'BioxelNodes_CutoutByThreshold', 'node_description': '' }, { - 'label': 'Mask by Range', + 'label': 'Cutout by Range', 'icon': 'IPO_CONSTANT', - 'node_type': 'BioxelNodes_MaskByRange', + 'node_type': 'BioxelNodes_CutoutByRange', 'node_description': '' }, { - 'label': 'Mask by Label', - 'icon': 'MESH_CAPSULE', - 'node_type': 'BioxelNodes_MaskByLabel', + 'label': 'Cutout by Color', + 'icon': 'COLOR', + 'node_type': 'BioxelNodes_CutoutByColor', 'node_description': '' - } + }, + "separator", + { + 'label': 'To Surface', + 'icon': 'MESH_DATA', + 'node_type': 'BioxelNodes_ToSurface', + 'node_description': '' + }, + { + 'label': 'Join Component', + 'icon': 'CONSTRAINT_BONE', + 'node_type': 'BioxelNodes_JoinComponent', + 'node_description': '' + }, ] }, { - 'label': 'Shaders', - 'icon': 'SHADING_RENDERED', + 'label': 'Properties', + 'icon': 'PROPERTIES', 'items': [ { - 'label': 'Membrane Shader', - 'icon': 'NODE_MATERIAL', - 'node_type': 'BioxelNodes_AssignMembraneShader', + 'label': 'Set Properties', + 'icon': 'PROPERTIES', + 'node_type': 'BioxelNodes_SetProperties', 'node_description': '' }, + "separator", { - 'label': 'Solid Shader', - 'icon': 'SHADING_SOLID', - 'node_type': 'BioxelNodes_AssignSolidShader', + 'label': 'Set Color', + 'icon': 'IPO_SINE', + 'node_type': 'BioxelNodes_SetColor', 'node_description': '' }, { - 'label': 'Slime Shader', - 'icon': 'OUTLINER_DATA_META', - 'node_type': 'BioxelNodes_AssignSlimeShader', + 'label': 'Set Color by Layer', + 'icon': 'IPO_QUINT', + 'node_type': 'BioxelNodes_SetColorByLayer', 'node_description': '' }, { - 'label': 'Volume Shader', - 'icon': 'VOLUME_DATA', - 'node_type': 'BioxelNodes_AssignVolumeShader', + 'label': 'Set Color by Ramp 2', + 'icon': 'IPO_QUAD', + 'node_type': 'BioxelNodes_SetColorByRamp2', 'node_description': '' }, { - 'label': 'Universal Shader', - 'icon': 'MATSHADERBALL', - 'node_type': 'BioxelNodes_AssignUniversalShader', + 'label': 'Set Color by Ramp 3', + 'icon': 'IPO_CUBIC', + 'node_type': 'BioxelNodes_SetColorByRamp3', 'node_description': '' - } + }, + { + 'label': 'Set Color by Ramp 4', + 'icon': 'IPO_QUART', + 'node_type': 'BioxelNodes_SetColorByRamp4', + 'node_description': '' + }, + { + 'label': 'Set Color by Ramp 5', + 'icon': 'IPO_QUINT', + 'node_type': 'BioxelNodes_SetColorByRamp5', + 'node_description': '' + }, + # "separator", + # { + # 'label': 'Color Presets', + # 'icon': 'COLOR', + # 'node_type': 'BioxelNodes_ColorPresets', + # 'node_description': '' + # } + # { + # 'label': 'Color Presets MRI', + # 'icon': 'COLOR', + # 'node_type': 'BioxelNodes_ColorPresets_MRI', + # 'node_description': '' + # }, ] }, { - 'label': 'Colors', - 'icon': 'COLOR', + 'label': 'Surface', + 'icon': 'MESH_DATA', 'items': [ { - 'label': 'Color Presets', - 'icon': 'COLOR', - 'node_type': 'BioxelNodes_ColorPresets', + 'label': 'Membrane Shader', + 'icon': 'NODE_MATERIAL', + 'node_type': 'BioxelNodes_AssignMembraneShader', 'node_description': '' }, { - 'label': 'Color Presets MRI', - 'icon': 'COLOR', - 'node_type': 'BioxelNodes_ColorPresets_MRI', + 'label': 'Solid Shader', + 'icon': 'SHADING_SOLID', + 'node_type': 'BioxelNodes_AssignSolidShader', 'node_description': '' }, - "separator", { - 'label': 'Color Ramp 2', - 'icon': 'IPO_QUAD', - 'node_type': 'BioxelNodes_SetColorRamp2', + 'label': 'Slime Shader', + 'icon': 'OUTLINER_DATA_META', + 'node_type': 'BioxelNodes_AssignSlimeShader', 'node_description': '' }, + "separator", { - 'label': 'Color Ramp 3', - 'icon': 'IPO_CUBIC', - 'node_type': 'BioxelNodes_SetColorRamp3', + 'label': 'Inflate', + 'icon': 'OUTLINER_OB_META', + 'node_type': 'BioxelNodes_M_Inflate', 'node_description': '' }, { - 'label': 'Color Ramp 4', - 'icon': 'IPO_QUART', - 'node_type': 'BioxelNodes_SetColorRamp4', + 'label': 'Smooth', + 'icon': 'MOD_SMOOTH', + 'node_type': 'BioxelNodes_M_Smooth', 'node_description': '' }, { - 'label': 'Color Ramp 5', - 'icon': 'IPO_QUINT', - 'node_type': 'BioxelNodes_SetColorRamp5', + 'label': 'Remove Small Island', + 'icon': 'FORCE_LENNARDJONES', + 'node_type': 'BioxelNodes_M_RemoveSmallIsland', 'node_description': '' } ] @@ -125,36 +164,11 @@ 'node_type': 'BioxelNodes_PrimitiveCutter', 'node_description': '' }, - "separator", { - 'label': 'Plane Cutter', + 'label': 'Object Cutter', 'icon': 'MESH_PLANE', - 'node_type': 'BioxelNodes_PlaneObjectCutter', + 'node_type': 'BioxelNodes_ObjectCutter', 'node_description': '' - }, - { - 'label': 'Cylinder Cutter', - 'icon': 'MESH_CYLINDER', - 'node_type': 'BioxelNodes_CylinderObjectCutter', - 'node_description': '', - }, - { - 'label': 'Cube Cutter', - 'icon': 'MESH_CUBE', - 'node_type': 'BioxelNodes_CubeObjectCutter', - 'node_description': '', - }, - { - 'label': 'Sphere Cutter', - 'icon': 'MESH_UVSPHERE', - 'node_type': 'BioxelNodes_SphereObjectCutter', - 'node_description': '', - }, - { - 'label': 'Pie Cutter', - 'icon': 'MESH_CONE', - 'node_type': 'BioxelNodes_PieObjectCutter', - 'node_description': '', } ] }, @@ -162,13 +176,6 @@ 'label': 'Utils', 'icon': 'MODIFIER', 'items': [ - { - 'label': 'Join Component', - 'icon': 'CONSTRAINT_BONE', - 'node_type': 'BioxelNodes_JoinComponent', - 'node_description': '' - }, - "separator", { 'label': 'Pick Mesh', 'icon': 'OUTLINER_OB_MESH', @@ -186,26 +193,8 @@ 'icon': 'MESH_CUBE', 'node_type': 'BioxelNodes_PickBboxWire', 'node_description': '' - }, - "separator", - { - 'label': 'Inflate', - 'icon': 'OUTLINER_OB_META', - 'node_type': 'BioxelNodes_M_Inflate', - 'node_description': '' - }, - { - 'label': 'Smooth', - 'icon': 'MOD_SMOOTH', - 'node_type': 'BioxelNodes_M_Smooth', - 'node_description': '' - }, - { - 'label': 'Remove Small Island', - 'icon': 'FORCE_LENNARDJONES', - 'node_type': 'BioxelNodes_M_RemoveSmallIsland', - 'node_description': '' } + ] } ] diff --git a/bioxelnodes/operators/container.py b/bioxelnodes/operators/container.py index 1106e78..452c636 100644 --- a/bioxelnodes/operators/container.py +++ b/bioxelnodes/operators/container.py @@ -1,15 +1,17 @@ import bpy - import bmesh from ..nodes import custom_nodes +from ..customnodes.nodes import AddCustomNode from ..bioxel.io import load_container, save_container -from ..bioxelutils.layer import get_all_layer_objs -from ..bioxelutils.container import (container_to_obj, obj_to_container, - get_container_objs_from_selection) -from ..bioxelutils.node import get_nodes_by_type, move_node_between_nodes, move_node_to_node -from .utils import change_render_setting, get_cache_dir, get_preferences, select_object +from ..bioxelutils.container import container_to_obj, obj_to_container +from ..bioxelutils.utils import (get_container_layer_objs, get_container_obj, get_nodes_by_type, + move_node_between_nodes, + move_node_to_node, + get_all_layer_objs) + +from .utils import get_cache_dir, select_object class SaveContainer(bpy.types.Operator): @@ -23,19 +25,14 @@ class SaveContainer(bpy.types.Operator): filename_ext = ".bioxel" - @classmethod - def poll(cls, context): - container_objs = get_container_objs_from_selection() - return len(container_objs) > 0 - def execute(self, context): - container_objs = get_container_objs_from_selection() + container_obj = get_container_obj(context.object) - if len(container_objs) == 0: + if container_obj is None: self.report({"WARNING"}, "Cannot find any bioxel container.") return {'FINISHED'} - container = obj_to_container(container_objs[0]) + container = obj_to_container(container_obj) save_path = f"{self.filepath.split('.')[0]}.bioxel" save_container(container, save_path, overwrite=True) @@ -82,20 +79,13 @@ def invoke(self, context, event): class PickObject(): bl_options = {'UNDO'} - @classmethod - def poll(cls, context): - container_objs = get_container_objs_from_selection() - return len(container_objs) > 0 - def execute(self, context): - container_objs = get_container_objs_from_selection() + container_obj = get_container_obj(context.object) - if len(container_objs) == 0: + if container_obj is None: self.report({"WARNING"}, "Cannot find any bioxel container.") return {'FINISHED'} - container_obj = container_objs[0] - bpy.ops.mesh.primitive_cube_add( size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1)) obj = bpy.context.active_object @@ -124,6 +114,7 @@ class PickMesh(bpy.types.Operator, PickObject): bl_idname = "bioxelnodes.pick_mesh" bl_label = "Pick Mesh" bl_description = "Pick Container Mesh" + bl_icon = "OUTLINER_OB_MESH" object_type = "Mesh" @@ -131,6 +122,7 @@ class PickVolume(bpy.types.Operator, PickObject): bl_idname = "bioxelnodes.pick_volume" bl_label = "Pick Volume" bl_description = "Pick Container Volume" + bl_icon = "OUTLINER_OB_VOLUME" object_type = "Volume" @@ -138,45 +130,34 @@ class PickBboxWire(bpy.types.Operator, PickObject): bl_idname = "bioxelnodes.pick_bbox_wire" bl_label = "Pick Bbox Wire" bl_description = "Pick Container Bbox Wire" + bl_icon = "MESH_CUBE" object_type = "BboxWire" class AddCutter(): bl_options = {'UNDO'} - @classmethod - def poll(cls, context): - container_objs = get_container_objs_from_selection() - return len(container_objs) > 0 - def execute(self, context): - container_objs = get_container_objs_from_selection() + container_obj = get_container_obj(context.object) - if len(container_objs) == 0: + if container_obj is None: self.report({"WARNING"}, "Cannot find any bioxel container.") return {'FINISHED'} - - container_obj = container_objs[0] - - if self.object_type == "plane": - node_type = "BioxelNodes_PlaneObjectCutter" + # TODO: do not use operator to create obj + if self.cutter_type == "plane": bpy.ops.mesh.primitive_plane_add( size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1)) - elif self.object_type == "cylinder": - node_type = "BioxelNodes_CylinderObjectCutter" + elif self.cutter_type == "cylinder": bpy.ops.mesh.primitive_cylinder_add( radius=1, depth=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1)) bpy.context.object.rotation_euler[0] = container_obj.rotation_euler[0] - elif self.object_type == "cube": - node_type = "BioxelNodes_CubeObjectCutter" + elif self.cutter_type == "cube": bpy.ops.mesh.primitive_cube_add( size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1)) - elif self.object_type == "sphere": - node_type = "BioxelNodes_SphereObjectCutter" + elif self.cutter_type == "sphere": bpy.ops.mesh.primitive_ico_sphere_add( radius=1, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1)) - elif self.object_type == "pie": - node_type = "BioxelNodes_PieObjectCutter" + elif self.cutter_type == "pie": # Create mesh pie_mesh = bpy.data.meshes.new('Pie') @@ -211,6 +192,7 @@ def execute(self, context): bpy.context.view_layer.objects.active = pie cutter_obj = bpy.context.active_object + cutter_obj.location = container_obj.location cutter_obj.visible_camera = False cutter_obj.visible_diffuse = False cutter_obj.visible_glossy = False @@ -220,16 +202,23 @@ def execute(self, context): cutter_obj.hide_render = True cutter_obj.display_type = 'WIRE' + select_object(container_obj) + modifier = container_obj.modifiers[0] node_group = modifier.node_group - cutter_node = custom_nodes.add_node(node_group, node_type) - cutter_node.inputs[0].default_value = cutter_obj cut_nodes = get_nodes_by_type(node_group, 'BioxelNodes_Cut') - output_node = get_nodes_by_type(node_group, 'NodeGroupOutput')[0] if len(cut_nodes) == 0: - cut_node = custom_nodes.add_node(node_group, 'BioxelNodes_Cut') + cutter_node = custom_nodes.add_node(node_group, + "BioxelNodes_ObjectCutter") + cutter_node.inputs[0].default_value = self.cutter_type.capitalize() + cutter_node.inputs[1].default_value = cutter_obj + + cut_node = custom_nodes.add_node(node_group, + 'BioxelNodes_Cut') + output_node = get_nodes_by_type(node_group, + 'NodeGroupOutput')[0] if len(output_node.inputs[0].links) == 0: node_group.links.new(cut_node.outputs[0], output_node.inputs[0]) @@ -247,44 +236,132 @@ def execute(self, context): cut_node.inputs[1]) move_node_to_node(cutter_node, cut_node, (-300, -300)) - select_object(cutter_obj) else: - move_node_to_node(cutter_node, output_node, (0, -100)) - select_object(container_obj) + bpy.ops.bioxelnodes.object_cutter('INVOKE_DEFAULT', + cutter_obj_name=cutter_obj.name, + cutter_type=self.cutter_type.capitalize()) return {'FINISHED'} +class ObjectCutter(bpy.types.Operator, AddCustomNode): + bl_idname = "bioxelnodes.object_cutter" + bl_label = "Object Cutter" + bl_description = "Object Cutter" + bl_icon = "NODE" + bl_options = {'UNDO'} + + cutter_obj_name: bpy.props.StringProperty() # type: ignore + + cutter_type: bpy.props.StringProperty() # type: ignore + + def execute(self, context): + cutter_obj = bpy.data.objects.get(self.cutter_obj_name) + if cutter_obj == None: + self.report({"WARNING"}, "Get no layer.") + return {'FINISHED'} + + self.get_node_tree(self.node_type, self.node_link) + prev_context = bpy.context.area.type + bpy.context.area.type = 'NODE_EDITOR' + bpy.ops.node.add_node('INVOKE_DEFAULT', + type='GeometryNodeGroup', + use_transform=True) + bpy.context.area.type = prev_context + node = bpy.context.active_node + + self.assign_node_tree(node) + node.show_options = False + + node.label = "Object Cutter" + node.inputs[0].default_value = self.cutter_type + node.inputs[1].default_value = cutter_obj + + return {"FINISHED"} + + def invoke(self, context, event): + self.nodes_file = custom_nodes.nodes_file + self.node_type = "BioxelNodes_ObjectCutter" + return self.execute(context) + + class AddPlaneCutter(bpy.types.Operator, AddCutter): bl_idname = "bioxelnodes.add_plane_cutter" bl_label = "Add a Plane Cutter" bl_description = "Add a Plane Cutter to Container" - object_type = "plane" + bl_icon = "MESH_PLANE" + cutter_type = "plane" class AddCylinderCutter(bpy.types.Operator, AddCutter): bl_idname = "bioxelnodes.add_cylinder_cutter" bl_label = "Add a Cylinder Cutter" bl_description = "Add a Cylinder Cutter to Container" - object_type = "cylinder" + bl_icon = "MESH_CYLINDER" + cutter_type = "cylinder" class AddCubeCutter(bpy.types.Operator, AddCutter): bl_idname = "bioxelnodes.add_cube_cutter" bl_label = "Add a Cube Cutter" bl_description = "Add a Cube Cutter to Container" - object_type = "cube" + bl_icon = "MESH_CUBE" + cutter_type = "cube" class AddSphereCutter(bpy.types.Operator, AddCutter): bl_idname = "bioxelnodes.add_sphere_cutter" bl_label = "Add a Sphere Cutter" bl_description = "Add a Sphere Cutter to Container" - object_type = "sphere" + bl_icon = "MESH_UVSPHERE" + cutter_type = "sphere" class AddPieCutter(bpy.types.Operator, AddCutter): bl_idname = "bioxelnodes.add_pie_cutter" bl_label = "Add a Pie Cutter" bl_description = "Add a Pie Cutter to Container" - object_type = "pie" + bl_icon = "MESH_CONE" + cutter_type = "pie" + + +class SaveAllLayerCaches(bpy.types.Operator): + bl_idname = "bioxelnodes.save_all_layer_caches" + bl_label = "Save All Layer Caches" + bl_description = "Save Container's caches to directory." + bl_icon = "FILE_TICK" + + cache_dir: bpy.props.StringProperty( + name="Cache Directory", + subtype='DIR_PATH', + default="//" + ) # type: ignore + + def execute(self, context): + container_obj = get_container_obj(context.object) + + if container_obj is None: + self.report({"WARNING"}, "Cannot find any bioxel container.") + return {'FINISHED'} + + fails = [] + for layer_obj in get_container_layer_objs(container_obj): + try: + bpy.ops.bioxelnodes.save_layer_cache('EXEC_DEFAULT', + layer_obj_name=layer_obj.name, + cache_dir=self.cache_dir) + except: + fails.append(layer_obj) + + if len(fails) == 0: + self.report({"INFO"}, f"Successfully saved bioxel layers.") + else: + self.report( + {"WARNING"}, f"{','.join([layer.name for layer in fails])} fail to save.") + + return {'FINISHED'} + + def invoke(self, context, event): + context.window_manager.invoke_props_dialog(self, + width=500) + return {'RUNNING_MODAL'} diff --git a/bioxelnodes/operators/io.py b/bioxelnodes/operators/io.py index 9ba7910..e0cb49d 100644 --- a/bioxelnodes/operators/io.py +++ b/bioxelnodes/operators/io.py @@ -5,19 +5,19 @@ import numpy as np from pathlib import Path - from ..exceptions import CancelledByUser from ..props import BIOXELNODES_Series -from ..bioxel.layer import Layer -from ..bioxelutils.layer import (get_all_layer_objs, - get_layer_obj) +from ..bioxelutils.utils import (get_all_layer_objs, get_container_obj, + get_layer_obj, + get_container_objs_from_selection) from ..bioxelutils.container import (Container, add_layers, - container_to_obj, - get_container_objs_from_selection) + container_to_obj) +from ..bioxel.layer import Layer from ..bioxel.parse import (DICOM_EXTS, SUPPORT_EXTS, get_ext, parse_volumetric_data) -from .utils import (change_render_setting, get_cache_dir, get_preferences, + +from .utils import (get_cache_dir, progress_update, progress_bar, select_object) # 3rd-party @@ -78,20 +78,30 @@ def invoke(self, context, event): return {'RUNNING_MODAL'} -class ImportAsScalarLayer(bpy.types.Operator, ImportVolumetricData): - bl_idname = "bioxelnodes.import_as_scalar_layer" +class ImportAsScalar(bpy.types.Operator, ImportVolumetricData): + bl_idname = "bioxelnodes.import_as_scalar" bl_label = "Import as Scalar" bl_description = "Import Volumetric Data to Container as Scalar" + bl_icon = "EVENT_S" read_as = "scalar" -class ImportAsLabelLayer(bpy.types.Operator, ImportVolumetricData): - bl_idname = "bioxelnodes.import_as_label_layer" +class ImportAsLabel(bpy.types.Operator, ImportVolumetricData): + bl_idname = "bioxelnodes.import_as_label" bl_label = "Import as Label" bl_description = "Import Volumetric Data to Container as Label" + bl_icon = "EVENT_L" read_as = "label" +class ImportAsColor(bpy.types.Operator, ImportVolumetricData): + bl_idname = "bioxelnodes.import_as_color" + bl_label = "Import as Label" + bl_description = "Import Volumetric Data to Container as Label" + bl_icon = "EVENT_C" + read_as = "color" + + class BIOXELNODES_FH_ImportVolumetricData(bpy.types.FileHandler): bl_idname = "BIOXELNODES_FH_ImportVolumetricData" bl_label = "File handler for dicom import" @@ -100,7 +110,16 @@ class BIOXELNODES_FH_ImportVolumetricData(bpy.types.FileHandler): @classmethod def poll_drop(cls, context): - return (context.area and context.area.type == 'VIEW_3D') + if not context.area: + return False + + if context.area.type == 'VIEW_3D': + return True + elif context.area.type == 'NODE_EDITOR': + container_obj = get_container_obj(context.object) + return container_obj is not None + else: + return False def get_series_ids(self, context): @@ -125,7 +144,6 @@ class ParseVolumetricData(bpy.types.Operator): meta = None thread = None _timer = None - container_obj_name = "" progress: bpy.props.FloatProperty(name="Progress", options={"SKIP_SAVE"}, @@ -144,7 +162,8 @@ class ParseVolumetricData(bpy.types.Operator): read_as: bpy.props.EnumProperty(name="Read as", default="scalar", items=[("scalar", "Scalar", ""), - ("label", "Labels", "")]) # type: ignore + ("label", "Labels", ""), + ("color", "Color", "")]) # type: ignore series_id: bpy.props.EnumProperty(name="Select Series", items=get_series_ids) # type: ignore @@ -153,6 +172,10 @@ class ParseVolumetricData(bpy.types.Operator): type=BIOXELNODES_Series) # type: ignore def execute(self, context): + if not self.filepath: + self.report({"WARNING"}, "No file selected.") + return {'CANCELLED'} + data_path = Path(self.filepath).resolve() ext = get_ext(data_path) if ext not in SUPPORT_EXTS: @@ -207,7 +230,7 @@ def modal(self, context, event): # Check if user press 'ESC' if event.type == 'ESC': self.is_cancelled = True - progress_update(context, 0, "Canceling...") + progress_update(context, 0.0, "Canceling...") return {'PASS_THROUGH'} # Check if is the timer time @@ -227,6 +250,7 @@ def modal(self, context, event): context.window_manager.event_timer_remove(self._timer) # Remove the progress bar from status bar bpy.types.STATUSBAR_HT_header.remove(progress_bar) + progress_update(context, 1.0) # Check if thread is cancelled by user if self.is_cancelled: @@ -248,23 +272,32 @@ def modal(self, context, event): orig_shape = self.meta['xyz_shape'] orig_spacing = self.meta['spacing'] + if orig_spacing[2] == 1 and orig_spacing[0] < 0.1: + spacing_log10 = math.floor(math.log10(min(*orig_spacing))) + orig_spacing = (orig_spacing[0] * math.pow(10, -spacing_log10-1), + orig_spacing[1] * math.pow(10, -spacing_log10-1), + 1) + min_size = min(orig_spacing[0], orig_spacing[1], orig_spacing[2]) bioxel_size = max(min_size, 1.0) - layer_shape = get_layer_shape(1, orig_shape, orig_spacing) - layer_size = get_layer_size(layer_shape, - bioxel_size) - log10 = math.floor(math.log10(max(*layer_size))) - log10 = max(1, log10) - log10 = min(3, log10) - scene_scale = math.pow(10, -log10) - - if self.container_obj_name: - container_obj = bpy.data.objects.get(self.container_obj_name) + # layer_shape = get_layer_shape(1, orig_shape, orig_spacing) + # layer_size = get_layer_size(layer_shape, + # bioxel_size) + # log10 = math.floor(math.log10(max(*layer_size))) + # log10 = max(1, log10) + # log10 = min(3, log10) + # scene_scale = math.pow(10, -log10) + scene_scale = 0.01 + + if context.area.type == "NODE_EDITOR": + container_obj = context.object container_name = container_obj.name + container_obj_name = container_name else: container_name = self.meta['name'] + container_obj_name = "" series_id = self.series_id if self.series_id != "empty" else "" bpy.ops.bioxelnodes.import_volumetric_data_dialog( @@ -278,7 +311,7 @@ def modal(self, context, event): series_id=series_id, frame_count=self.meta['frame_count'], channel_count=self.meta['channel_count'], - container_obj_name=self.container_obj_name, + container_obj_name=container_obj_name, read_as=self.read_as, scene_scale=scene_scale ) @@ -287,18 +320,20 @@ def modal(self, context, event): return {'FINISHED'} def invoke(self, context, event): - if not self.filepath: + if context.window_manager.bioxelnodes_progress_factor < 1: + print("A process is executing, please wait for it to finish.") return {'CANCELLED'} + if not self.filepath: + return self.execute(context) + data_path = Path(self.filepath).resolve() ext = get_ext(data_path) - container_objs = get_container_objs_from_selection() - if len(container_objs) > 0: - self.container_obj_name = container_objs[0].name - - title = f"Add to **{self.container_obj_name}**" \ - if self.container_obj_name != "" else f"Init a Container" + if context.area.type == "NODE_EDITOR": + title = f"Add to **{context.object.name}**" + else: + title = "Init a Container" # Series Selection if ext in DICOM_EXTS: @@ -367,8 +402,7 @@ def get_meta(key): return {'CANCELLED'} if self.skip_read_as: - self.execute(context) - return {'RUNNING_MODAL'} + return self.execute(context) else: context.window_manager.invoke_props_dialog(self, width=400, @@ -429,7 +463,8 @@ class ImportVolumetricDataDialog(bpy.types.Operator): read_as: bpy.props.EnumProperty(name="Read as", default="scalar", items=[("scalar", "Scalar", ""), - ("label", "Labels", "")]) # type: ignore + ("label", "Labels", ""), + ("color", "Color", "")]) # type: ignore bioxel_size: bpy.props.FloatProperty(name="Bioxel Size (Larger size means small resolution)", soft_min=0.1, soft_max=10.0, @@ -540,12 +575,13 @@ def progress_callback(frame, total): try: layer = Layer(data=data == np.full_like(data, i+1), name=name_i, - kind=kind, - affine=affine) + kind=kind) layer.resize(shape=shape, progress_callback=progress_callback) + layer.affine = affine + layers.append(layer) except CancelledByUser: return @@ -553,6 +589,64 @@ def progress_callback(frame, total): self.has_error = e return + if kind == "color": + + if np.issubdtype(np.uint8, data.dtype): + data = np.multiply(data, 1.0 / 256, + dtype=np.float32) + elif data.dtype.kind in ['u', 'i']: + min_val = data.min() + max_val = data.max() + # Avoid division by zero if all values are the same + if max_val != min_val: + # Normalize the array to the range (0,1) + data = (data - min_val) / (max_val - min_val) + else: + # If all values are the same, the normalized array will be all zeros + data = np.zeros_like(data, dtype=np.float32) + + # Convert the normalized array to float dtype + data = data.astype(np.float32) + else: + data = data.astype(np.float32) + + name = self.layer_name or "Color" + if data.shape[4] == 1: + data = np.repeat(data, repeats=3, axis=4) + elif data.shape[4] == 2: + d_shape = list(data.shape) + d_shape = d_shape[:4] + [1] + zore = np.zeros(tuple(d_shape), dtype=np.float32) + data = np.concatenate((data, zore), axis=-1) + elif data.shape[4] > 3: + data = data[:, :, :, :, :3] + + if cancel(): + return + + progress_update(context, 0.2, + f"Processing {name}...") + progress_callback = progress_callback_factory(name, + 0.2, + 0.7) + + try: + layer = Layer(data=data, + name=name, + kind=kind) + + layer.resize(shape=shape, + progress_callback=progress_callback) + + layer.affine = affine + + layers.append(layer) + except CancelledByUser: + return + except Exception as e: + self.has_error = e + return + elif kind == "scalar": name = self.layer_name or "Scalar" @@ -573,12 +667,13 @@ def progress_callback(frame, total): try: layer = Layer(data=data[:, :, :, :, i:i+1], name=name_i, - kind=kind, - affine=affine) + kind=kind) layer.resize(shape=shape, progress_callback=progress_callback) + layer.affine = affine + layers.append(layer) except CancelledByUser: return @@ -598,12 +693,13 @@ def progress_callback(frame, total): try: layer = Layer(data=data, name=name, - kind=kind, - affine=affine) + kind=kind) layer.resize(shape=shape, progress_callback=progress_callback) + layer.affine = affine + layers.append(layer) except CancelledByUser: return @@ -634,7 +730,7 @@ def progress_callback(frame, total): def modal(self, context, event): if event.type == 'ESC': self.is_cancelled = True - progress_update(context, 0, "Canceling...") + progress_update(context, 0.0, "Canceling...") return {'PASS_THROUGH'} if event.type != 'TIMER': @@ -647,6 +743,7 @@ def modal(self, context, event): self.thread.join() context.window_manager.event_timer_remove(self._timer) bpy.types.STATUSBAR_HT_header.remove(progress_bar) + progress_update(context, 1.0) if self.is_cancelled: self.report({"WARNING"}, "Canncelled by user.") @@ -672,7 +769,8 @@ def modal(self, context, event): container_obj=container_obj, cache_dir=get_cache_dir(context)) else: - container = Container(name=self.container_name, + name = self.container_name or "Container" + container = Container(name=name, layers=self.layers) container_obj = container_to_obj(container, @@ -683,7 +781,10 @@ def modal(self, context, event): # Change render setting for better result if is_first_import: - change_render_setting(context) + bpy.ops.bioxelnodes.render_setting_preset('EXEC_DEFAULT', + preset="slice_viewer") + bpy.ops.bioxelnodes.render_setting_preset('EXEC_DEFAULT', + preset="cycles_preview") self.report({"INFO"}, "Successfully Imported") return {'FINISHED'} diff --git a/bioxelnodes/operators/layer.py b/bioxelnodes/operators/layer.py index 8c86923..ab520f8 100644 --- a/bioxelnodes/operators/layer.py +++ b/bioxelnodes/operators/layer.py @@ -1,23 +1,19 @@ from pathlib import Path import bpy +import re import numpy as np - from ..bioxel.layer import Layer -from ..bioxelutils.node import get_nodes_by_type -from ..bioxelutils.container import add_layers, get_container_obj -from ..bioxelutils.layer import obj_to_layer, get_container_layer_objs, get_layer_obj -from .utils import get_preferences, select_object - - -def get_layer_prop_value(layer_obj: bpy.types.Object, prop: str): - try: - node_group = layer_obj.modifiers[0].node_group - layer_node = get_nodes_by_type(node_group, "BioxelNodes__Layer")[0] - return layer_node.inputs[prop].default_value - except: - return None +from ..utils import copy_to_dir +from ..customnodes.nodes import AddCustomNode +from ..bioxelutils.utils import (get_container_obj, + get_layer_prop_value, + get_container_layer_objs, + get_node_type, set_layer_prop_value) +from ..bioxelutils.layer import layer_to_obj, obj_to_layer +from .utils import get_cache_dir, get_layer_item_label, get_layer_label, get_preferences +from ..nodes import custom_nodes def get_label_layer_selection(self, context): @@ -33,77 +29,202 @@ def get_label_layer_selection(self, context): return items +def get_selected_objs_in_node_tree(context): + select_objs = [] + # node_group = context.space_data.edit_tree + for node in context.selected_nodes: + if get_node_type(node) == "BioxelNodes_FetchLayer": + layer_obj = node.inputs[0].default_value + if layer_obj != None: + cache_filepath = Path(bpy.path.abspath( + layer_obj.data.filepath)).resolve() + if cache_filepath.is_file(): + select_objs.append(layer_obj) + return select_objs + + class LayerOperator(): bl_options = {'UNDO'} - base_obj: bpy.types.Object = None + layer_obj_name: bpy.props.StringProperty( + options={"HIDDEN"}) # type: ignore + + @property + def layer_obj(self): + return bpy.data.objects.get(self.layer_obj_name) + + @property + def is_lost(self): + if self.layer_obj is None: + return None + + cache_filepath = Path(bpy.path.abspath( + self.layer_obj.data.filepath)).resolve() + return not cache_filepath.is_file() + + +class FetchLayer(bpy.types.Operator, LayerOperator, AddCustomNode): + bl_idname = "bioxelnodes.fetch_layer" + bl_label = "Fetch Layer" + bl_description = "Fetch Layer" + bl_icon = "NODE" - def add_layer(self, context, layer): - preferences = get_preferences(context) - cache_dir = Path(preferences.cache_dir, 'VDBs') - container_obj = self.base_obj.parent + def execute(self, context): + if self.layer_obj == None: + self.report({"WARNING"}, "Get no layer.") + return {'FINISHED'} + + if self.is_lost: + self.report({"WARNING"}, "Selected layer is lost.") + return {'FINISHED'} + + self.get_node_tree(self.node_type, self.node_link) + prev_context = bpy.context.area.type + bpy.context.area.type = 'NODE_EDITOR' + bpy.ops.node.add_node('INVOKE_DEFAULT', + type='GeometryNodeGroup', + use_transform=True) + bpy.context.area.type = prev_context + node = bpy.context.active_node - add_layers([layer], - container_obj, - cache_dir) + self.assign_node_tree(node) + node.show_options = False + layer_obj = self.layer_obj + node.inputs[0].default_value = layer_obj + node.label = get_layer_label(layer_obj) - select_object(container_obj) + return {"FINISHED"} def invoke(self, context, event): - self.base_obj = get_layer_obj(bpy.context.active_object) + self.nodes_file = custom_nodes.nodes_file + self.node_type = "BioxelNodes_FetchLayer" return self.execute(context) -class ScalarOperator(LayerOperator): - @classmethod - def poll(cls, context): - label_objs = [obj for obj in context.selected_ids - if get_layer_prop_value(obj, "kind") == "scalar"] - return True if len(label_objs) > 0 else False +class FetchLayerMenu(bpy.types.Menu): + bl_idname = "BIOXELNODES_MT_ADD_LAYER" + bl_label = "Fetch Layer" + def draw(self, context): + container_obj = get_container_obj(bpy.context.active_object) + layer_objs = get_container_layer_objs(container_obj) + layout = self.layout -class LabelOperator(LayerOperator): - @classmethod - def poll(cls, context): - label_objs = [obj for obj in context.selected_ids - if get_layer_prop_value(obj, "kind") == "label"] - return True if len(label_objs) > 0 else False + for layer_obj in layer_objs: + op = layout.operator(FetchLayer.bl_idname, + text=get_layer_item_label(context, layer_obj)) + op.layer_obj_name = layer_obj.name -class SignScalar(bpy.types.Operator, ScalarOperator): - bl_idname = "bioxelnodes.sign_scalar" - bl_label = "Sign Scalar" - bl_description = "Sign the scalar value" +class ModifyLayerOperator(LayerOperator): + def layer_operate(self, orig_layer: Layer): + """do the operation""" + return orig_layer + + def add_layer_node(self, context, layer): + layer_obj = layer_to_obj(layer, + container_obj=self.layer_obj.parent, + cache_dir=get_cache_dir(context)) + + bpy.ops.bioxelnodes.fetch_layer('INVOKE_DEFAULT', + layer_obj_name=layer_obj.name) def execute(self, context): - base_layer = obj_to_layer(self.base_obj) + if self.layer_obj == None: + self.report({"WARNING"}, "Get no layer.") + return {'FINISHED'} - modified_layer = base_layer.copy() - modified_layer.data = -base_layer.data - modified_layer.name = f"{base_layer.name}_Signed" + if self.is_lost: + self.report({"WARNING"}, "Selected layer is lost.") + return {'FINISHED'} - self.add_layer(context, modified_layer) + orig_layer = obj_to_layer(self.layer_obj) + new_layer = self.layer_operate(orig_layer) + self.add_layer_node(context, new_layer) return {'FINISHED'} -class FillOperator(ScalarOperator): +class ResampleScalar(bpy.types.Operator, ModifyLayerOperator): + bl_idname = "bioxelnodes.resample_scalar" + bl_label = "Resample Scalar" + bl_description = "Resample Scalar" + bl_icon = "ALIASED" + + bioxel_size: bpy.props.FloatProperty( + name="Bioxel Size", + soft_min=0.1, soft_max=10.0, + default=1, + ) # type: ignore + + @staticmethod + def get_new_shape(orig_shape, orig_size, new_size): + return (int(orig_shape[0]*orig_size/new_size), + int(orig_shape[1]*orig_size/new_size), + int(orig_shape[2]*orig_size/new_size)) + + def layer_operate(self, orig_layer: Layer): + modified_layer = orig_layer.copy() + new_shape = self.get_new_shape(modified_layer.shape, + modified_layer.bioxel_size[0], + self.bioxel_size) + + modified_layer.resize(new_shape) + modified_layer.name = f"{orig_layer.name}_R-{self.bioxel_size:.2f}" + return modified_layer + + def draw(self, context): + orig_shape = get_layer_prop_value(self.layer_obj, "shape") + orig_size = get_layer_prop_value(self.layer_obj, "bioxel_size") + new_shape = self.get_new_shape(orig_shape, + orig_size, + self.bioxel_size) + + orig_shape = tuple(orig_shape) + bioxel_count = new_shape[0] * new_shape[1] * new_shape[2] + + layer_shape_text = f"Shape from {str(orig_shape)} to {str(new_shape)}" + if bioxel_count > 100000000: + layer_shape_text += "**TOO LARGE!**" + + layout = self.layout + layout.prop(self, "bioxel_size") + layout.label(text=layer_shape_text) + def invoke(self, context, event): - self.base_obj = get_layer_obj(bpy.context.active_object) - scalar_min = get_layer_prop_value(self.base_obj, "min") - self.fill_value = min(scalar_min, 0) - context.window_manager.invoke_props_dialog(self, width=400) - return {'RUNNING_MODAL'} + if self.layer_obj: + bioxel_size = get_layer_prop_value(self.layer_obj, "bioxel_size") + self.bioxel_size = bioxel_size + context.window_manager.invoke_props_dialog(self, + width=400, + title=f"Resample {self.layer_obj.name}") + return {'RUNNING_MODAL'} + else: + return self.execute(context) -class FillByThreshold(bpy.types.Operator, FillOperator): - bl_idname = "bioxelnodes.fill_by_threshold" - bl_label = "Fill Value by Threshold" - bl_description = "Fill Value by Threshold" - threshold: bpy.props.FloatProperty( - name="Threshold", - soft_min=0, soft_max=1024, - default=128, - ) # type: ignore +class SignScalar(bpy.types.Operator, ModifyLayerOperator): + bl_idname = "bioxelnodes.sign_scalar" + bl_label = "Sign Scalar" + bl_description = "Sign the scalar value" + bl_icon = "REMOVE" + + def layer_operate(self, orig_layer: Layer): + modified_layer = orig_layer.copy() + modified_layer.data = -orig_layer.data + modified_layer.name = f"{orig_layer.name}_Sign" + return modified_layer + + def invoke(self, context, event): + if self.layer_obj: + context.window_manager.invoke_confirm(self, + event, + message=f"Are you sure to sign {self.layer_obj.name}?") + return {'RUNNING_MODAL'} + else: + return self.execute(context) + + +class FillOperator(ModifyLayerOperator): fill_value: bpy.props.FloatProperty( name="Fill Value", @@ -112,30 +233,51 @@ class FillByThreshold(bpy.types.Operator, FillOperator): ) # type: ignore invert: bpy.props.BoolProperty( - name="Invert Area", + name="Invert Aera", default=True, ) # type: ignore - def execute(self, context): - base_layer = obj_to_layer(self.base_obj) + def invoke(self, context, event): + if self.layer_obj: + scalar_min = get_layer_prop_value(self.layer_obj, "min") + + self.fill_value = min(scalar_min, 0) + context.window_manager.invoke_props_dialog(self, + width=400, + title=f"Fill {self.layer_obj.name}") + return {'RUNNING_MODAL'} + else: + return self.execute(context) + + +class FillByThreshold(bpy.types.Operator, FillOperator): + bl_idname = "bioxelnodes.fill_by_threshold" + bl_label = "Fill Value by Threshold" + bl_description = "Fill Value by Threshold" + bl_icon = "EMPTY_SINGLE_ARROW" + + threshold: bpy.props.FloatProperty( + name="Threshold", + soft_min=0, soft_max=1024, + default=128, + ) # type: ignore - data = np.amax(base_layer.data, -1) + def layer_operate(self, orig_layer: Layer): + data = np.amax(orig_layer.data, -1) mask = data <= self.threshold \ if self.invert else data > self.threshold - modified_layer = base_layer.copy() + modified_layer = orig_layer.copy() modified_layer.fill(self.fill_value, mask) - modified_layer.name = f"{base_layer.name}_{self.threshold}-Filled" - - self.add_layer(context, modified_layer) - - return {'FINISHED'} + modified_layer.name = f"{orig_layer.name}_F-{self.threshold}" + return modified_layer class FillByRange(bpy.types.Operator, FillOperator): bl_idname = "bioxelnodes.fill_by_range" bl_label = "Fill Value by Range" bl_description = "Fill Value by Range" + bl_icon = "IPO_CONSTANT" from_min: bpy.props.FloatProperty( name="From Min", @@ -149,82 +291,52 @@ class FillByRange(bpy.types.Operator, FillOperator): default=256, ) # type: ignore - fill_value: bpy.props.FloatProperty( - name="Fill Value", - soft_min=0, soft_max=1024.0, - default=0, - ) # type: ignore - - invert: bpy.props.BoolProperty( - name="Invert Area", - default=True, - ) # type: ignore - - def execute(self, context): - base_layer = obj_to_layer(self.base_obj) - - data = np.amax(base_layer.data, -1) + def layer_operate(self, orig_layer: Layer): + data = np.amax(orig_layer.data, -1) mask = (data <= self.from_min) | (data >= self.from_max) if self.invert else \ (data > self.from_min) & (data < self.from_max) - modified_layer = base_layer.copy() + modified_layer = orig_layer.copy() modified_layer.fill(self.fill_value, mask) - modified_layer.name = f"{base_layer.name}_{self.from_min}-{self.from_max}-Filled" - - self.add_layer(context, modified_layer) - return {'FINISHED'} + modified_layer.name = f"{orig_layer.name}_F-{self.from_min}-{self.from_max}" + return modified_layer class FillByLabel(bpy.types.Operator, FillOperator): bl_idname = "bioxelnodes.fill_by_label" bl_label = "Fill Value by Label" bl_description = "Fill Value by Label Area" + bl_icon = "MESH_CAPSULE" - label_obj_name: bpy.props.EnumProperty( - name="Label Layer", - items=get_label_layer_selection - ) # type: ignore - - fill_value: bpy.props.FloatProperty( - name="Fill Value", - soft_min=0, soft_max=1024.0, - default=0, - ) # type: ignore - - invert: bpy.props.BoolProperty( - name="Invert Label", - default=True, - ) # type: ignore + label_obj_name: bpy.props.EnumProperty(name="Label Layer", + items=get_label_layer_selection) # type: ignore - def execute(self, context): + def layer_operate(self, orig_layer: Layer): label_obj = bpy.data.objects.get(self.label_obj_name) if not label_obj: self.report({"WARNING"}, "Cannot find any label layer.") return {'FINISHED'} - base_layer = obj_to_layer(self.base_obj) - label_layer = obj_to_layer(label_obj) - label_layer.resize(base_layer.shape) + label_layer.resize(orig_layer.shape) mask = np.amax(label_layer.data, -1) if self.invert: mask = 1 - mask - modified_layer = base_layer.copy() + modified_layer = orig_layer.copy() modified_layer.fill(self.fill_value, mask) - modified_layer.name = f"{base_layer.name}_{label_layer.name}-Filled" - - self.add_layer(context, modified_layer) - return {'FINISHED'} + modified_layer.name = f"{orig_layer.name}_F-{label_layer.name}" + return modified_layer -class CombineLabels(bpy.types.Operator, LabelOperator): +class CombineLabels(bpy.types.Operator, ModifyLayerOperator): bl_idname = "bioxelnodes.combine_labels" bl_label = "Combine Labels" bl_description = "Combine all selected labels" + bl_icon = "MOD_BUILD" def execute(self, context): - label_objs = [obj for obj in context.selected_ids + label_objs = [obj for obj in get_selected_objs_in_node_tree(context) if get_layer_prop_value(obj, "kind") == "label"] if len(label_objs) < 2: @@ -240,11 +352,173 @@ def execute(self, context): for label_obj in label_objs: label_layer = obj_to_layer(label_obj) label_layer.resize(base_layer.shape) - modified_layer.data = np.maximum(modified_layer.data, label_layer.data) + modified_layer.data = np.maximum( + modified_layer.data, label_layer.data) label_names.append(label_layer.name) - modified_layer.name = f"{'-'.join(label_names)}-Combined" + modified_layer.name = f"C-{'-'.join(label_names)}" + + self.add_layer_node(context, modified_layer) + return {'FINISHED'} + + +class SaveLayerCache(bpy.types.Operator, LayerOperator): + bl_idname = "bioxelnodes.save_layer_cache" + bl_label = "Save Layer Cache" + bl_description = "Save Layer Cache" + bl_icon = "FILE_TICK" + + cache_dir: bpy.props.StringProperty( + name="Cache Directory", + subtype='DIR_PATH', + default="//" + ) # type: ignore + + def execute(self, context): + if self.layer_obj == None: + self.report({"WARNING"}, "Get no layer.") + return {'FINISHED'} + + if self.is_lost: + self.report({"WARNING"}, "Selected layer is lost.") + return {'FINISHED'} + + # "//" + output_dir = bpy.path.abspath(self.cache_dir) + source_dir = bpy.path.abspath(self.layer_obj.data.filepath) + + source_path: Path = Path(source_dir).resolve() + is_sequence = self.layer_obj.data.is_sequence + + name = self.layer_obj.name if is_sequence else f"{self.layer_obj.name}.vdb" + output_path: Path = Path(output_dir, name, source_path.name).resolve() \ + if is_sequence else Path(output_dir, name).resolve() - self.add_layer(context, modified_layer) + if output_path != source_path: + copy_to_dir(source_path.parent if is_sequence else source_path, + output_path.parent.parent if is_sequence else output_path.parent, + new_name=name) + + blend_path = Path(bpy.path.abspath("//")).resolve() + + self.layer_obj.data.filepath = bpy.path.relpath(str(output_path), + start=str(blend_path)) + + return {'FINISHED'} + + def invoke(self, context, event): + if self.layer_obj: + context.window_manager.invoke_props_dialog(self, + width=500) + return {'RUNNING_MODAL'} + else: + return self.execute(context) + + +class RenameLayer(bpy.types.Operator, LayerOperator): + bl_idname = "bioxelnodes.rename_layer" + bl_label = "Rename Layer" + bl_description = "Rename Layer" + bl_icon = "FILE_FONT" + + name: bpy.props.StringProperty(name="New Name") # type: ignore + + def execute(self, context): + if self.layer_obj == None: + self.report({"WARNING"}, "Get no layer.") + return {'FINISHED'} + + if self.is_lost: + self.report({"WARNING"}, "Selected layer is lost.") + return {'FINISHED'} + + name = f"{self.layer_obj.parent.name}_{self.name}" + self.layer_obj.name = name + self.layer_obj_name = name + self.layer_obj.data.name = name + + set_layer_prop_value(self.layer_obj, "name", self.name) + + node_group = context.space_data.edit_tree + for node in node_group.nodes: + if get_node_type(node) == "BioxelNodes_FetchLayer": + if node.inputs[0].default_value == self.layer_obj: + node.label = self.name + + return {'FINISHED'} + + def invoke(self, context, event): + if self.layer_obj: + self.name = get_layer_prop_value(self.layer_obj, "name") + context.window_manager.invoke_props_dialog(self, + width=500, + title=f"rename {self.layer_obj.name}") + return {'RUNNING_MODAL'} + else: + return self.execute(context) + + +class RemoveLayer(bpy.types.Operator, LayerOperator): + bl_idname = "bioxelnodes.remove_layer" + bl_label = "Remove Selected Layer" + bl_description = "Remove Layer" + bl_icon = "TRASH" + + def execute(self, context): + if self.layer_obj == None: + self.report({"WARNING"}, "Get no layer.") + return {'FINISHED'} + + node_group = context.space_data.edit_tree + for node in node_group.nodes: + if get_node_type(node) == "BioxelNodes_FetchLayer": + if node.inputs[0].default_value == self.layer_obj: + node_group.nodes.remove(node) + + cache_filepath = Path(bpy.path.abspath( + self.layer_obj.data.filepath)).resolve() + + if cache_filepath.is_file(): + if self.layer_obj.data.is_sequence: + for f in cache_filepath.parent.iterdir(): + f.unlink(missing_ok=True) + else: + cache_filepath.unlink(missing_ok=True) + + # also remove layer object + bpy.data.volumes.remove(self.layer_obj.data) return {'FINISHED'} + + def invoke(self, context, event): + if self.layer_obj: + context.window_manager.invoke_confirm(self, + event, + message=f"Are you sure to remove {self.layer_obj.name}?") + return {'RUNNING_MODAL'} + else: + return self.execute(context) + + +class RemoveMissingLayers(bpy.types.Operator): + bl_idname = "bioxelnodes.remove_lost_layers" + bl_label = "Remove All Missing Layers" + bl_description = "Remove all missing " + bl_icon = "BRUSH_DATA" + + def execute(self, context): + container_obj = context.object + for layer_obj in get_container_layer_objs(container_obj): + cache_filepath = Path(bpy.path.abspath( + layer_obj.data.filepath)).resolve() + if cache_filepath.is_file(): + continue + bpy.ops.bioxelnodes.remove_layer('EXEC_DEFAULT', + layer_obj_name=layer_obj.name) + return {'FINISHED'} + + def invoke(self, context, event): + context.window_manager.invoke_confirm(self, + event, + message=f"Are you sure to remove all **Missing** layers?") + return {'RUNNING_MODAL'} diff --git a/bioxelnodes/operators/misc.py b/bioxelnodes/operators/misc.py index c2a7a27..20435f8 100644 --- a/bioxelnodes/operators/misc.py +++ b/bioxelnodes/operators/misc.py @@ -1,14 +1,11 @@ -import re import bpy from pathlib import Path import shutil - - -from ..utils import copy_to_dir from .utils import get_cache_dir from ..nodes import custom_nodes -from ..bioxelutils.container import get_container_objs_from_selection -from ..bioxelutils.layer import get_all_layer_objs, get_container_layer_objs +from ..bioxelutils.utils import (get_container_objs_from_selection, + get_all_layer_objs, + get_container_layer_objs) CLASS_PREFIX = "BIOXELNODES_MT_NODES" @@ -32,30 +29,6 @@ def execute(self, context): return {'FINISHED'} -def save_layer_cache(layer_obj, output_dir): - pattern = r'\.\d{4}\.' - - # "//" - output_dir = bpy.path.abspath(output_dir) - source_dir = bpy.path.abspath(layer_obj.data.filepath) - - source_path: Path = Path(source_dir).resolve() - is_sequence = re.search(pattern, source_path.name) is not None - name = layer_obj.name if is_sequence else f"{layer_obj.name}.vdb" - output_path: Path = Path(output_dir, name, source_path.name).resolve() \ - if is_sequence else Path(output_dir, name).resolve() - - if output_path != source_path: - copy_to_dir(source_path.parent if is_sequence else source_path, - output_path.parent.parent if is_sequence else output_path.parent, - new_name=name) - - blend_path = Path(bpy.path.abspath("//")).resolve() - - layer_obj.data.filepath = bpy.path.relpath( - str(output_path), start=str(blend_path)) - - class SaveStagedData(bpy.types.Operator): bl_idname = "bioxelnodes.save_staged_data" bl_label = "Save Staged Data" @@ -114,17 +87,19 @@ def execute(self, context): if self.save_cache: fails = [] - for layer in get_all_layer_objs(): + for layer_obj in get_all_layer_objs(): try: - save_layer_cache(layer, self.cache_dir) + bpy.ops.bioxelnodes.save_layer_cache('EXEC_DEFAULT', + layer_obj_name=layer_obj.name, + cache_dir=self.cache_dir) except: - fails.append(layer) + fails.append(layer_obj) if len(fails) == 0: self.report({"INFO"}, f"Successfully saved bioxel layers.") else: self.report( - {"WARNING"}, f"{','.join([layer.name for layer in fails])} fail to save.") + {"WARNING"}, f"{','.join([layer_obj.name for layer_obj in fails])} fail to save.") return {'FINISHED'} @@ -147,51 +122,6 @@ def draw(self, context): panel.prop(self, "lib_dir") -class SaveCaches(bpy.types.Operator): - bl_idname = "bioxelnodes.save_caches" - bl_label = "Save Caches" - bl_description = "Save Container's caches to directory." - - cache_dir: bpy.props.StringProperty( - name="Layer Directory", - subtype='DIR_PATH', - default="//" - ) # type: ignore - - @classmethod - def poll(cls, context): - container_objs = get_container_objs_from_selection() - return len(container_objs) > 0 - - def execute(self, context): - container_objs = get_container_objs_from_selection() - - if len(container_objs) == 0: - self.report({"WARNING"}, "Cannot find any bioxel container.") - return {'FINISHED'} - - fails = [] - for container_obj in container_objs: - for layer_obj in get_container_layer_objs(container_obj): - try: - save_layer_cache(layer_obj, self.cache_dir) - except: - fails.append(layer_obj) - - if len(fails) == 0: - self.report({"INFO"}, f"Successfully saved bioxel layers.") - else: - self.report( - {"WARNING"}, f"{','.join([layer.name for layer in fails])} fail to save.") - - return {'FINISHED'} - - def invoke(self, context, event): - context.window_manager.invoke_props_dialog(self, - width=500) - return {'RUNNING_MODAL'} - - class CleanAllCaches(bpy.types.Operator): bl_idname = "bioxelnodes.clear_all_caches" bl_label = "Clean All Caches in Temp" @@ -213,3 +143,71 @@ def invoke(self, context, event): event, message="All caches will be cleaned, include other project files, do you still want to clean?") return {'RUNNING_MODAL'} + + +class RenderSettingPreset(bpy.types.Operator): + bl_idname = "bioxelnodes.render_setting_preset" + bl_label = "Render Setting Preset" + bl_description = "Render Setting Preset" + + PRESETS = { + "slice_viewer": "Slice Viewer", + "eevee_preview": "EEVEE Preview", + "eevee_production": "EEVEE Production", + "cycles_preview": "Cycles Preview", + "cycles_production": "Cycles Production" + } + + preset: bpy.props.EnumProperty(name="Preset", + default="eevee_preview", + items=[(k, v, "") + for k, v in PRESETS.items()]) # type: ignore + + def execute(self, context): + if self.preset == "eevee_preview": + bpy.context.scene.render.engine = 'BLENDER_EEVEE_NEXT' + bpy.context.scene.eevee.use_taa_reprojection = False + bpy.context.scene.eevee.taa_samples = 16 + bpy.context.scene.eevee.volumetric_tile_size = '2' + bpy.context.scene.eevee.volumetric_shadow_samples = 128 + bpy.context.scene.eevee.volumetric_samples = 128 + bpy.context.scene.eevee.volumetric_ray_depth = 16 + bpy.context.scene.eevee.use_volumetric_shadows = True + + elif self.preset == "eevee_production": + bpy.context.scene.render.engine = 'BLENDER_EEVEE_NEXT' + bpy.context.scene.eevee.use_taa_reprojection = False + bpy.context.scene.eevee.taa_samples = 16 + bpy.context.scene.eevee.volumetric_tile_size = '1' + bpy.context.scene.eevee.volumetric_shadow_samples = 128 + bpy.context.scene.eevee.volumetric_samples = 256 + bpy.context.scene.eevee.volumetric_ray_depth = 16 + bpy.context.scene.eevee.use_volumetric_shadows = True + + elif self.preset == "cycles_preview": + bpy.context.scene.render.engine = 'CYCLES' + bpy.context.scene.cycles.shading_system = True + bpy.context.scene.cycles.volume_bounces = 12 + bpy.context.scene.cycles.transparent_max_bounces = 16 + bpy.context.scene.cycles.volume_preview_step_rate = 10 + bpy.context.scene.cycles.volume_step_rate = 10 + + elif self.preset == "cycles_production": + bpy.context.scene.render.engine = 'CYCLES' + bpy.context.scene.cycles.shading_system = True + bpy.context.scene.cycles.volume_bounces = 16 + bpy.context.scene.cycles.transparent_max_bounces = 32 + bpy.context.scene.cycles.volume_preview_step_rate = 1 + bpy.context.scene.cycles.volume_step_rate = 1 + + elif self.preset == "slice_viewer": + # bpy.context.scene.render.engine = 'BLENDER_EEVEE_NEXT' + bpy.context.scene.eevee.use_taa_reprojection = False + bpy.context.scene.eevee.taa_samples = 4 + bpy.context.scene.eevee.volumetric_tile_size = '2' + bpy.context.scene.eevee.volumetric_shadow_samples = 128 + bpy.context.scene.eevee.volumetric_samples = 128 + bpy.context.scene.eevee.volumetric_ray_depth = 1 + bpy.context.scene.eevee.use_volumetric_shadows = False + + return {'FINISHED'} diff --git a/bioxelnodes/operators/utils.py b/bioxelnodes/operators/utils.py index d1fda4f..22de898 100644 --- a/bioxelnodes/operators/utils.py +++ b/bioxelnodes/operators/utils.py @@ -1,29 +1,8 @@ import bpy from pathlib import Path -from .. import __package__ as base_package - -def change_render_setting(context): - preferences = get_preferences(context) - if preferences.do_change_render_setting: - bpy.context.scene.render.engine = 'CYCLES' - try: - bpy.context.scene.cycles.shading_system = True - bpy.context.scene.cycles.volume_bounces = 12 - bpy.context.scene.cycles.transparent_max_bounces = 16 - bpy.context.scene.cycles.volume_preview_step_rate = 10 - bpy.context.scene.cycles.volume_step_rate = 10 - except: - pass - - try: - bpy.context.scene.eevee.use_taa_reprojection = False - bpy.context.scene.eevee.volumetric_tile_size = '2' - bpy.context.scene.eevee.volumetric_shadow_samples = 128 - bpy.context.scene.eevee.volumetric_samples = 256 - bpy.context.scene.eevee.use_volumetric_shadows = True - except: - pass +from ..bioxelutils.utils import get_layer_prop_value +from .. import __package__ as base_package def select_object(target_obj): @@ -58,3 +37,24 @@ def get_cache_dir(context): cache_path = Path(preferences.cache_dir, 'VDBs') cache_path.mkdir(parents=True, exist_ok=True) return str(cache_path) + + +def get_layer_item_label(context, layer_obj): + label = get_layer_label(layer_obj) + cache_filepath = Path(bpy.path.abspath(layer_obj.data.filepath)).resolve() + if cache_filepath.is_file(): + cache_dirpath = Path(get_cache_dir(context)) + if cache_dirpath in cache_filepath.parents: + label = "* " + label + + else: + label = "**MISSING**" + label + + return label + + +def get_layer_label(layer_obj): + name = get_layer_prop_value(layer_obj, "name") + kind = get_layer_prop_value(layer_obj, "kind") + + return f"{name}" diff --git a/bioxelnodes/props.py b/bioxelnodes/props.py index d46bfd6..9dda4f8 100644 --- a/bioxelnodes/props.py +++ b/bioxelnodes/props.py @@ -1,5 +1,42 @@ import bpy +from .bioxelutils.utils import get_node_type + + +class BIOXELNODES_UL_layer_list(bpy.types.UIList): + def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + layout.label(text=item.label, translate=False, icon_value=icon) + + class BIOXELNODES_Series(bpy.types.PropertyGroup): id: bpy.props.StringProperty() # type: ignore label: bpy.props.StringProperty() # type: ignore + + +def select_layer(self, context): + layer_list_UL = bpy.context.window_manager.bioxelnodes_layer_list_UL + layer_list = layer_list_UL.layer_list + layer_list_active = layer_list_UL.layer_list_active + + if len(layer_list) > 0 and layer_list_active != -1 and layer_list_active < len(layer_list): + layer_obj = bpy.data.objects[layer_list[layer_list_active].obj_name] + node_group = context.space_data.edit_tree + for node in node_group.nodes: + node.select = False + if get_node_type(node) == "BioxelNodes_FetchLayer": + if node.inputs[0].default_value == layer_obj: + node.select = True + + +class BIOXELNODES_Layer(bpy.types.PropertyGroup): + obj_name: bpy.props.StringProperty() # type: ignore + label: bpy.props.StringProperty() # type: ignore + info_text: bpy.props.StringProperty() # type: ignore + + +class BIOXELNODES_LayerListUL(bpy.types.PropertyGroup): + layer_list: bpy.props.CollectionProperty( + type=BIOXELNODES_Layer) # type: ignore + layer_list_active: bpy.props.IntProperty(default=-1, + update=select_layer, + options=set()) # type: ignore diff --git a/build.py b/build.py index 24a4b35..0efcc3d 100644 --- a/build.py +++ b/build.py @@ -16,7 +16,8 @@ class Platform: "pyometiff==1.0.0", "mrcfile==1.5.1", "h5py==3.11.0", - "transforms3d==0.4.2"] + "transforms3d==0.4.2", + "tifffile==2024.7.24"] platforms = {"windows-x64": Platform(pypi_suffix="win_amd64", diff --git a/pyproject.toml b/pyproject.toml index c3b05be..14dd6f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bioxelnodes" -version = "0.3.3" +version = "0.4.0" description = "" authors = ["Ma Nan "] license = "MIT" From 051df670a506a021f43c9983a86cf8156ea78241 Mon Sep 17 00:00:00 2001 From: Ma Nan Date: Fri, 16 Aug 2024 13:56:14 +0800 Subject: [PATCH 2/3] feat: boost render speed --- .../assets/Nodes/BioxelNodes_4.2.blend | 4 +- bioxelnodes/bioxelutils/layer.py | 9 ++- bioxelnodes/bioxelutils/utils.py | 32 +++++++++ bioxelnodes/menus.py | 24 +++++-- bioxelnodes/nodes.py | 4 +- bioxelnodes/operators/container.py | 66 +++++++++++++++++-- bioxelnodes/operators/io.py | 5 +- bioxelnodes/operators/misc.py | 63 +++++++++++------- 8 files changed, 160 insertions(+), 47 deletions(-) diff --git a/bioxelnodes/assets/Nodes/BioxelNodes_4.2.blend b/bioxelnodes/assets/Nodes/BioxelNodes_4.2.blend index 4442571..15e4bec 100644 --- a/bioxelnodes/assets/Nodes/BioxelNodes_4.2.blend +++ b/bioxelnodes/assets/Nodes/BioxelNodes_4.2.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2de3498292c416d2ddc48f088c380330276bd75085f4261e8857c77160a3fa9b -size 7489655 +oid sha256:1de545ce6fa3f392f57a26f241d2d95877371b86aecfc8d7a5408b35f4ffeeaa +size 7626389 diff --git a/bioxelnodes/bioxelutils/layer.py b/bioxelnodes/bioxelutils/layer.py index 45f9207..587a9f3 100644 --- a/bioxelnodes/bioxelutils/layer.py +++ b/bioxelnodes/bioxelutils/layer.py @@ -9,7 +9,8 @@ from ..nodes import custom_nodes from ..bioxel.layer import Layer -from .utils import get_layer_prop_value, move_node_between_nodes +from .utils import (get_layer_prop_value, + move_node_between_nodes, add_direct_driver) def obj_to_layer(layer_obj: bpy.types.Object): @@ -135,9 +136,14 @@ def layer_to_obj(layer: Layer, cache_filepaths = [cache_filepath] layer_data = bpy.data.volumes.new(layer_display_name) + layer_data.render.space = 'WORLD' + layer_data.render.step_size = container_obj.scale[0] * layer.bioxel_size[0] layer_data.sequence_mode = 'REPEAT' layer_data.filepath = str(cache_filepaths[0]) + # add_direct_driver(layer_data, "render.step_size", + # container_obj, "scale[0]") + if layer.frame_count > 1: layer_data.is_sequence = True layer_data.frame_duration = layer.frame_count @@ -145,7 +151,6 @@ def layer_to_obj(layer: Layer, layer_data.is_sequence = False layer_obj = bpy.data.objects.new(layer_display_name, layer_data) - layer_obj['bioxel_layer'] = True print(f"Creating Node for {layer.name}...") diff --git a/bioxelnodes/bioxelutils/utils.py b/bioxelnodes/bioxelutils/utils.py index 55823b7..153f149 100644 --- a/bioxelnodes/bioxelutils/utils.py +++ b/bioxelnodes/bioxelutils/utils.py @@ -94,3 +94,35 @@ def get_all_layer_objs(): 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) \ No newline at end of file diff --git a/bioxelnodes/menus.py b/bioxelnodes/menus.py index f6e65e8..70715ae 100644 --- a/bioxelnodes/menus.py +++ b/bioxelnodes/menus.py @@ -13,10 +13,10 @@ from .operators.container import (SaveAllLayerCaches, SaveContainer, LoadContainer, AddPieCutter, AddPlaneCutter, AddCylinderCutter, AddCubeCutter, AddSphereCutter, - PickBboxWire, PickMesh, PickVolume) + PickBboxWire, PickMesh, PickVolume, ScaleContainer) from .operators.io import (ImportAsLabel, ImportAsScalar, ImportAsColor) from .operators.misc import (CleanAllCaches, - ReLinkNodes, RenderSettingPreset, SaveStagedData) + ReLinkNodes, RenderSettingPreset, SaveStagedData, SliceViewer) class PickFromContainerMenu(bpy.types.Menu): @@ -156,19 +156,19 @@ def draw(self, context): active_obj_name = "" layout = self.layout - + layout.separator() layout.menu(AddLayerMenu.bl_idname, icon=AddLayerMenu.bl_icon) - - layout.operator(RemoveMissingLayers.bl_idname, - icon=RemoveMissingLayers.bl_icon) - layout.separator() layout.operator(SaveContainer.bl_idname) + layout.separator() + layout.operator(ScaleContainer.bl_idname) layout.menu(AddCutterMenu.bl_idname) layout.menu(PickFromContainerMenu.bl_idname) layout.operator(SaveAllLayerCaches.bl_idname, icon=SaveAllLayerCaches.bl_icon) + layout.operator(RemoveMissingLayers.bl_idname, + icon=RemoveMissingLayers.bl_icon) layout.separator() layout.menu(FetchLayerMenu.bl_idname) @@ -247,6 +247,8 @@ def NODE_PT(self, context): sidebar.menu(AddLayerMenu.bl_idname, icon=AddLayerMenu.bl_icon, text="") + sidebar.operator(SaveAllLayerCaches.bl_idname, + icon=SaveAllLayerCaches.bl_icon, text="") sidebar.operator(RemoveMissingLayers.bl_idname, icon=RemoveMissingLayers.bl_icon, text="") @@ -285,13 +287,21 @@ def NODE_PT(self, context): layout.separator() +def VIEW3D_TOPBAR(self, context): + layout = self.layout + layout.operator(SliceViewer.bl_idname, + icon=SliceViewer.bl_icon, text="") + + def add(): + bpy.types.VIEW3D_HT_header.append(VIEW3D_TOPBAR) bpy.types.NODE_PT_node_tree_properties.prepend(NODE_PT) bpy.types.TOPBAR_MT_editor_menus.append(TOPBAR) bpy.types.NODE_MT_editor_menus.append(NODE) def remove(): + bpy.types.VIEW3D_HT_header.remove(VIEW3D_TOPBAR) bpy.types.NODE_PT_node_tree_properties.remove(NODE_PT) bpy.types.TOPBAR_MT_editor_menus.remove(TOPBAR) bpy.types.NODE_MT_editor_menus.remove(NODE) diff --git a/bioxelnodes/nodes.py b/bioxelnodes/nodes.py index b219a85..bd94e93 100644 --- a/bioxelnodes/nodes.py +++ b/bioxelnodes/nodes.py @@ -60,9 +60,9 @@ 'node_description': '' }, { - 'label': 'Set Color by Layer', + 'label': 'Set Color by Color', 'icon': 'IPO_QUINT', - 'node_type': 'BioxelNodes_SetColorByLayer', + 'node_type': 'BioxelNodes_SetColorByColor', 'node_description': '' }, { diff --git a/bioxelnodes/operators/container.py b/bioxelnodes/operators/container.py index 452c636..0683327 100644 --- a/bioxelnodes/operators/container.py +++ b/bioxelnodes/operators/container.py @@ -6,7 +6,8 @@ from ..customnodes.nodes import AddCustomNode from ..bioxel.io import load_container, save_container from ..bioxelutils.container import container_to_obj, obj_to_container -from ..bioxelutils.utils import (get_container_layer_objs, get_container_obj, get_nodes_by_type, +from ..bioxelutils.utils import (get_container_layer_objs, get_container_obj, + get_layer_prop_value, get_nodes_by_type, move_node_between_nodes, move_node_to_node, get_all_layer_objs) @@ -66,7 +67,8 @@ def execute(self, context): select_object(container_obj) if is_first_import: - change_render_setting(context) + bpy.ops.bioxelnodes.render_setting_preset('EXEC_DEFAULT', + preset="preview_c") self.report({"INFO"}, f"Successfully load {load_path}") return {'FINISHED'} @@ -158,14 +160,24 @@ def execute(self, context): bpy.ops.mesh.primitive_ico_sphere_add( radius=1, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1)) elif self.cutter_type == "pie": + bpy.ops.mesh.primitive_plane_add( + size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1)) + pie = bpy.context.active_object + pie.name = "Pie" + orig_data = pie.data + # Create mesh pie_mesh = bpy.data.meshes.new('Pie') - # Create object - pie = bpy.data.objects.new('Pie', pie_mesh) + pie.data = pie_mesh + bpy.data.meshes.remove(orig_data) + + # # Create object + # pie = bpy.data.objects.new('Pie', pie_mesh) + + # # Link object to scene + # bpy.context.scene.collection.objects.link(pie) - # Link object to scene - bpy.context.scene.collection.objects.link(pie) # Get a BMesh representation bm = bmesh.new() # create an empty BMesh bm.from_mesh(pie_mesh) # fill it in from a Mesh @@ -189,7 +201,7 @@ def execute(self, context): # Finish up, write the bmesh back to the mesh bm.to_mesh(pie_mesh) - bpy.context.view_layer.objects.active = pie + # bpy.context.view_layer.objects.active = pie cutter_obj = bpy.context.active_object cutter_obj.location = container_obj.location @@ -325,6 +337,46 @@ class AddPieCutter(bpy.types.Operator, AddCutter): cutter_type = "pie" +class ScaleContainer(bpy.types.Operator): + bl_idname = "bioxelnodes.scale_container" + bl_label = "Scale Container" + bl_description = "Scale Container." + bl_icon = "FILE_TICK" + + scene_scale: bpy.props.FloatProperty(name="Scene Scale", + soft_min=0.0001, soft_max=10.0, + min=1e-6, max=1e6, + default=0.01) # type: ignore + + def execute(self, context): + container_obj = get_container_obj(context.object) + + if container_obj is None: + self.report({"WARNING"}, "Cannot find any bioxel container.") + return {'FINISHED'} + + container_obj.scale[0] = self.scene_scale + container_obj.scale[1] = self.scene_scale + container_obj.scale[2] = self.scene_scale + + for layer_obj in get_container_layer_objs(container_obj): + bioxel_size = get_layer_prop_value(layer_obj, "bioxel_size") + layer_obj.data.render.step_size = self.scene_scale * bioxel_size + + return {'FINISHED'} + + def invoke(self, context, event): + container_obj = get_container_obj(context.object) + + if container_obj is None: + return self.execute(context) + else: + self.scene_scale = container_obj.scale[0] + context.window_manager.invoke_props_dialog(self, + width=500) + return {'RUNNING_MODAL'} + + class SaveAllLayerCaches(bpy.types.Operator): bl_idname = "bioxelnodes.save_all_layer_caches" bl_label = "Save All Layer Caches" diff --git a/bioxelnodes/operators/io.py b/bioxelnodes/operators/io.py index e0cb49d..6bfcd1c 100644 --- a/bioxelnodes/operators/io.py +++ b/bioxelnodes/operators/io.py @@ -782,9 +782,8 @@ def modal(self, context, event): # Change render setting for better result if is_first_import: bpy.ops.bioxelnodes.render_setting_preset('EXEC_DEFAULT', - preset="slice_viewer") - bpy.ops.bioxelnodes.render_setting_preset('EXEC_DEFAULT', - preset="cycles_preview") + preset="preview_c") + # bpy.ops.bioxelnodes.slice_viewer('EXEC_DEFAULT') self.report({"INFO"}, "Successfully Imported") return {'FINISHED'} diff --git a/bioxelnodes/operators/misc.py b/bioxelnodes/operators/misc.py index 20435f8..5d378c9 100644 --- a/bioxelnodes/operators/misc.py +++ b/bioxelnodes/operators/misc.py @@ -151,20 +151,19 @@ class RenderSettingPreset(bpy.types.Operator): bl_description = "Render Setting Preset" PRESETS = { - "slice_viewer": "Slice Viewer", - "eevee_preview": "EEVEE Preview", - "eevee_production": "EEVEE Production", - "cycles_preview": "Cycles Preview", - "cycles_production": "Cycles Production" + "preview_e": "Preview (EEVEE)", + "preview_c": "Preview (Cycles)", + "production_e": "Production (EEVEE)", + "production_c": "Production (Cycles)" } preset: bpy.props.EnumProperty(name="Preset", - default="eevee_preview", + default="preview_c", items=[(k, v, "") for k, v in PRESETS.items()]) # type: ignore def execute(self, context): - if self.preset == "eevee_preview": + if self.preset == "preview_e": bpy.context.scene.render.engine = 'BLENDER_EEVEE_NEXT' bpy.context.scene.eevee.use_taa_reprojection = False bpy.context.scene.eevee.taa_samples = 16 @@ -174,7 +173,7 @@ def execute(self, context): bpy.context.scene.eevee.volumetric_ray_depth = 16 bpy.context.scene.eevee.use_volumetric_shadows = True - elif self.preset == "eevee_production": + elif self.preset == "production_e": bpy.context.scene.render.engine = 'BLENDER_EEVEE_NEXT' bpy.context.scene.eevee.use_taa_reprojection = False bpy.context.scene.eevee.taa_samples = 16 @@ -184,30 +183,46 @@ def execute(self, context): bpy.context.scene.eevee.volumetric_ray_depth = 16 bpy.context.scene.eevee.use_volumetric_shadows = True - elif self.preset == "cycles_preview": + elif self.preset == "preview_c": bpy.context.scene.render.engine = 'CYCLES' bpy.context.scene.cycles.shading_system = True bpy.context.scene.cycles.volume_bounces = 12 bpy.context.scene.cycles.transparent_max_bounces = 16 - bpy.context.scene.cycles.volume_preview_step_rate = 10 - bpy.context.scene.cycles.volume_step_rate = 10 + bpy.context.scene.cycles.volume_preview_step_rate = 1 + bpy.context.scene.cycles.volume_step_rate = 1 - elif self.preset == "cycles_production": + elif self.preset == "production_c": bpy.context.scene.render.engine = 'CYCLES' bpy.context.scene.cycles.shading_system = True bpy.context.scene.cycles.volume_bounces = 16 bpy.context.scene.cycles.transparent_max_bounces = 32 - bpy.context.scene.cycles.volume_preview_step_rate = 1 - bpy.context.scene.cycles.volume_step_rate = 1 - - elif self.preset == "slice_viewer": - # bpy.context.scene.render.engine = 'BLENDER_EEVEE_NEXT' - bpy.context.scene.eevee.use_taa_reprojection = False - bpy.context.scene.eevee.taa_samples = 4 - bpy.context.scene.eevee.volumetric_tile_size = '2' - bpy.context.scene.eevee.volumetric_shadow_samples = 128 - bpy.context.scene.eevee.volumetric_samples = 128 - bpy.context.scene.eevee.volumetric_ray_depth = 1 - bpy.context.scene.eevee.use_volumetric_shadows = False + bpy.context.scene.cycles.volume_preview_step_rate = 0.1 + bpy.context.scene.cycles.volume_step_rate = 0.1 return {'FINISHED'} + +class SliceViewer(bpy.types.Operator): + bl_idname = "bioxelnodes.slice_viewer" + bl_label = "Slice Viewer" + bl_description = "Slice Viewer" + bl_icon = "FILE_VOLUME" + + def execute(self, context): + bpy.context.scene.eevee.use_taa_reprojection = False + bpy.context.scene.eevee.taa_samples = 4 + bpy.context.scene.eevee.volumetric_tile_size = '2' + bpy.context.scene.eevee.volumetric_shadow_samples = 128 + bpy.context.scene.eevee.volumetric_samples = 128 + bpy.context.scene.eevee.volumetric_ray_depth = 1 + bpy.context.scene.eevee.use_volumetric_shadows = False + + for area in bpy.context.screen.areas: + if area.type == 'VIEW_3D': + area.spaces[0].shading.type = 'MATERIAL' + area.spaces[0].shading.studio_light = 'studio.exr' + area.spaces[0].shading.studiolight_intensity = 2.5 + area.spaces[0].shading.use_scene_lights = False + area.spaces[0].shading.use_scene_world = False + + + return {'FINISHED'} \ No newline at end of file From 655b19bc7c904e6f960a694745d3449233d028b8 Mon Sep 17 00:00:00 2001 From: Ma Nan Date: Tue, 20 Aug 2024 14:21:10 +0800 Subject: [PATCH 3/3] feat: improving the user experience --- .../assets/Nodes/BioxelNodes_4.2.blend | 3 - .../assets/Nodes/BioxelNodes_v0.1.x.blend | 3 + .../assets/Nodes/BioxelNodes_v0.2.x.blend | 3 + .../assets/Nodes/BioxelNodes_v0.3.x.blend | 3 + .../assets/Nodes/BioxelNodes_v1.0.x.blend | 3 + bioxelnodes/bioxel/layer.py | 29 +- bioxelnodes/bioxel/parse.py | 3 +- .../bioxelutils/{utils.py => common.py} | 125 +++- bioxelnodes/bioxelutils/container.py | 42 +- bioxelnodes/bioxelutils/layer.py | 34 +- bioxelnodes/bioxelutils/node.py | 68 ++ bioxelnodes/blender_manifest.toml | 2 +- bioxelnodes/{nodes.py => constants.py} | 189 +++--- bioxelnodes/customnodes/__init__.py | 1 - bioxelnodes/customnodes/menus.py | 147 ----- bioxelnodes/customnodes/nodes.py | 117 ---- bioxelnodes/exceptions.py | 18 + bioxelnodes/menus.py | 342 ++++++---- bioxelnodes/node_menu.py | 87 +++ bioxelnodes/operators/container.py | 358 +++++++---- bioxelnodes/operators/io.py | 234 ++++--- bioxelnodes/operators/layer.py | 598 ++++++++++-------- bioxelnodes/operators/misc.py | 224 +++---- bioxelnodes/operators/node.py | 62 ++ bioxelnodes/operators/utils.py | 60 -- bioxelnodes/preferences.py | 14 +- bioxelnodes/props.py | 4 +- bioxelnodes/utils.py | 40 ++ pyproject.toml | 2 +- 29 files changed, 1642 insertions(+), 1173 deletions(-) delete mode 100644 bioxelnodes/assets/Nodes/BioxelNodes_4.2.blend create mode 100644 bioxelnodes/assets/Nodes/BioxelNodes_v0.1.x.blend create mode 100644 bioxelnodes/assets/Nodes/BioxelNodes_v0.2.x.blend create mode 100644 bioxelnodes/assets/Nodes/BioxelNodes_v0.3.x.blend create mode 100644 bioxelnodes/assets/Nodes/BioxelNodes_v1.0.x.blend rename bioxelnodes/bioxelutils/{utils.py => common.py} (51%) create mode 100644 bioxelnodes/bioxelutils/node.py rename bioxelnodes/{nodes.py => constants.py} (50%) delete mode 100644 bioxelnodes/customnodes/__init__.py delete mode 100644 bioxelnodes/customnodes/menus.py delete mode 100644 bioxelnodes/customnodes/nodes.py create mode 100644 bioxelnodes/node_menu.py create mode 100644 bioxelnodes/operators/node.py delete mode 100644 bioxelnodes/operators/utils.py diff --git a/bioxelnodes/assets/Nodes/BioxelNodes_4.2.blend b/bioxelnodes/assets/Nodes/BioxelNodes_4.2.blend deleted file mode 100644 index 15e4bec..0000000 --- a/bioxelnodes/assets/Nodes/BioxelNodes_4.2.blend +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1de545ce6fa3f392f57a26f241d2d95877371b86aecfc8d7a5408b35f4ffeeaa -size 7626389 diff --git a/bioxelnodes/assets/Nodes/BioxelNodes_v0.1.x.blend b/bioxelnodes/assets/Nodes/BioxelNodes_v0.1.x.blend new file mode 100644 index 0000000..70cc114 --- /dev/null +++ b/bioxelnodes/assets/Nodes/BioxelNodes_v0.1.x.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d6832d20cb556ff904cfd7c5574b048ea54011cb531fe6423c4689186de2c8aa +size 1749214 diff --git a/bioxelnodes/assets/Nodes/BioxelNodes_v0.2.x.blend b/bioxelnodes/assets/Nodes/BioxelNodes_v0.2.x.blend new file mode 100644 index 0000000..016c4cf --- /dev/null +++ b/bioxelnodes/assets/Nodes/BioxelNodes_v0.2.x.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b219a6f005718d8223766215a598ebde9b646c3960fb298d72ffc864791f3c3a +size 6691383 diff --git a/bioxelnodes/assets/Nodes/BioxelNodes_v0.3.x.blend b/bioxelnodes/assets/Nodes/BioxelNodes_v0.3.x.blend new file mode 100644 index 0000000..595e09b --- /dev/null +++ b/bioxelnodes/assets/Nodes/BioxelNodes_v0.3.x.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:87831f56b9d23b8ee3eccfea02c27e8ff305723a079e2c115a7b059fb5e1cb24 +size 6803344 diff --git a/bioxelnodes/assets/Nodes/BioxelNodes_v1.0.x.blend b/bioxelnodes/assets/Nodes/BioxelNodes_v1.0.x.blend new file mode 100644 index 0000000..ef7bc8b --- /dev/null +++ b/bioxelnodes/assets/Nodes/BioxelNodes_v1.0.x.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cda3a7c9f63386b6c7e48e5447a03366b51a3e2b446b53955cc42b7b98e5c504 +size 9005254 diff --git a/bioxelnodes/bioxel/layer.py b/bioxelnodes/bioxel/layer.py index 20365b1..33020c1 100644 --- a/bioxelnodes/bioxel/layer.py +++ b/bioxelnodes/bioxel/layer.py @@ -2,7 +2,6 @@ import numpy as np from . import scipy -from . import skimage as ski from . import scipy as ndi # 3rd-party @@ -70,23 +69,27 @@ def max(self): def copy(self): return copy.deepcopy(self) - def fill(self, value: float, mask: np.ndarray): + def fill(self, value: float, mask: np.ndarray, smooth: int = 0): mask_frames = () if mask.ndim == 4: if mask.shape[0] != self.frame_count: raise Exception("Mask frame count is not same as ") for f in range(self.frame_count): mask_frame = mask[f, :, :, :] - mask_frame = scipy.minimum_filter( - mask_frame.astype(np.float32), size=3) + if smooth > 0: + mask_frame = scipy.minimum_filter(mask_frame.astype(np.float32), + mode="nearest", + size=smooth) # mask_frame = scipy.median_filter( # mask_frame.astype(np.float32), size=2) mask_frames += (mask_frame,) elif mask.ndim == 3: for f in range(self.frame_count): mask_frame = mask[:, :, :] - mask_frame = scipy.minimum_filter( - mask_frame.astype(np.float32), size=3) + if smooth > 0: + mask_frame = scipy.minimum_filter(mask_frame.astype(np.float32), + mode="nearest", + size=smooth) # mask_frame = scipy.median_filter( # mask_frame.astype(np.float32), size=2) mask_frames += (mask_frame,) @@ -97,12 +100,11 @@ def fill(self, value: float, mask: np.ndarray): _mask = np.expand_dims(_mask, axis=-1) self.data = _mask * value + (1-_mask) * self.data - def resize(self, shape: tuple, progress_callback=None): + def resize(self, shape: tuple, smooth: int = 0, progress_callback=None): if len(shape) != 3: raise Exception("Shape must be 3 dim") data = self.data - order = 0 if self.dtype == bool else 1 # # TXYZC > TXYZ # if self.kind in ['label', 'scalar']: @@ -121,15 +123,22 @@ def resize(self, shape: tuple, progress_callback=None): # shape, # preserve_range=True, # anti_aliasing=data.dtype.kind != "b") + frame = data[f, :, :, :, :] + if smooth > 0: + frame = scipy.median_filter(frame.astype(np.float32), + mode="nearest", + size=smooth) factors = np.divide(self.shape, shape) zoom_factors = [1 / f for f in factors] - frame = ndi.zoom(data[f, :, :, :, :], + order = 0 if frame.dtype == bool else 1 + frame = ndi.zoom(frame, zoom_factors+[1.0], mode="nearest", grid_mode=False, order=order) - + if smooth > 0: + frame = frame.astype(self.dtype) data_frames += (frame,) data = np.stack(data_frames) diff --git a/bioxelnodes/bioxel/parse.py b/bioxelnodes/bioxel/parse.py index f2b258f..556b035 100644 --- a/bioxelnodes/bioxel/parse.py +++ b/bioxelnodes/bioxel/parse.py @@ -1,6 +1,5 @@ from pathlib import Path import numpy as np -from .layer import Layer # 3rd-party import SimpleITK as sitk @@ -166,7 +165,7 @@ def remove_end_str(string: str, end: str): return string -def parse_volumetric_data(data_file: str, series_id="", progress_callback=None) -> Layer: +def parse_volumetric_data(data_file: str, series_id="", progress_callback=None): """Parse any volumetric data to numpy with shap (T,X,Y,Z,C) Args: diff --git a/bioxelnodes/bioxelutils/utils.py b/bioxelnodes/bioxelutils/common.py similarity index 51% rename from bioxelnodes/bioxelutils/utils.py rename to bioxelnodes/bioxelutils/common.py index 153f149..e2d2a4e 100644 --- a/bioxelnodes/bioxelutils/utils.py +++ b/bioxelnodes/bioxelutils/common.py @@ -1,5 +1,10 @@ +from ast import literal_eval +from pathlib import Path import bpy +from ..constants import NODE_LIB_FILEPATH, VERSION +from ..utils import get_cache_dir + def move_node_to_node(node, target_node, offset=(0, 0)): node.location.x = target_node.location.x + offset[0] @@ -48,20 +53,65 @@ def get_container_obj(current_obj): return None -def get_layer_prop_value(layer_obj: bpy.types.Object, prop: str): +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] - value = layer_node.inputs[prop].default_value + 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) - if type(value).__name__ == "float": + 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): @@ -95,6 +145,7 @@ def get_all_layer_objs(): return layer_objs + def add_driver(target, target_prop, var_sources, expression): driver = target.driver_add(target_prop) is_vector = isinstance(driver, list) @@ -125,4 +176,70 @@ def add_direct_driver(target, target_prop, source, source_prop): } ] expression = "var0" - add_driver(target, target_prop, drivers, expression) \ No newline at end of file + 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 is_incompatible(): + if get_file_prop("addon_version") is None: + for node_group in bpy.data.node_groups: + if node_group.name.startswith("BioxelNodes"): + return True + else: + addon_version = literal_eval(get_file_prop("addon_version")) + if addon_version[0] != VERSION[0]\ + or addon_version[1] != VERSION[1]: + return True + + return False + +def local_lib_not_updated(): + use_local = False + for node_group in bpy.data.node_groups: + if node_group.name.startswith("BioxelNodes"): + node_group_lib = node_group.library + if node_group_lib: + abs_filepath = bpy.path.abspath(node_group_lib.filepath) + _local_lib_file = Path(abs_filepath).resolve().as_posix() + if _local_lib_file != NODE_LIB_FILEPATH.as_posix(): + use_local = True + break + + addon_version = literal_eval(get_file_prop("addon_version")) + not_update = addon_version != VERSION + return use_local and not_update \ No newline at end of file diff --git a/bioxelnodes/bioxelutils/container.py b/bioxelnodes/bioxelutils/container.py index b953fb7..3be1f4c 100644 --- a/bioxelnodes/bioxelutils/container.py +++ b/bioxelnodes/bioxelutils/container.py @@ -6,12 +6,12 @@ from .layer import Layer, layer_to_obj, obj_to_layer -from ..nodes import custom_nodes -from .utils import (get_container_layer_objs, - get_layer_prop_value, - get_nodes_by_type, - move_node_to_node) - +from .common import (get_container_layer_objs, + get_layer_prop_value, + get_nodes_by_type, + move_node_to_node) +from .node import add_node_to_graph +from ..utils import get_use_link NODE_TYPE = { "label": "BioxelNodes_MaskByLabel", @@ -82,13 +82,17 @@ def add_layers(layers: list[Layer], cache_dir: str): node_group = container_obj.modifiers[0].node_group - output_node = get_nodes_by_type(node_group, - 'NodeGroupOutput')[0] + try: + output_node = get_nodes_by_type(node_group, + 'NodeGroupOutput')[0] + except: + output_node = node_group.nodes.new("NodeGroupOutput") for i, layer in enumerate(layers): layer_obj = layer_to_obj(layer, container_obj, cache_dir) - fetch_node = custom_nodes.add_node(node_group, - "BioxelNodes_FetchLayer") + fetch_node = add_node_to_graph("FetchLayer", + node_group, + get_use_link()) fetch_node.label = get_layer_prop_value(layer_obj, "name") fetch_node.inputs[0].default_value = layer_obj @@ -104,6 +108,7 @@ def add_layers(layers: list[Layer], def container_to_obj(container: Container, scene_scale: float, + step_size: float, cache_dir: str): # Wrapper a Container @@ -134,11 +139,26 @@ def container_to_obj(container: Container, container_obj.name = container.name container_obj.data.name = container.name container_obj.show_in_front = True + + container_obj.lock_location[0] = True + container_obj.lock_location[1] = True + container_obj.lock_location[2] = True + + container_obj.lock_rotation[0] = True + container_obj.lock_rotation[1] = True + container_obj.lock_rotation[2] = True + + container_obj.lock_scale[0] = True + container_obj.lock_scale[1] = True + container_obj.lock_scale[2] = True + container_obj['bioxel_container'] = True + container_obj["scene_scale"] = scene_scale + container_obj["step_size"] = step_size modifier = container_obj.modifiers.new("GeometryNodes", 'NODES') node_group = bpy.data.node_groups.new('GeometryNodes', 'GeometryNodeTree') - node_group.interface.new_socket(name="Component", + node_group.interface.new_socket(name="Output", in_out="OUTPUT", socket_type="NodeSocketGeometry") modifier.node_group = node_group diff --git a/bioxelnodes/bioxelutils/layer.py b/bioxelnodes/bioxelutils/layer.py index 587a9f3..14a0df7 100644 --- a/bioxelnodes/bioxelutils/layer.py +++ b/bioxelnodes/bioxelutils/layer.py @@ -7,10 +7,11 @@ from pathlib import Path from uuid import uuid4 -from ..nodes import custom_nodes from ..bioxel.layer import Layer -from .utils import (get_layer_prop_value, - move_node_between_nodes, add_direct_driver) +from ..utils import get_use_link +from .node import add_node_to_graph +from .common import (get_layer_prop_value, + move_node_between_nodes) def obj_to_layer(layer_obj: bpy.types.Object): @@ -26,7 +27,11 @@ def obj_to_layer(layer_obj: bpy.types.Object): grids, base_metadata = vdb.readAll(str(f)) grid = grids[0] metadata = grid.metadata - data_frame = np.ndarray(grid["data_shape"], np.float32) + if grid["layer_kind"] in ['label', 'scalar']: + data_shape = grid["data_shape"] + else: + data_shape = tuple(list(grid["data_shape"]) + [3]) + data_frame = np.ndarray(data_shape, np.float32) grid.copyToArray(data_frame) data_frames += (data_frame,) data = np.stack(data_frames) @@ -34,7 +39,11 @@ def obj_to_layer(layer_obj: bpy.types.Object): grids, base_metadata = vdb.readAll(str(cache_filepath)) grid = grids[0] metadata = grid.metadata - data = np.ndarray(grid["data_shape"], np.float32) + if grid["layer_kind"] in ['label', 'scalar']: + data_shape = grid["data_shape"] + else: + data_shape = tuple(list(grid["data_shape"]) + [3]) + data = np.ndarray(data_shape, np.float32) grid.copyToArray(data) data = np.expand_dims(data, axis=0) # expend frame @@ -137,13 +146,12 @@ def layer_to_obj(layer: Layer, layer_data = bpy.data.volumes.new(layer_display_name) layer_data.render.space = 'WORLD' - layer_data.render.step_size = container_obj.scale[0] * layer.bioxel_size[0] + scene_scale = container_obj.get("scene_scale") or 0.01 + step_size = container_obj.get("step_size") or 1 + layer_data.render.step_size = scene_scale * step_size layer_data.sequence_mode = 'REPEAT' layer_data.filepath = str(cache_filepaths[0]) - # add_direct_driver(layer_data, "render.step_size", - # container_obj, "scale[0]") - if layer.frame_count > 1: layer_data.is_sequence = True layer_data.frame_duration = layer.frame_count @@ -164,8 +172,7 @@ def layer_to_obj(layer: Layer, socket_type="NodeSocketGeometry") modifier.node_group = node_group - layer_node = custom_nodes.add_node(node_group, - "BioxelNodes__Layer") + layer_node = add_node_to_graph("_Layer", node_group, get_use_link()) layer_node.inputs['name'].default_value = layer.name layer_node.inputs['shape'].default_value = layer.shape @@ -176,11 +183,12 @@ def layer_to_obj(layer: Layer, affine_key = f"affine{i}{j}" layer_node.inputs[affine_key].default_value = layer.affine[j, i] - layer_node.inputs['id'].default_value = random.randint(-200000000, - 200000000) + layer_node.inputs['unique'].default_value = random.uniform(0, 1) layer_node.inputs['bioxel_size'].default_value = layer.bioxel_size[0] layer_node.inputs['dtype'].default_value = layer.dtype.str layer_node.inputs['dtype_num'].default_value = layer.dtype.num + layer_node.inputs['frame_count'].default_value = layer.frame_count + layer_node.inputs['channel_count'].default_value = layer.channel_count layer_node.inputs['offset'].default_value = max(0, -layer.min) layer_node.inputs['min'].default_value = layer.min layer_node.inputs['max'].default_value = layer.max diff --git a/bioxelnodes/bioxelutils/node.py b/bioxelnodes/bioxelutils/node.py new file mode 100644 index 0000000..7279116 --- /dev/null +++ b/bioxelnodes/bioxelutils/node.py @@ -0,0 +1,68 @@ +from pathlib import Path +import shutil +import bpy + +from .common import get_file_prop, set_file_prop +from ..exceptions import Incompatible, NoFound + +from ..constants import NODE_LIB_FILEPATH, VERSION + + +def get_node_group(node_type: str, use_link=True): + # unannotate below for local debug in node lib file. + # node_group = bpy.data.node_groups[node_type] + # return node_group + + if get_file_prop("addon_version") is None: + set_file_prop("addon_version", VERSION) + + local_lib_file = None + addon_lib_file = NODE_LIB_FILEPATH.as_posix() + + for node_group in bpy.data.node_groups: + if node_group.name.startswith("BioxelNodes"): + node_group_lib = node_group.library + if node_group_lib: + abs_filepath = bpy.path.abspath(node_group_lib.filepath) + _local_lib_file = Path(abs_filepath).resolve().as_posix() + if _local_lib_file != addon_lib_file: + local_lib_file = _local_lib_file + break + + # local lib first + lib_file = local_lib_file or addon_lib_file + bpy.ops.wm.append('EXEC_DEFAULT', + directory=f"{lib_file}/NodeTree", + filename=node_type, + link=use_link, + use_recursive=True, + do_reuse_local_id=True) + node_group = bpy.data.node_groups.get(node_type) + + if node_group is None: + raise NoFound('No custom node found') + + return node_group + + +def assign_node_group(node, node_type: str): + node.node_tree = bpy.data.node_groups[node_type] + node.width = 200.0 + node.name = node_type + return node + + +def add_node_to_graph(node_name: str, node_group, use_link=True): + node_type = f"BioxelNodes_{node_name}" + + # Deselect all nodes first + for node in node_group.nodes: + if node.select: + node.select = False + + get_node_group(node_type, use_link) + node = node_group.nodes.new("GeometryNodeGroup") + assign_node_group(node, node_type) + + node.show_options = False + return node diff --git a/bioxelnodes/blender_manifest.toml b/bioxelnodes/blender_manifest.toml index 2e2f504..a166bb9 100644 --- a/bioxelnodes/blender_manifest.toml +++ b/bioxelnodes/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "bioxelnodes" -version = "0.4.0" +version = "1.0.0" name = "Bioxel Nodes" tagline = "For scientific volumetric data visualization in Blender" maintainer = "Ma Nan " diff --git a/bioxelnodes/nodes.py b/bioxelnodes/constants.py similarity index 50% rename from bioxelnodes/nodes.py rename to bioxelnodes/constants.py index bd94e93..e93018e 100644 --- a/bioxelnodes/nodes.py +++ b/bioxelnodes/constants.py @@ -1,8 +1,9 @@ - from pathlib import Path -from .customnodes import CustomNodes -NODE_FILE = "BioxelNodes_4.2" +VERSION = (1, 0, 0) +NODE_LIB_FILENAME = "BioxelNodes_v1.0.x" +NODE_LIB_FILEPATH = Path(Path(__file__).parent, + f"assets/Nodes/{NODE_LIB_FILENAME}.blend").resolve() MENU_ITEMS = [ { @@ -12,34 +13,40 @@ { 'label': 'Cutout by Threshold', 'icon': 'EMPTY_SINGLE_ARROW', - 'node_type': 'BioxelNodes_CutoutByThreshold', - 'node_description': '' + 'name': 'CutoutByThreshold', + 'description': '' }, { 'label': 'Cutout by Range', 'icon': 'IPO_CONSTANT', - 'node_type': 'BioxelNodes_CutoutByRange', - 'node_description': '' + 'name': 'CutoutByRange', + 'description': '' }, { 'label': 'Cutout by Color', 'icon': 'COLOR', - 'node_type': 'BioxelNodes_CutoutByColor', - 'node_description': '' + 'name': 'CutoutByColor', + 'description': '' }, "separator", { 'label': 'To Surface', 'icon': 'MESH_DATA', - 'node_type': 'BioxelNodes_ToSurface', - 'node_description': '' + 'name': 'ToSurface', + 'description': '' }, { 'label': 'Join Component', 'icon': 'CONSTRAINT_BONE', - 'node_type': 'BioxelNodes_JoinComponent', - 'node_description': '' + 'name': 'JoinComponent', + 'description': '' }, + { + 'label': 'Slice', + 'icon': 'TEXTURE', + 'name': 'Slice', + 'description': '' + } ] }, { @@ -49,59 +56,46 @@ { 'label': 'Set Properties', 'icon': 'PROPERTIES', - 'node_type': 'BioxelNodes_SetProperties', - 'node_description': '' + 'name': 'SetProperties', + 'description': '' }, "separator", { 'label': 'Set Color', 'icon': 'IPO_SINE', - 'node_type': 'BioxelNodes_SetColor', - 'node_description': '' + 'name': 'SetColor', + 'description': '' }, { 'label': 'Set Color by Color', 'icon': 'IPO_QUINT', - 'node_type': 'BioxelNodes_SetColorByColor', - 'node_description': '' + 'name': 'SetColorByColor', + 'description': '' }, { 'label': 'Set Color by Ramp 2', 'icon': 'IPO_QUAD', - 'node_type': 'BioxelNodes_SetColorByRamp2', - 'node_description': '' + 'name': 'SetColorByRamp2', + 'description': '' }, { 'label': 'Set Color by Ramp 3', 'icon': 'IPO_CUBIC', - 'node_type': 'BioxelNodes_SetColorByRamp3', - 'node_description': '' + 'name': 'SetColorByRamp3', + 'description': '' }, { 'label': 'Set Color by Ramp 4', 'icon': 'IPO_QUART', - 'node_type': 'BioxelNodes_SetColorByRamp4', - 'node_description': '' + 'name': 'SetColorByRamp4', + 'description': '' }, { 'label': 'Set Color by Ramp 5', 'icon': 'IPO_QUINT', - 'node_type': 'BioxelNodes_SetColorByRamp5', - 'node_description': '' - }, - # "separator", - # { - # 'label': 'Color Presets', - # 'icon': 'COLOR', - # 'node_type': 'BioxelNodes_ColorPresets', - # 'node_description': '' - # } - # { - # 'label': 'Color Presets MRI', - # 'icon': 'COLOR', - # 'node_type': 'BioxelNodes_ColorPresets_MRI', - # 'node_description': '' - # }, + 'name': 'SetColorByRamp5', + 'description': '' + } ] }, { @@ -111,39 +105,45 @@ { 'label': 'Membrane Shader', 'icon': 'NODE_MATERIAL', - 'node_type': 'BioxelNodes_AssignMembraneShader', - 'node_description': '' + 'name': 'AssignMembraneShader', + 'description': '' }, { 'label': 'Solid Shader', 'icon': 'SHADING_SOLID', - 'node_type': 'BioxelNodes_AssignSolidShader', - 'node_description': '' + 'name': 'AssignSolidShader', + 'description': '' }, { 'label': 'Slime Shader', 'icon': 'OUTLINER_DATA_META', - 'node_type': 'BioxelNodes_AssignSlimeShader', - 'node_description': '' + 'name': 'AssignSlimeShader', + 'description': '' }, - "separator", + + ] + }, + { + 'label': 'Transform', + 'icon': 'EMPTY_AXIS', + 'items': [ { - 'label': 'Inflate', - 'icon': 'OUTLINER_OB_META', - 'node_type': 'BioxelNodes_M_Inflate', - 'node_description': '' + 'label': 'Transform', + 'icon': 'EMPTY_AXIS', + 'name': 'Transform', + 'description': '' }, { - 'label': 'Smooth', - 'icon': 'MOD_SMOOTH', - 'node_type': 'BioxelNodes_M_Smooth', - 'node_description': '' + 'label': 'Transform Parent', + 'icon': 'ORIENTATION_PARENT', + 'name': 'TransformParent', + 'description': '' }, { - 'label': 'Remove Small Island', - 'icon': 'FORCE_LENNARDJONES', - 'node_type': 'BioxelNodes_M_RemoveSmallIsland', - 'node_description': '' + 'label': 'ReCenter', + 'icon': 'PROP_CON', + 'name': 'ReCenter', + 'description': '' } ] }, @@ -154,21 +154,21 @@ { 'label': 'Cut', 'icon': 'MOD_BEVEL', - 'node_type': 'BioxelNodes_Cut', - 'node_description': '' + 'name': 'Cut', + 'description': '' }, "separator", { 'label': 'Primitive Cutter', 'icon': 'MOD_LINEART', - 'node_type': 'BioxelNodes_PrimitiveCutter', - 'node_description': '' + 'name': 'PrimitiveCutter', + 'description': '' }, { 'label': 'Object Cutter', 'icon': 'MESH_PLANE', - 'node_type': 'BioxelNodes_ObjectCutter', - 'node_description': '' + 'name': 'ObjectCutter', + 'description': '' } ] }, @@ -177,41 +177,48 @@ 'icon': 'MODIFIER', 'items': [ { - 'label': 'Pick Mesh', + 'label': 'Pick Surface', 'icon': 'OUTLINER_OB_MESH', - 'node_type': 'BioxelNodes_PickMesh', - 'node_description': '' + 'name': 'PickSurface', + 'description': '' }, { 'label': 'Pick Volume', 'icon': 'OUTLINER_OB_VOLUME', - 'node_type': 'BioxelNodes_PickVolume', - 'node_description': '' + 'name': 'PickVolume', + 'description': '' + }, + { + 'label': 'Pick Shape Wire', + 'icon': 'FILE_VOLUME', + 'name': 'PickShapeWire', + 'description': '' }, { 'label': 'Pick Bbox Wire', 'icon': 'MESH_CUBE', - 'node_type': 'BioxelNodes_PickBboxWire', - 'node_description': '' + 'name': 'PickBboxWire', + 'description': '' + }, + "separator", + { + 'label': 'Inflate', + 'icon': 'OUTLINER_OB_META', + 'name': 'Inflate', + 'description': '' + }, + { + 'label': 'Smooth', + 'icon': 'MOD_SMOOTH', + 'name': 'Smooth', + 'description': '' + }, + { + 'label': 'Remove Small Island', + 'icon': 'FORCE_LENNARDJONES', + 'name': 'RemoveSmallIsland', + 'description': '' } - ] } ] - - -custom_nodes = CustomNodes( - menu_items=MENU_ITEMS, - nodes_file=Path(__file__).parent / f"assets/Nodes/{NODE_FILE}.blend", - class_prefix="BIOXELNODES_MT_NODES", - root_label='Bioxel Nodes', - root_icon="FILE_VOLUME", -) - - -def register(): - custom_nodes.register() - - -def unregister(): - custom_nodes.unregister() diff --git a/bioxelnodes/customnodes/__init__.py b/bioxelnodes/customnodes/__init__.py deleted file mode 100644 index bfad98c..0000000 --- a/bioxelnodes/customnodes/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .menus import CustomNodes \ No newline at end of file diff --git a/bioxelnodes/customnodes/menus.py b/bioxelnodes/customnodes/menus.py deleted file mode 100644 index afe1c7a..0000000 --- a/bioxelnodes/customnodes/menus.py +++ /dev/null @@ -1,147 +0,0 @@ -import bpy -from pathlib import Path -from .nodes import AddCustomNode - - -class CustomNodes(): - def __init__( - self, - menu_items, - nodes_file, - class_prefix="CUSTOMNODES_MT_NODES", - root_label='CustomNodes', - root_icon='NONE' - ) -> None: - if not Path(nodes_file).is_file(): - raise FileNotFoundError(str(Path(nodes_file).resolve())) - - self.menu_items = menu_items - self.nodes_file = str(Path(nodes_file).resolve()) - self.class_prefix = class_prefix - self.root_label = root_label - self.root_icon = root_icon - self.class_prefix = class_prefix - - menu_classes = [] - self._create_menu_class( - items=menu_items, - label=root_label, - menu_classes=menu_classes - ) - self.menu_classes = menu_classes - - idname = f"{class_prefix}_{root_label.replace(' ', '').upper()}" - - def add_node_menu(self, context): - if ('GeometryNodeTree' == bpy.context.area.spaces[0].tree_type): - layout = self.layout - layout.separator() - layout.menu(idname, icon=root_icon) - - self.add_node_menu = add_node_menu - - def _create_menu_class(self, menu_classes, items, label='CustomNodes', icon=0, idname_namespace=None): - nodes_file = self.nodes_file - idname_namespace = idname_namespace or self.class_prefix - idname = f"{idname_namespace}_{label.replace(' ', '').upper()}" - - # create submenu class if item is menu. - for item in items: - item_items = item.get('items') if item != 'separator' else None - if item_items: - menu_class = self._create_menu_class( - menu_classes=menu_classes, - items=item_items, - label=item.get('label') or 'CustomNodes', - icon=item.get('icon') or 0, - idname_namespace=idname - ) - item['menu_class'] = menu_class - - # create menu class - class Menu(bpy.types.Menu): - bl_idname = idname - bl_label = label - nodes_file = self.nodes_file - - def draw(self, context): - layout = self.layout - - for item in items: - # print(item) - if item == "separator": - layout.separator() - elif item.get('menu_class'): - layout.menu( - item.get('menu_class').bl_idname, - icon=item.get('icon') or 'NONE' - ) - else: - op = layout.operator( - 'customnodes.add_custom_node', - text=item.get('label'), - icon=item.get('icon') or 'NONE' - ) - op.nodes_file = nodes_file - op.node_type = item['node_type'] - op.node_label = item.get('label') or "" - op.node_description = item.get( - 'node_description') or "Add Custom Node." - op.node_link = item.get('link') or True - op.node_callback = item.get('node_callback') or "" - - menu_classes.append(Menu) - return Menu - - def _find_item(self, found_items, menu_items, node_type: str): - - for item in menu_items: - if item == 'separator': - continue - - if item.get("node_type") == node_type: - found_items.append(item) - - item_items = item.get('items') - if item_items: - self._find_item(found_items, item_items, node_type) - - def find_item(self, node_type: str): - found_items = [] - self._find_item(found_items, self.menu_items, node_type) - return found_items[0] if len(found_items) > 0 else None - - def add_node(self, node_group, node_type: str): - item = self.find_item(node_type) - op = AddCustomNode() - op.nodes_file = self.nodes_file - op.node_type = node_type - if item: - op.node_label = item.get('label') or "" - op.node_link = item.get('link') or True - op.node_callback = item.get('node_callback') or "" - else: - op.node_label = "" - op.node_link = True - op.node_callback = "" - return op.add_node(node_group) - - def register(self): - for cls in self.menu_classes: - bpy.utils.register_class(cls) - - bpy.types.NODE_MT_add.append(self.add_node_menu) - bpy.types.Scene.customnodes_node_dir = bpy.props.StringProperty( - name="Node Directory", - subtype='DIR_PATH', - default="//" - ) - - def unregister(self): - bpy.types.NODE_MT_add.remove(self.add_node_menu) - try: - for cls in reversed(self.menu_classes): - bpy.utils.unregister_class(cls) - - except RuntimeError: - pass diff --git a/bioxelnodes/customnodes/nodes.py b/bioxelnodes/customnodes/nodes.py deleted file mode 100644 index ee8cd6d..0000000 --- a/bioxelnodes/customnodes/nodes.py +++ /dev/null @@ -1,117 +0,0 @@ -import bpy - - -class AddCustomNode(): - - nodes_file: bpy.props.StringProperty( - name="nodes_file", - subtype='FILE_PATH', - default="" - ) # type: ignore - - node_type: bpy.props.StringProperty( - name='node_type', - description='', - default='', - subtype='NONE', - maxlen=0 - ) # type: ignore - - node_label: bpy.props.StringProperty( - name='node_label', - default='' - ) # type: ignore - - node_description: bpy.props.StringProperty( - name="node_description", - description="", - default="", - subtype="NONE" - ) # type: ignore - - node_link: bpy.props.BoolProperty( - name='node_link', - default=True - ) # type: ignore - - node_callback: bpy.props.StringProperty( - name='node_callback', - default='' - ) # type: ignore - - @classmethod - def description(cls, context, properties): - return properties.node_description - - def assign_node_tree(self, node): - node.node_tree = bpy.data.node_groups[self.node_type] - - node.width = 200.0 - node.label = self.node_label or self.node_type - node.name = self.node_type - - if self.node_callback: - exec(self.node_callback) - - return node - - def get_node_tree(self, node_type, node_link): - # try to get node from current file if exists - node_tree = bpy.data.node_groups.get(node_type) - # if not exists, get it from asset file. - if not node_tree: - bpy.ops.wm.append( - 'EXEC_DEFAULT', - directory=f"{self.nodes_file}/NodeTree", - filename=node_type, - link=node_link, - use_recursive=True - ) - - node_tree = bpy.data.node_groups.get(node_type) - if node_tree: - return node_tree - else: - raise RuntimeError('No custom node found') - - - def add_node(self, node_group): - # Deselect all nodes first - for node in node_group.nodes: - if node.select: - node.select = False - - self.get_node_tree(self.node_type, self.node_link) - node = node_group.nodes.new("GeometryNodeGroup") - self.assign_node_tree(node) - node.show_options = False - - return node - - -class CUSTOMNODES_OT_Add_Custom_Node(bpy.types.Operator, AddCustomNode): - bl_idname = "customnodes.add_custom_node" - bl_label = "Add Custom Node" - bl_options = {"REGISTER", "UNDO"} - - def execute(self, context): - self.get_node_tree(self.node_type, self.node_link) - - # intended to be called upon button press in the node tree - prev_context = bpy.context.area.type - bpy.context.area.type = 'NODE_EDITOR' - # actually invoke the operator to add a node to the current node tree - # use_transform=True ensures it appears where the user's mouse is and is currently - # being moved so the user can place it where they wish - bpy.ops.node.add_node( - 'INVOKE_DEFAULT', - type='GeometryNodeGroup', - use_transform=True - ) - bpy.context.area.type = prev_context - node = bpy.context.active_node - - self.assign_node_tree(node) - node.show_options = False - - return {"FINISHED"} diff --git a/bioxelnodes/exceptions.py b/bioxelnodes/exceptions.py index f1846c0..d2de041 100644 --- a/bioxelnodes/exceptions.py +++ b/bioxelnodes/exceptions.py @@ -2,3 +2,21 @@ class CancelledByUser(Exception): def __init__(self): message = 'Cancelled by user' super().__init__(message) + + +class NoContent(Exception): + def __init__(self, message): + super().__init__(message) + self.message = message + + +class NoFound(Exception): + def __init__(self, message): + super().__init__(message) + self.message = message + + +class Incompatible(Exception): + def __init__(self, message): + super().__init__(message) + self.message = message diff --git a/bioxelnodes/menus.py b/bioxelnodes/menus.py index 70715ae..a120844 100644 --- a/bioxelnodes/menus.py +++ b/bioxelnodes/menus.py @@ -1,46 +1,70 @@ -from pathlib import Path import bpy -from .operators.utils import get_layer_item_label -from .bioxelutils.utils import (get_container_obj, - get_container_objs_from_selection, - get_container_layer_objs, - get_layer_prop_value) -from .operators.layer import (FetchLayerMenu, FetchLayer, - RemoveLayer, RemoveMissingLayers, RenameLayer, ResampleScalar, SaveLayerCache, - SignScalar, CombineLabels, - FillByLabel, FillByThreshold, FillByRange, get_selected_objs_in_node_tree) -from .operators.container import (SaveAllLayerCaches, SaveContainer, LoadContainer, +from .constants import MENU_ITEMS, NODE_LIB_FILENAME +from .node_menu import NodeMenu + +from .bioxelutils.common import (get_container_obj, + get_container_layer_objs, get_layer_label, + get_layer_prop_value) +from .operators.layer import (FetchLayer, RelocateLayer, RetimeLayer, RenameLayer, + RemoveSelectedLayers, SaveSelectedLayersCache, + ResampleLayer, SignScalar, CombineLabels, + FillByLabel, FillByThreshold, FillByRange) +from .operators.container import (AddLocator, AddSlicer, ExtractShapeWire, + SaveContainerLayersCache, RemoveContainerMissingLayers, + SaveContainer, LoadContainer, AddPieCutter, AddPlaneCutter, AddCylinderCutter, AddCubeCutter, AddSphereCutter, - PickBboxWire, PickMesh, PickVolume, ScaleContainer) + ExtractBboxWire, ExtractSurface, ExtractVolume, ContainerProps) + from .operators.io import (ImportAsLabel, ImportAsScalar, ImportAsColor) -from .operators.misc import (CleanAllCaches, - ReLinkNodes, RenderSettingPreset, SaveStagedData, SliceViewer) +from .operators.misc import (CleanTemp, + ReLinkNodeLib, RemoveAllMissingLayers, RenderSettingPreset, SaveAllLayersCache, SaveNodeLib, SliceViewer) + + +class FetchLayerMenu(bpy.types.Menu): + bl_idname = "BIOXELNODES_MT_ADD_LAYER" + bl_label = "Fetch Layer" + + def draw(self, context): + container_obj = get_container_obj(bpy.context.active_object) + layer_objs = get_container_layer_objs(container_obj) + layout = self.layout + + for layer_obj in layer_objs: + op = layout.operator(FetchLayer.bl_idname, + text=get_layer_label(layer_obj)) + op.layer_obj_name = layer_obj.name -class PickFromContainerMenu(bpy.types.Menu): +class ExtractFromContainerMenu(bpy.types.Menu): bl_idname = "BIOXELNODES_MT_PICK" - bl_label = "Pick from Container" + bl_label = "Extract from Container" def draw(self, context): layout = self.layout - layout.operator(PickMesh.bl_idname) - layout.operator(PickVolume.bl_idname) - layout.operator(PickBboxWire.bl_idname) + layout.operator(ExtractSurface.bl_idname) + layout.operator(ExtractVolume.bl_idname) + layout.operator(ExtractShapeWire.bl_idname) + layout.operator(ExtractBboxWire.bl_idname) class AddCutterMenu(bpy.types.Menu): bl_idname = "BIOXELNODES_MT_CUTTERS" - bl_label = "Add a Object Cutter" + bl_label = "Add a Cutter" def draw(self, context): layout = self.layout - layout.operator(AddPlaneCutter.bl_idname, text="Plane Cutter") - layout.operator(AddCylinderCutter.bl_idname, text="Cylinder Cutter") - layout.operator(AddCubeCutter.bl_idname, text="Cube Cutter") - layout.operator(AddSphereCutter.bl_idname, text="Sphere Cutter") - layout.operator(AddPieCutter.bl_idname, text="Pie Cutter") + layout.operator(AddPlaneCutter.bl_idname, + icon=AddPlaneCutter.bl_icon, text="Plane Cutter") + layout.operator(AddCylinderCutter.bl_idname, + icon=AddCylinderCutter.bl_icon, text="Cylinder Cutter") + layout.operator(AddCubeCutter.bl_idname, + icon=AddCubeCutter.bl_icon, text="Cube Cutter") + layout.operator(AddSphereCutter.bl_idname, + icon=AddSphereCutter.bl_icon, text="Sphere Cutter") + layout.operator(AddPieCutter.bl_idname, + icon=AddPieCutter.bl_icon, text="Pie Cutter") class ImportLayerMenu(bpy.types.Menu): @@ -60,7 +84,7 @@ def draw(self, context): class AddLayerMenu(bpy.types.Menu): bl_idname = "BIOXELNODES_MT_ADDLAYER" - bl_label = "Import Volumetric Data (Add to)" + bl_label = "Import Volumetric Data (Add)" bl_icon = "FILE_NEW" def draw(self, context): @@ -79,31 +103,37 @@ class ModifyLayerMenu(bpy.types.Menu): bl_icon = "FILE_NEW" def draw(self, context): - layer_objs = get_selected_objs_in_node_tree(context) - if len(layer_objs) > 0: - active_obj_name = layer_objs[0].name - else: - active_obj_name = "" + layout = self.layout + layout.operator(SignScalar.bl_idname, + icon=SignScalar.bl_icon) + layout.operator(FillByThreshold.bl_idname, + icon=FillByThreshold.bl_icon) + layout.operator(FillByRange.bl_idname, + icon=FillByRange.bl_icon) + layout.operator(FillByLabel.bl_idname, + icon=FillByLabel.bl_icon) + layout.operator(CombineLabels.bl_idname, + icon=CombineLabels.bl_icon) + + +class ReLinkNodeLibMenu(bpy.types.Menu): + bl_idname = "BIOXELNODES_MT_RELINK" + bl_label = "Relink Node Library" + def draw(self, context): layout = self.layout - op = layout.operator(ResampleScalar.bl_idname, - icon=ResampleScalar.bl_icon) - op.layer_obj_name = active_obj_name - op = layout.operator(SignScalar.bl_idname, - icon=SignScalar.bl_icon) - op.layer_obj_name = active_obj_name - op = layout.operator(FillByThreshold.bl_idname, - icon=FillByThreshold.bl_icon) - op.layer_obj_name = active_obj_name - op = layout.operator(FillByRange.bl_idname, - icon=FillByRange.bl_icon) - op.layer_obj_name = active_obj_name - op = layout.operator(FillByLabel.bl_idname, - icon=FillByLabel.bl_icon) - op.layer_obj_name = active_obj_name - op = layout.operator(CombineLabels.bl_idname, - icon=CombineLabels.bl_icon) - op.layer_obj_name = active_obj_name + versions = [{"label": "v0.3.x", "filename": "BioxelNodes_v0.3.x"}, + {"label": "v0.2.x", "filename": "BioxelNodes_v0.2.x"}, + {"label": "v0.1.x", "filename": "BioxelNodes_v0.1.x"}] + + op = layout.operator(ReLinkNodeLib.bl_idname, + text="Current Version") + op.node_lib_filename = NODE_LIB_FILENAME + layout.separator() + for version in versions: + op = layout.operator(ReLinkNodeLib.bl_idname, + text=version["label"]) + op.node_lib_filename = version["filename"] class RenderSettingMenu(bpy.types.Menu): @@ -118,6 +148,15 @@ def draw(self, context): op.preset = k +class DangerZoneMenu(bpy.types.Menu): + bl_idname = "BIOXELNODES_MT_DANGER" + bl_label = "Danger Zone" + + def draw(self, context): + layout = self.layout + layout.operator(CleanTemp.bl_idname) + + class BioxelNodesTopbarMenu(bpy.types.Menu): bl_idname = "BIOXELNODES_MT_TOPBAR" bl_label = "Bioxel Nodes" @@ -130,9 +169,15 @@ def draw(self, context): layout.operator(LoadContainer.bl_idname) layout.separator() - layout.operator(SaveStagedData.bl_idname) - layout.operator(ReLinkNodes.bl_idname) - layout.operator(CleanAllCaches.bl_idname) + layout.operator(SaveAllLayersCache.bl_idname) + layout.operator(RemoveAllMissingLayers.bl_idname) + + layout.separator() + layout.operator(SaveNodeLib.bl_idname) + layout.menu(ReLinkNodeLibMenu.bl_idname) + + layout.separator() + layout.menu(DangerZoneMenu.bl_idname) layout.separator() layout.menu(RenderSettingMenu.bl_idname) @@ -143,51 +188,66 @@ def TOPBAR(self, context): layout.menu(BioxelNodesTopbarMenu.bl_idname) -class BioxelNodesNodeMenu(bpy.types.Menu): - bl_idname = "BIOXELNODES_MT_NODE" +class NodeHeadMenu(bpy.types.Menu): + bl_idname = "BIOXELNODES_MT_NODE_HEAD" bl_label = "Bioxel Nodes" bl_icon = "FILE_VOLUME" def draw(self, context): - layer_objs = get_selected_objs_in_node_tree(context) - if len(layer_objs) > 0: - active_obj_name = layer_objs[0].name - else: - active_obj_name = "" layout = self.layout - layout.separator() layout.menu(AddLayerMenu.bl_idname, icon=AddLayerMenu.bl_icon) - layout.separator() layout.operator(SaveContainer.bl_idname) + + layout.separator() + layout.operator(ContainerProps.bl_idname) + layout.menu(ExtractFromContainerMenu.bl_idname) + layout.separator() - layout.operator(ScaleContainer.bl_idname) + layout.operator(SaveContainerLayersCache.bl_idname, + icon=SaveContainerLayersCache.bl_icon) + layout.operator(RemoveContainerMissingLayers.bl_idname) + + layout.separator() + layout.operator(AddLocator.bl_idname, + icon=AddLocator.bl_icon) + layout.operator(AddSlicer.bl_idname, + icon=AddSlicer.bl_icon) layout.menu(AddCutterMenu.bl_idname) - layout.menu(PickFromContainerMenu.bl_idname) - layout.operator(SaveAllLayerCaches.bl_idname, - icon=SaveAllLayerCaches.bl_icon) - layout.operator(RemoveMissingLayers.bl_idname, - icon=RemoveMissingLayers.bl_icon) layout.separator() layout.menu(FetchLayerMenu.bl_idname) - op = layout.operator(SaveLayerCache.bl_idname, - icon=SaveLayerCache.bl_icon) - op.layer_obj_name = active_obj_name - op = layout.operator(RenameLayer.bl_idname, - icon=RenameLayer.bl_icon) - op.layer_obj_name = active_obj_name - op = layout.operator(RemoveLayer.bl_idname, - icon=RemoveLayer.bl_icon) - op.layer_obj_name = active_obj_name + +class NodeContextMenu(bpy.types.Menu): + bl_idname = "BIOXELNODES_MT_NODE_CONTEXT" + bl_label = "Bioxel Nodes" + bl_icon = "FILE_VOLUME" + + def draw(self, context): + layout = self.layout + layout.operator(SaveSelectedLayersCache.bl_idname, + icon=SaveSelectedLayersCache.bl_icon) + layout.operator(RemoveSelectedLayers.bl_idname) + + layout.separator() + layout.operator(RenameLayer.bl_idname, + icon=RenameLayer.bl_icon) + layout.operator(RetimeLayer.bl_idname) + layout.operator(RelocateLayer.bl_idname) layout.separator() - layout.menu(ModifyLayerMenu.bl_idname) + layout.operator(ResampleLayer.bl_idname, + icon=ResampleLayer.bl_icon) + layout.operator(SignScalar.bl_idname) + layout.operator(FillByThreshold.bl_idname) + layout.operator(FillByRange.bl_idname) + layout.operator(FillByLabel.bl_idname) + layout.operator(CombineLabels.bl_idname) -def NODE(self, context): +def NODE_CONTEXT(self, context): container_obj = context.object is_geo_nodes = context.area.ui_type == "GeometryNodeTree" is_container = get_container_obj(container_obj) @@ -197,10 +257,23 @@ def NODE(self, context): layout = self.layout layout.separator() - layout.menu(BioxelNodesNodeMenu.bl_idname) + layout.menu(NodeContextMenu.bl_idname) -def NODE_PT(self, context): +def NODE_HEAD(self, context): + container_obj = context.object + is_geo_nodes = context.area.ui_type == "GeometryNodeTree" + is_container = get_container_obj(container_obj) + + if not is_geo_nodes or not is_container: + return + + layout = self.layout + layout.separator() + layout.menu(NodeHeadMenu.bl_idname) + + +def NODE_PROP(self, context): container_obj = context.object is_geo_nodes = context.area.ui_type == "GeometryNodeTree" is_container = get_container_obj(container_obj) @@ -216,20 +289,19 @@ def NODE_PT(self, context): layer_list_UL = bpy.context.window_manager.bioxelnodes_layer_list_UL layer_list = layer_list_UL.layer_list - layer_list_active = layer_list_UL.layer_list_active layer_list.clear() for layer_obj in get_container_layer_objs(container_obj): layer_item = layer_list.add() - layer_item.label = get_layer_item_label(context, layer_obj) + layer_item.label = get_layer_label(layer_obj) layer_item.obj_name = layer_obj.name layer_item.info_text = "\n".join([f"{prop}: {get_layer_prop_value(layer_obj, prop)}" - for prop in ["kind", "bioxel_size", "shape", "min", "max", ]]) - - if len(layer_list) > 0 and layer_list_active != -1 and layer_list_active < len(layer_list): - active_obj_name = layer_list[layer_list_active].obj_name - else: - active_obj_name = "" + for prop in ["kind", + "bioxel_size", + "shape", + "frame_count", + "channel_count", + "min", "max"]]) layout = self.layout layout.label(text="Layer List") @@ -243,48 +315,41 @@ def NODE_PT(self, context): item_dyntip_propname="info_text", rows=20) - sidebar = split.column(align=True) - sidebar.menu(AddLayerMenu.bl_idname, - icon=AddLayerMenu.bl_icon, text="") - - sidebar.operator(SaveAllLayerCaches.bl_idname, - icon=SaveAllLayerCaches.bl_icon, text="") - sidebar.operator(RemoveMissingLayers.bl_idname, - icon=RemoveMissingLayers.bl_icon, text="") - - sidebar.separator() - op = sidebar.operator(FetchLayer.bl_idname, - icon=FetchLayer.bl_icon, text="") - op.layer_obj_name = active_obj_name - op = sidebar.operator(SaveLayerCache.bl_idname, - icon=SaveLayerCache.bl_icon, text="") - op.layer_obj_name = active_obj_name - op = sidebar.operator(RenameLayer.bl_idname, - icon=RenameLayer.bl_icon, text="") - op.layer_obj_name = active_obj_name - op = sidebar.operator(RemoveLayer.bl_idname, - icon=RemoveLayer.bl_icon, text="") - op.layer_obj_name = active_obj_name - - sidebar.separator() - op = sidebar.operator(ResampleScalar.bl_idname, - icon=ResampleScalar.bl_icon, text="") - op.layer_obj_name = active_obj_name - op = sidebar.operator(SignScalar.bl_idname, - icon=SignScalar.bl_icon, text="") - op.layer_obj_name = active_obj_name - op = sidebar.operator(FillByThreshold.bl_idname, - icon=FillByThreshold.bl_icon, text="") - op.layer_obj_name = active_obj_name - op = sidebar.operator(FillByRange.bl_idname, - icon=FillByRange.bl_icon, text="") - op.layer_obj_name = active_obj_name - op = sidebar.operator(FillByLabel.bl_idname, - icon=FillByLabel.bl_icon, text="") - op.layer_obj_name = active_obj_name - - sidebar.separator() - layout.separator() + # sidebar = split.column(align=True) + # sidebar.menu(AddLayerMenu.bl_idname, + # icon=AddLayerMenu.bl_icon, text="") + + # sidebar.operator(SaveContainerLayersCache.bl_idname, + # icon=SaveContainerLayersCache.bl_icon, text="") + # sidebar.operator(RemoveContainerMissingLayers.bl_idname, + # icon=RemoveContainerMissingLayers.bl_icon, text="") + + # sidebar.separator() + # sidebar.operator(SaveSelectedLayersCache.bl_idname, + # icon=SaveSelectedLayersCache.bl_icon, text="") + # sidebar.operator(RemoveSelectedLayers.bl_idname, + # icon=RemoveSelectedLayers.bl_icon, text="") + + # sidebar.separator() + # sidebar.operator(RenameLayer.bl_idname, + # icon=RenameLayer.bl_icon, text="") + # sidebar.operator(ResampleLayer.bl_idname, + # icon=ResampleLayer.bl_icon, text="") + # sidebar.operator(RetimeLayer.bl_idname, + # icon=RetimeLayer.bl_icon, text="") + + # sidebar.separator() + # sidebar.operator(SignScalar.bl_idname, + # icon=SignScalar.bl_icon, text="") + # sidebar.operator(FillByThreshold.bl_idname, + # icon=FillByThreshold.bl_icon, text="") + # sidebar.operator(FillByRange.bl_idname, + # icon=FillByRange.bl_icon, text="") + # sidebar.operator(FillByLabel.bl_idname, + # icon=FillByLabel.bl_icon, text="") + + # sidebar.separator() + # layout.separator() def VIEW3D_TOPBAR(self, context): @@ -293,15 +358,24 @@ def VIEW3D_TOPBAR(self, context): icon=SliceViewer.bl_icon, text="") +node_menu = NodeMenu( + menu_items=MENU_ITEMS +) + + def add(): + node_menu.register() bpy.types.VIEW3D_HT_header.append(VIEW3D_TOPBAR) - bpy.types.NODE_PT_node_tree_properties.prepend(NODE_PT) bpy.types.TOPBAR_MT_editor_menus.append(TOPBAR) - bpy.types.NODE_MT_editor_menus.append(NODE) + bpy.types.NODE_PT_node_tree_properties.prepend(NODE_PROP) + bpy.types.NODE_MT_editor_menus.append(NODE_HEAD) + bpy.types.NODE_MT_context_menu.append(NODE_CONTEXT) def remove(): bpy.types.VIEW3D_HT_header.remove(VIEW3D_TOPBAR) - bpy.types.NODE_PT_node_tree_properties.remove(NODE_PT) bpy.types.TOPBAR_MT_editor_menus.remove(TOPBAR) - bpy.types.NODE_MT_editor_menus.remove(NODE) + bpy.types.NODE_PT_node_tree_properties.remove(NODE_PROP) + bpy.types.NODE_MT_editor_menus.remove(NODE_HEAD) + bpy.types.NODE_MT_context_menu.remove(NODE_CONTEXT) + node_menu.unregister() diff --git a/bioxelnodes/node_menu.py b/bioxelnodes/node_menu.py new file mode 100644 index 0000000..f64cd5b --- /dev/null +++ b/bioxelnodes/node_menu.py @@ -0,0 +1,87 @@ +import bpy +from .operators.node import AddNode + + +class NodeMenu(): + def __init__( + self, + menu_items, + ) -> None: + + self.menu_items = menu_items + self.class_prefix = f"BIOXELNODES_MT" + root_label = "Bioxel Nodes" + menu_classes = [] + self._create_menu_class( + items=menu_items, + label=root_label, + menu_classes=menu_classes + ) + self.menu_classes = menu_classes + + idname = f"{self.class_prefix}_{root_label.replace(' ', '').upper()}" + icon = "FILE_VOLUME" + + def drew_menu(self, context): + if (bpy.context.area.spaces[0].tree_type == 'GeometryNodeTree'): + layout = self.layout + layout.separator() + layout.menu(idname, + icon=icon) + self.drew_menu = drew_menu + + def _create_menu_class(self, menu_classes, items, label='CustomNodes', icon=0, idname_namespace=None): + idname_namespace = idname_namespace or self.class_prefix + idname = f"{idname_namespace}_{label.replace(' ', '').upper()}" + + # create submenu class if item is menu. + for item in items: + item_items = item.get('items') if item != 'separator' else None + if item_items: + menu_class = self._create_menu_class( + menu_classes=menu_classes, + items=item_items, + label=item.get('label') or 'CustomNodes', + icon=item.get('icon') or 0, + idname_namespace=idname + ) + item['menu_class'] = menu_class + + # create menu class + class Menu(bpy.types.Menu): + bl_idname = idname + bl_label = label + + def draw(self, context): + layout = self.layout + for item in items: + if item == "separator": + layout.separator() + elif item.get('menu_class'): + layout.menu( + item.get('menu_class').bl_idname, + icon=item.get('icon') or 'NONE' + ) + else: + op = layout.operator( + AddNode.bl_idname, + text=item.get('label'), + icon=item.get('icon') or 'NONE' + ) + op.node_name = item['name'] + op.node_label = item.get('label') or "" + op.node_description = item.get( + 'node_description') or "" + + menu_classes.append(Menu) + return Menu + + def register(self): + for cls in self.menu_classes: + bpy.utils.register_class(cls) + bpy.types.NODE_MT_add.append(self.drew_menu) + + def unregister(self): + bpy.types.NODE_MT_add.remove(self.drew_menu) + for cls in reversed(self.menu_classes): + bpy.utils.unregister_class(cls) diff --git a/bioxelnodes/operators/container.py b/bioxelnodes/operators/container.py index 0683327..ba9fc01 100644 --- a/bioxelnodes/operators/container.py +++ b/bioxelnodes/operators/container.py @@ -2,17 +2,16 @@ import bmesh -from ..nodes import custom_nodes -from ..customnodes.nodes import AddCustomNode +from ..utils import get_cache_dir, get_use_link, select_object from ..bioxel.io import load_container, save_container from ..bioxelutils.container import container_to_obj, obj_to_container -from ..bioxelutils.utils import (get_container_layer_objs, get_container_obj, - get_layer_prop_value, get_nodes_by_type, - move_node_between_nodes, - move_node_to_node, - get_all_layer_objs) - -from .utils import get_cache_dir, select_object +from ..bioxelutils.node import add_node_to_graph +from ..bioxelutils.common import (get_container_layer_objs, get_container_obj, + get_nodes_by_type, is_missing_layer, + move_node_between_nodes, + move_node_to_node, + get_all_layer_objs) +from .layer import RemoveLayers, SaveLayersCache class SaveContainer(bpy.types.Operator): @@ -63,7 +62,7 @@ def execute(self, context): container_obj = container_to_obj(container, scene_scale=0.01, - cache_dir=get_cache_dir(context)) + cache_dir=get_cache_dir()) select_object(container_obj) if is_first_import: @@ -78,7 +77,7 @@ def invoke(self, context, event): return {'RUNNING_MODAL'} -class PickObject(): +class ExtractObject(): bl_options = {'UNDO'} def execute(self, context): @@ -92,18 +91,19 @@ def execute(self, context): size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1)) obj = bpy.context.active_object - obj.name = f"{self.object_type}_{container_obj.name}" + obj.name = f"{container_obj.name}_{self.object_type}" bpy.ops.node.new_geometry_nodes_modifier() modifier = obj.modifiers[0] node_group = modifier.node_group output_node = get_nodes_by_type(node_group, 'NodeGroupOutput')[0] - pick_mesh_node = custom_nodes.add_node(node_group, - f"BioxelNodes_Pick{self.object_type}") + fetch_mesh_node = add_node_to_graph(f"Fetch{self.object_type}", + node_group, + get_use_link()) - pick_mesh_node.inputs[0].default_value = container_obj - node_group.links.new(pick_mesh_node.outputs[0], output_node.inputs[0]) + fetch_mesh_node.inputs[0].default_value = container_obj + node_group.links.new(fetch_mesh_node.outputs[0], output_node.inputs[0]) select_object(obj) @@ -112,26 +112,34 @@ def execute(self, context): return {'FINISHED'} -class PickMesh(bpy.types.Operator, PickObject): - bl_idname = "bioxelnodes.pick_mesh" - bl_label = "Pick Mesh" - bl_description = "Pick Container Mesh" +class ExtractSurface(bpy.types.Operator, ExtractObject): + bl_idname = "bioxelnodes.fetch_mesh" + bl_label = "Extract Surface" + bl_description = "Extract Surface" bl_icon = "OUTLINER_OB_MESH" - object_type = "Mesh" + object_type = "Surface" -class PickVolume(bpy.types.Operator, PickObject): +class ExtractVolume(bpy.types.Operator, ExtractObject): bl_idname = "bioxelnodes.pick_volume" - bl_label = "Pick Volume" - bl_description = "Pick Container Volume" + bl_label = "Extract Volume" + bl_description = "Extract Volume" bl_icon = "OUTLINER_OB_VOLUME" object_type = "Volume" -class PickBboxWire(bpy.types.Operator, PickObject): +class ExtractShapeWire(bpy.types.Operator, ExtractObject): + bl_idname = "bioxelnodes.pick_shape_wire" + bl_label = "Extract Shape Wire" + bl_description = "Extract Shape Wire" + bl_icon = "FILE_VOLUME" + object_type = "ShapeWire" + + +class ExtractBboxWire(bpy.types.Operator, ExtractObject): bl_idname = "bioxelnodes.pick_bbox_wire" - bl_label = "Pick Bbox Wire" - bl_description = "Pick Container Bbox Wire" + bl_label = "Extract Bbox Wire" + bl_description = "Extract Bbox Wire" bl_icon = "MESH_CUBE" object_type = "BboxWire" @@ -162,14 +170,12 @@ def execute(self, context): elif self.cutter_type == "pie": bpy.ops.mesh.primitive_plane_add( size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1)) - pie = bpy.context.active_object - pie.name = "Pie" - orig_data = pie.data + pie_obj = bpy.context.active_object + orig_data = pie_obj.data # Create mesh pie_mesh = bpy.data.meshes.new('Pie') - - pie.data = pie_mesh + pie_obj.data = pie_mesh bpy.data.meshes.remove(orig_data) # # Create object @@ -204,7 +210,19 @@ def execute(self, context): # bpy.context.view_layer.objects.active = pie cutter_obj = bpy.context.active_object - cutter_obj.location = container_obj.location + + # vcos = [container_obj.matrix_world @ + # v.co for v in container_obj.data.vertices] + + # def find_center(l): return (max(l) + min(l)) / 2 + + # x, y, z = [[v[i] for v in vcos] for i in range(3)] + # center = [find_center(axis) for axis in [x, y, z]] + + # cutter_obj.location = center + name = self.cutter_type.capitalize() + cutter_obj.name = name + cutter_obj.data.name = name cutter_obj.visible_camera = False cutter_obj.visible_diffuse = False cutter_obj.visible_glossy = False @@ -213,6 +231,7 @@ def execute(self, context): cutter_obj.visible_shadow = False cutter_obj.hide_render = True cutter_obj.display_type = 'WIRE' + cutter_obj.lineart.usage = 'EXCLUDE' select_object(container_obj) @@ -222,13 +241,16 @@ def execute(self, context): cut_nodes = get_nodes_by_type(node_group, 'BioxelNodes_Cut') if len(cut_nodes) == 0: - cutter_node = custom_nodes.add_node(node_group, - "BioxelNodes_ObjectCutter") + cutter_node = add_node_to_graph("ObjectCutter", + node_group, + get_use_link()) cutter_node.inputs[0].default_value = self.cutter_type.capitalize() cutter_node.inputs[1].default_value = cutter_obj - cut_node = custom_nodes.add_node(node_group, - 'BioxelNodes_Cut') + cut_node = add_node_to_graph("Cut", + node_group, + get_use_link()) + output_node = get_nodes_by_type(node_group, 'NodeGroupOutput')[0] if len(output_node.inputs[0].links) == 0: @@ -249,58 +271,20 @@ def execute(self, context): move_node_to_node(cutter_node, cut_node, (-300, -300)) else: - bpy.ops.bioxelnodes.object_cutter('INVOKE_DEFAULT', - cutter_obj_name=cutter_obj.name, - cutter_type=self.cutter_type.capitalize()) + bpy.ops.bioxelnodes.add_node('EXEC_DEFAULT', + node_name="ObjectCutter", + node_label=name) + node = bpy.context.active_node + node.inputs[0].default_value = name + node.inputs[1].default_value = cutter_obj return {'FINISHED'} -class ObjectCutter(bpy.types.Operator, AddCustomNode): - bl_idname = "bioxelnodes.object_cutter" - bl_label = "Object Cutter" - bl_description = "Object Cutter" - bl_icon = "NODE" - bl_options = {'UNDO'} - - cutter_obj_name: bpy.props.StringProperty() # type: ignore - - cutter_type: bpy.props.StringProperty() # type: ignore - - def execute(self, context): - cutter_obj = bpy.data.objects.get(self.cutter_obj_name) - if cutter_obj == None: - self.report({"WARNING"}, "Get no layer.") - return {'FINISHED'} - - self.get_node_tree(self.node_type, self.node_link) - prev_context = bpy.context.area.type - bpy.context.area.type = 'NODE_EDITOR' - bpy.ops.node.add_node('INVOKE_DEFAULT', - type='GeometryNodeGroup', - use_transform=True) - bpy.context.area.type = prev_context - node = bpy.context.active_node - - self.assign_node_tree(node) - node.show_options = False - - node.label = "Object Cutter" - node.inputs[0].default_value = self.cutter_type - node.inputs[1].default_value = cutter_obj - - return {"FINISHED"} - - def invoke(self, context, event): - self.nodes_file = custom_nodes.nodes_file - self.node_type = "BioxelNodes_ObjectCutter" - return self.execute(context) - - class AddPlaneCutter(bpy.types.Operator, AddCutter): bl_idname = "bioxelnodes.add_plane_cutter" bl_label = "Add a Plane Cutter" - bl_description = "Add a Plane Cutter to Container" + bl_description = "Add a plane cutter to current container" bl_icon = "MESH_PLANE" cutter_type = "plane" @@ -308,7 +292,7 @@ class AddPlaneCutter(bpy.types.Operator, AddCutter): class AddCylinderCutter(bpy.types.Operator, AddCutter): bl_idname = "bioxelnodes.add_cylinder_cutter" bl_label = "Add a Cylinder Cutter" - bl_description = "Add a Cylinder Cutter to Container" + bl_description = "Add a cylinder cutter to current container" bl_icon = "MESH_CYLINDER" cutter_type = "cylinder" @@ -316,7 +300,7 @@ class AddCylinderCutter(bpy.types.Operator, AddCutter): class AddCubeCutter(bpy.types.Operator, AddCutter): bl_idname = "bioxelnodes.add_cube_cutter" bl_label = "Add a Cube Cutter" - bl_description = "Add a Cube Cutter to Container" + bl_description = "Add a cube cutter to current container" bl_icon = "MESH_CUBE" cutter_type = "cube" @@ -324,7 +308,7 @@ class AddCubeCutter(bpy.types.Operator, AddCutter): class AddSphereCutter(bpy.types.Operator, AddCutter): bl_idname = "bioxelnodes.add_sphere_cutter" bl_label = "Add a Sphere Cutter" - bl_description = "Add a Sphere Cutter to Container" + bl_description = "Add a sphere cutter to current container" bl_icon = "MESH_UVSPHERE" cutter_type = "sphere" @@ -332,15 +316,128 @@ class AddSphereCutter(bpy.types.Operator, AddCutter): class AddPieCutter(bpy.types.Operator, AddCutter): bl_idname = "bioxelnodes.add_pie_cutter" bl_label = "Add a Pie Cutter" - bl_description = "Add a Pie Cutter to Container" + bl_description = "Add a pie cutter to current container" bl_icon = "MESH_CONE" cutter_type = "pie" -class ScaleContainer(bpy.types.Operator): - bl_idname = "bioxelnodes.scale_container" - bl_label = "Scale Container" - bl_description = "Scale Container." +class AddSlicer(bpy.types.Operator): + bl_idname = "bioxelnodes.add_slicer" + bl_label = "Add a Slicer" + bl_description = "Add a slicer to current container" + bl_icon = "TEXTURE" + bl_options = {'UNDO'} + + def execute(self, context): + container_obj = get_container_obj(context.object) + + if container_obj is None: + self.report({"WARNING"}, "Cannot find any bioxel container.") + return {'FINISHED'} + + bpy.ops.mesh.primitive_plane_add(size=2, + enter_editmode=False, + align='WORLD', + location=(0, 0, 0), + scale=(1, 1, 1)) + + slicer_obj = bpy.context.active_object + slicer_obj.name = "Slicer" + slicer_obj.data.name = "Slicer" + + slicer_obj.visible_camera = False + slicer_obj.visible_diffuse = False + slicer_obj.visible_glossy = False + slicer_obj.visible_transmission = False + slicer_obj.visible_volume_scatter = False + slicer_obj.visible_shadow = False + slicer_obj.hide_render = True + slicer_obj.display_type = 'WIRE' + slicer_obj.lineart.usage = 'EXCLUDE' + + select_object(container_obj) + + modifier = container_obj.modifiers[0] + node_group = modifier.node_group + + slice_node = add_node_to_graph("Slice", + node_group, + get_use_link()) + + slice_node.inputs[1].default_value = slicer_obj + + output_node = get_nodes_by_type(node_group, + 'NodeGroupOutput')[0] + if len(output_node.inputs[0].links) == 0: + node_group.links.new(slice_node.outputs[0], + output_node.inputs[0]) + move_node_to_node(slice_node, output_node, (-300, 0)) + else: + pre_output_node = output_node.inputs[0].links[0].from_node + node_group.links.new(pre_output_node.outputs[0], + slice_node.inputs[0]) + node_group.links.new(slice_node.outputs[0], + output_node.inputs[0]) + move_node_between_nodes(slice_node, + [pre_output_node, output_node]) + + return {'FINISHED'} + + +class AddLocator(bpy.types.Operator): + bl_idname = "bioxelnodes.add_locator" + bl_label = "Add a Locator" + bl_description = "Add a locator to current container" + bl_icon = "EMPTY_AXIS" + bl_options = {'UNDO'} + + def execute(self, context): + container_obj = get_container_obj(context.object) + + if container_obj is None: + self.report({"WARNING"}, "Cannot find any bioxel container.") + return {'FINISHED'} + + bpy.ops.object.empty_add(type='ARROWS', + align='WORLD', + location=(0, 0, 0), + scale=(1, 1, 1)) + + locator_obj = bpy.context.active_object + locator_obj.name = "Locator" + select_object(container_obj) + + modifier = container_obj.modifiers[0] + node_group = modifier.node_group + + parent_node = add_node_to_graph("TransformParent", + node_group, + get_use_link()) + + parent_node.inputs[1].default_value = locator_obj + + output_node = get_nodes_by_type(node_group, + 'NodeGroupOutput')[0] + if len(output_node.inputs[0].links) == 0: + node_group.links.new(parent_node.outputs[0], + output_node.inputs[0]) + move_node_to_node(parent_node, output_node, (-300, 0)) + else: + pre_output_node = output_node.inputs[0].links[0].from_node + node_group.links.new(pre_output_node.outputs[0], + parent_node.inputs[0]) + node_group.links.new(parent_node.outputs[0], + output_node.inputs[0]) + move_node_between_nodes(parent_node, + [pre_output_node, output_node]) + + return {'FINISHED'} + + +class ContainerProps(bpy.types.Operator): + bl_idname = "bioxelnodes.container_props" + bl_label = "Change Container Properties" + bl_description = "Change current ontainer properties" bl_icon = "FILE_TICK" scene_scale: bpy.props.FloatProperty(name="Scene Scale", @@ -348,6 +445,11 @@ class ScaleContainer(bpy.types.Operator): min=1e-6, max=1e6, default=0.01) # type: ignore + step_size: bpy.props.FloatProperty(name="Step Size", + soft_min=0.1, soft_max=100.0, + min=0.1, max=1e2, + default=1) # type: ignore + def execute(self, context): container_obj = get_container_obj(context.object) @@ -358,10 +460,12 @@ def execute(self, context): container_obj.scale[0] = self.scene_scale container_obj.scale[1] = self.scene_scale container_obj.scale[2] = self.scene_scale + container_obj["scene_scale"] = self.scene_scale + container_obj["step_size"] = self.step_size for layer_obj in get_container_layer_objs(container_obj): - bioxel_size = get_layer_prop_value(layer_obj, "bioxel_size") - layer_obj.data.render.step_size = self.scene_scale * bioxel_size + layer_obj.data.render.space = 'WORLD' + layer_obj.data.render.step_size = self.scene_scale * self.step_size return {'FINISHED'} @@ -371,49 +475,51 @@ def invoke(self, context, event): if container_obj is None: return self.execute(context) else: - self.scene_scale = container_obj.scale[0] - context.window_manager.invoke_props_dialog(self, - width=500) + self.scene_scale = container_obj.get("scene_scale") or 0.01 + self.step_size = container_obj.get("step_size") or 1 + context.window_manager.invoke_props_dialog(self) return {'RUNNING_MODAL'} -class SaveAllLayerCaches(bpy.types.Operator): - bl_idname = "bioxelnodes.save_all_layer_caches" - bl_label = "Save All Layer Caches" - bl_description = "Save Container's caches to directory." +def get_container_layers(context, layer_filter=None): + def _layer_filter(layer_obj, context): + return True + + layer_filter = layer_filter or _layer_filter + container_obj = context.object + layer_objs = get_container_layer_objs(container_obj) + return [obj for obj in layer_objs if layer_filter(obj, context)] + + +class SaveContainerLayersCache(bpy.types.Operator, SaveLayersCache): + bl_idname = "bioxelnodes.save_container_layers_cache" + bl_label = "Save Container Layers' Cache" + bl_description = "Save all current container layers' cache to directory." bl_icon = "FILE_TICK" - cache_dir: bpy.props.StringProperty( - name="Cache Directory", - subtype='DIR_PATH', - default="//" - ) # type: ignore + success_msg = "Successfully saved all container layers." - def execute(self, context): - container_obj = get_container_obj(context.object) + def get_layers(self, context): + def is_not_missing(layer_obj, context): + return not is_missing_layer(layer_obj) + return get_container_layers(context, is_not_missing) - if container_obj is None: - self.report({"WARNING"}, "Cannot find any bioxel container.") - return {'FINISHED'} - fails = [] - for layer_obj in get_container_layer_objs(container_obj): - try: - bpy.ops.bioxelnodes.save_layer_cache('EXEC_DEFAULT', - layer_obj_name=layer_obj.name, - cache_dir=self.cache_dir) - except: - fails.append(layer_obj) - - if len(fails) == 0: - self.report({"INFO"}, f"Successfully saved bioxel layers.") - else: - self.report( - {"WARNING"}, f"{','.join([layer.name for layer in fails])} fail to save.") +class RemoveContainerMissingLayers(bpy.types.Operator, RemoveLayers): + bl_idname = "bioxelnodes.remove_container_missing_layers" + bl_label = "Remove Container Missing Layers" + bl_description = "Remove all current container missing layers" + bl_icon = "BRUSH_DATA" - return {'FINISHED'} + success_msg = "Successfully removed all container missing layers." + + def get_layers(self, context): + def is_missing(layer_obj, context): + return is_missing_layer(layer_obj) + return get_container_layers(context, is_missing) def invoke(self, context, event): - context.window_manager.invoke_props_dialog(self, - width=500) + context.window_manager.invoke_confirm(self, + event, + message=f"Are you sure to remove all **Missing** layers?") return {'RUNNING_MODAL'} diff --git a/bioxelnodes/operators/io.py b/bioxelnodes/operators/io.py index 6bfcd1c..7819256 100644 --- a/bioxelnodes/operators/io.py +++ b/bioxelnodes/operators/io.py @@ -7,9 +7,8 @@ from ..exceptions import CancelledByUser from ..props import BIOXELNODES_Series -from ..bioxelutils.utils import (get_all_layer_objs, get_container_obj, - get_layer_obj, - get_container_objs_from_selection) +from ..bioxelutils.common import (get_all_layer_objs, get_container_obj, + get_layer_obj, is_incompatible) from ..bioxelutils.container import (Container, add_layers, container_to_obj) @@ -17,8 +16,9 @@ from ..bioxel.parse import (DICOM_EXTS, SUPPORT_EXTS, get_ext, parse_volumetric_data) -from .utils import (get_cache_dir, - progress_update, progress_bar, select_object) +from ..utils import (get_cache_dir, + progress_update, progress_bar, + select_object) # 3rd-party import SimpleITK as sitk @@ -57,7 +57,7 @@ class ImportVolumetricData(): filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore - read_as = "scalar" + read_as = "SCALAR" def execute(self, context): data_path = Path(self.filepath).resolve() @@ -83,7 +83,7 @@ class ImportAsScalar(bpy.types.Operator, ImportVolumetricData): bl_label = "Import as Scalar" bl_description = "Import Volumetric Data to Container as Scalar" bl_icon = "EVENT_S" - read_as = "scalar" + read_as = "SCALAR" class ImportAsLabel(bpy.types.Operator, ImportVolumetricData): @@ -91,7 +91,7 @@ class ImportAsLabel(bpy.types.Operator, ImportVolumetricData): bl_label = "Import as Label" bl_description = "Import Volumetric Data to Container as Label" bl_icon = "EVENT_L" - read_as = "label" + read_as = "LABEL" class ImportAsColor(bpy.types.Operator, ImportVolumetricData): @@ -99,7 +99,7 @@ class ImportAsColor(bpy.types.Operator, ImportVolumetricData): bl_label = "Import as Label" bl_description = "Import Volumetric Data to Container as Label" bl_icon = "EVENT_C" - read_as = "color" + read_as = "COLOR" class BIOXELNODES_FH_ImportVolumetricData(bpy.types.FileHandler): @@ -142,6 +142,8 @@ class ParseVolumetricData(bpy.types.Operator): bl_options = {'UNDO'} meta = None + label_count = 0 + dtype = None thread = None _timer = None @@ -160,10 +162,10 @@ class ParseVolumetricData(bpy.types.Operator): options={"SKIP_SAVE"}) # type: ignore read_as: bpy.props.EnumProperty(name="Read as", - default="scalar", - items=[("scalar", "Scalar", ""), - ("label", "Labels", ""), - ("color", "Color", "")]) # type: ignore + default="SCALAR", + items=[("SCALAR", "Scalar", ""), + ("LABEL", "Labels", ""), + ("COLOR", "Color", "")]) # type: ignore series_id: bpy.props.EnumProperty(name="Select Series", items=get_series_ids) # type: ignore @@ -172,6 +174,11 @@ class ParseVolumetricData(bpy.types.Operator): type=BIOXELNODES_Series) # type: ignore def execute(self, context): + if is_incompatible(): + self.report({"ERROR"}, + "Current addon verison is not compatible to this file. If you insist on editing this file please keep the same addon version") + return {'CANCELLED'} + if not self.filepath: self.report({"WARNING"}, "No file selected.") return {'CANCELLED'} @@ -205,6 +212,8 @@ def progress_callback(factor, text): return self.meta = meta + self.label_count = int(np.max(data)) + self.dtype = data.dtype # Init cancel flag self.is_cancelled = False @@ -270,26 +279,49 @@ def modal(self, context, event): for key, value in self.meta.items(): print(f"{key}: {value}") + if self.read_as == "LABEL": + if self.label_count > 100 or self.dtype.kind not in ["i", "u"]: + self.report({"ERROR"}, "Invaild label data.") + return {'CANCELLED'} + orig_shape = self.meta['xyz_shape'] orig_spacing = self.meta['spacing'] - if orig_spacing[2] == 1 and orig_spacing[0] < 0.1: - spacing_log10 = math.floor(math.log10(min(*orig_spacing))) - orig_spacing = (orig_spacing[0] * math.pow(10, -spacing_log10-1), - orig_spacing[1] * math.pow(10, -spacing_log10-1), - 1) - min_size = min(orig_spacing[0], - orig_spacing[1], orig_spacing[2]) - bioxel_size = max(min_size, 1.0) + min_log10 = math.floor(math.log10(min(*orig_spacing))) + max_log10 = math.floor(math.log10(max(*orig_spacing))) + # min_space = min(*orig_spacing) + # max_space = max(*orig_spacing) - # layer_shape = get_layer_shape(1, orig_shape, orig_spacing) - # layer_size = get_layer_size(layer_shape, - # bioxel_size) - # log10 = math.floor(math.log10(max(*layer_size))) - # log10 = max(1, log10) - # log10 = min(3, log10) - # scene_scale = math.pow(10, -log10) - scene_scale = 0.01 + if orig_spacing[2] == 1 and min_log10 < -1: + orig_spacing = (orig_spacing[0] * math.pow(10, -min_log10-2), + orig_spacing[1] * math.pow(10, -min_log10-2), + 1) + elif min_log10 > 0: + orig_spacing = (orig_spacing[0] * math.pow(10, -min_log10-1), + orig_spacing[1] * math.pow(10, -min_log10-1), + orig_spacing[2] * math.pow(10, -min_log10-1)) + elif max_log10 < 0: + orig_spacing = (orig_spacing[0] * math.pow(10, -max_log10-1), + orig_spacing[1] * math.pow(10, -max_log10-1), + orig_spacing[2] * math.pow(10, -max_log10-1)) + + bioxel_size = max(min(*orig_spacing), 1.0) + + layer_shape = get_layer_shape(bioxel_size, + orig_shape, + orig_spacing) + layer_size = get_layer_size(layer_shape, + bioxel_size, + 0.01) + min_log10 = math.floor(math.log10(min(*layer_size))) + max_log10 = math.floor(math.log10(max(*layer_size))) + + if min_log10 > 0: + scene_scale = math.pow(10, -min_log10-2) + elif max_log10 < 0: + scene_scale = math.pow(10, -max_log10-2) + else: + scene_scale = 0.01 if context.area.type == "NODE_EDITOR": container_obj = context.object @@ -313,6 +345,7 @@ def modal(self, context, event): channel_count=self.meta['channel_count'], container_obj_name=container_obj_name, read_as=self.read_as, + label_count=self.label_count, scene_scale=scene_scale ) @@ -320,6 +353,8 @@ def modal(self, context, event): return {'FINISHED'} def invoke(self, context, event): + # why not report in execute? + # If this operator is executing, a new execute will when pre-one done. if context.window_manager.bioxelnodes_progress_factor < 1: print("A process is executing, please wait for it to finish.") return {'CANCELLED'} @@ -420,17 +455,17 @@ def draw(self, context): layout.prop(self, "series_id") -def get_sequence_sources(self, context): - items = [("-1", "None (1 frame)", "")] +def get_frame_sources(self, context): + items = [("-1", "First Frame (1 frame)", "")] orig_shape = tuple(self.orig_shape) if self.frame_count > 1: - items.append(("0", f"Frame ({self.frame_count} frames)", "")) + items.append(("0", f"Frames ({self.frame_count} frames)", "")) elif self.frame_count == 1 and self.channel_count > 1: - items.append(("4", f"Channel ({self.channel_count} frames)", "")) + items.append(("4", f"Channels ({self.channel_count} frames)", "")) elif self.frame_count == 1 and self.channel_count == 1: - items.append(("1", f"X ({orig_shape[0]} frames)", "")) - items.append(("2", f"Y ({orig_shape[1]} frames)", "")) - items.append(("3", f"Z ({orig_shape[2]} frames)", "")) + items.append(("1", f"X-axis ({orig_shape[0]} frames)", "")) + items.append(("2", f"Y-axis ({orig_shape[1]} frames)", "")) + items.append(("3", f"Z-axis ({orig_shape[2]} frames)", "")) return items @@ -460,11 +495,13 @@ class ImportVolumetricDataDialog(bpy.types.Operator): channel_count: bpy.props.IntProperty() # type: ignore + label_count: bpy.props.IntProperty() # type: ignore + read_as: bpy.props.EnumProperty(name="Read as", - default="scalar", - items=[("scalar", "Scalar", ""), - ("label", "Labels", ""), - ("color", "Color", "")]) # type: ignore + default="SCALAR", + items=[("SCALAR", "Scalar", ""), + ("LABEL", "Labels", ""), + ("COLOR", "Color", "")]) # type: ignore bioxel_size: bpy.props.FloatProperty(name="Bioxel Size (Larger size means small resolution)", soft_min=0.1, soft_max=10.0, @@ -482,11 +519,14 @@ class ImportVolumetricDataDialog(bpy.types.Operator): min=1e-6, max=1e6, default=0.01) # type: ignore - split_channels: bpy.props.BoolProperty(name="Split Channels", - default=False) # type: ignore + remap: bpy.props.BoolProperty(name="Remap to 0~1", + default=False) # type: ignore + + split_channel: bpy.props.BoolProperty(name="Split Channels", + default=False) # type: ignore - sequence_source: bpy.props.EnumProperty(name="Time Sequence From", - items=get_sequence_sources) # type: ignore + frame_source: bpy.props.EnumProperty(name="Frame From", + items=get_frame_sources) # type: ignore def execute(self, context): def import_volumetric_data_func(self, context, cancel): @@ -516,26 +556,26 @@ def progress_callback(factor, text): mat_scale = transforms3d.zooms.zfdir2aff(self.bioxel_size) affine = np.dot(meta['affine'], mat_scale) - kind = self.read_as + kind = self.read_as.lower() if cancel(): return # change shape as sequence or not - if self.sequence_source == "-1": + if self.frame_source == "-1": data = data[0:1, :, :, :, :] - elif self.sequence_source == "0": + elif self.frame_source == "0": # frame as frame pass - elif self.sequence_source == "1": + elif self.frame_source == "1": # X as frame data = data.transpose(1, 0, 2, 3, 4) shape = (1, shape[1], shape[2]) - elif self.sequence_source == "2": + elif self.frame_source == "2": # Y as frame data = data.transpose(2, 1, 0, 3, 4) shape = (shape[0], 1, shape[2]) - elif self.sequence_source == "3": + elif self.frame_source == "3": # Z as frame data = data.transpose(3, 1, 2, 0, 4) shape = (shape[0], shape[1], 1) @@ -551,6 +591,7 @@ def progress_callback(frame, total): sub_progress = progress + frame * sub_progress_step progress_update(context, sub_progress, f"Processing {layer_name} Frame {frame+1}...") + print(f"Processing {layer_name} Frame {frame+1}...") return progress_callback layers = [] @@ -590,11 +631,13 @@ def progress_callback(frame, total): return if kind == "color": - if np.issubdtype(np.uint8, data.dtype): data = np.multiply(data, 1.0 / 256, dtype=np.float32) elif data.dtype.kind in ['u', 'i']: + # Convert the normalized array to float dtype + data = data.astype(np.float32) + min_val = data.min() max_val = data.max() # Avoid division by zero if all values are the same @@ -605,8 +648,6 @@ def progress_callback(frame, total): # If all values are the same, the normalized array will be all zeros data = np.zeros_like(data, dtype=np.float32) - # Convert the normalized array to float dtype - data = data.astype(np.float32) else: data = data.astype(np.float32) @@ -650,7 +691,21 @@ def progress_callback(frame, total): elif kind == "scalar": name = self.layer_name or "Scalar" - if self.split_channels: + if self.remap: + # Convert the normalized array to float dtype + data = data.astype(np.float32) + + min_val = data.min() + max_val = data.max() + # Avoid division by zero if all values are the same + if max_val != min_val: + # Normalize the array to the range (0,1) + data = (data - min_val) / (max_val - min_val) + else: + # If all values are the same, the normalized array will be all zeros + data = np.zeros_like(data, dtype=np.float32) + + if self.split_channel: progress_step = 0.7/self.channel_count for i in range(self.channel_count): @@ -767,15 +822,17 @@ def modal(self, context, event): container_obj = add_layers(self.layers, container_obj=container_obj, - cache_dir=get_cache_dir(context)) + cache_dir=get_cache_dir()) else: name = self.container_name or "Container" container = Container(name=name, layers=self.layers) + step_size = container.layers[0].bioxel_size[0]*5 container_obj = container_to_obj(container, scene_scale=self.scene_scale, - cache_dir=get_cache_dir(context)) + step_size=step_size, + cache_dir=get_cache_dir()) select_object(container_obj) @@ -806,54 +863,69 @@ def draw(self, context): # change shape as sequence or not channel_count = self.channel_count - if self.sequence_source == "-1": + frame_count = self.frame_count + if self.frame_source == "-1": + frame_count = 1 pass - elif self.sequence_source == "0": + elif self.frame_source == "0": # frame as frame pass - elif self.sequence_source == "1": + elif self.frame_source == "1": + frame_count = orig_shape[0] layer_shape = (1, layer_shape[1], layer_shape[2]) - elif self.sequence_source == "2": + elif self.frame_source == "2": + frame_count = orig_shape[1] layer_shape = (layer_shape[0], 1, layer_shape[2]) - elif self.sequence_source == "3": + elif self.frame_source == "3": + frame_count = orig_shape[2] layer_shape = (layer_shape[0], layer_shape[1], 1) else: channel_count = 1 - import_channel = channel_count if self.split_channels or channel_count == 1 else "combined" + if self.read_as == "SCALAR": + layer_count = channel_count if self.split_channel else 1 + channel_count = 1 + elif self.read_as == "LABEL": + layer_count = self.label_count + channel_count = 1 + else: + layer_count = 1 + channel_count = 3 bioxel_count = layer_shape[0] * layer_shape[1] * layer_shape[2] - layer_shape_text = f"Shape from {str(orig_shape)} to {str(layer_shape)}" + orig_shape_text = f"[{self.frame_count}, ({orig_shape[0]},{orig_shape[1]},{orig_shape[2]}), {self.channel_count}]" + layer_shape_text = f"{layer_count} x [{frame_count}, ({layer_shape[0]},{layer_shape[1]},{layer_shape[2]}), {channel_count}]" + if bioxel_count > 100000000: layer_shape_text += "**TOO LARGE!**" + layer_size = get_layer_size(layer_shape, + self.bioxel_size, + self.scene_scale) + layer_size_text = f"Size will be: ({layer_size[0]:.2f}, {layer_size[1]:.2f}, {layer_size[2]:.2f}) m" + layout = self.layout if self.container_obj_name == "": - layout.prop(self, "container_name") - layout.prop(self, "layer_name") + panel = layout.box() + panel.prop(self, "container_name") + panel.prop(self, "scene_scale") + panel.label(text=layer_size_text) panel = layout.box() + panel.prop(self, "layer_name") panel.prop(self, "bioxel_size") row = panel.row() row.prop(self, "orig_spacing") - panel.label(text=layer_shape_text) - - panel = layout.box() - panel.prop(self, "sequence_source") + panel.prop(self, "frame_source") - if self.read_as == "scalar": - panel = layout.box() - panel.prop(self, "split_channels", - text=f"Split Channels (Get {channel_count} channels, import {import_channel} channels)") + if self.read_as == "SCALAR": + panel.prop(self, "split_channel", + text=f"Split Channel as Multi Layer") - if self.container_obj_name == "": - layer_size = get_layer_size(layer_shape, - self.bioxel_size, - self.scene_scale) - layer_size_text = f"Size will be: ({layer_size[0]:.2f}, {layer_size[1]:.2f}, {layer_size[2]:.2f}) m" - panel = layout.box() - panel.prop(self, "scene_scale") - panel.label(text=layer_size_text) + panel.label( + text="Dimension Order: [Frame, (X-axis,Y-axis,Z-axis), Channel]") + panel.label( + text=f"Shape from {orig_shape_text} to {layer_shape_text}") class ExportVolumetricData(bpy.types.Operator): diff --git a/bioxelnodes/operators/layer.py b/bioxelnodes/operators/layer.py index ab520f8..16c8c0e 100644 --- a/bioxelnodes/operators/layer.py +++ b/bioxelnodes/operators/layer.py @@ -1,19 +1,16 @@ from pathlib import Path import bpy -import re import numpy as np +from ..exceptions import NoContent from ..bioxel.layer import Layer -from ..utils import copy_to_dir -from ..customnodes.nodes import AddCustomNode -from ..bioxelutils.utils import (get_container_obj, - get_layer_prop_value, - get_container_layer_objs, - get_node_type, set_layer_prop_value) +from ..bioxelutils.common import (get_container_obj, get_layer_kind, get_layer_label, get_layer_name, + get_layer_prop_value, + get_container_layer_objs, + get_node_type, is_missing_layer, set_layer_prop_value) from ..bioxelutils.layer import layer_to_obj, obj_to_layer -from .utils import get_cache_dir, get_layer_item_label, get_layer_label, get_preferences -from ..nodes import custom_nodes +from ..utils import get_cache_dir, copy_to_dir def get_label_layer_selection(self, context): @@ -21,134 +18,244 @@ def get_label_layer_selection(self, context): container_obj = get_container_obj(bpy.context.active_object) for layer_obj in get_container_layer_objs(container_obj): - if get_layer_prop_value(layer_obj, "kind") == "label": + kind = get_layer_prop_value(layer_obj, "kind") + name = get_layer_prop_value(layer_obj, "name") + if kind == "label": items.append((layer_obj.name, - layer_obj.name, + name, "")) return items -def get_selected_objs_in_node_tree(context): - select_objs = [] - # node_group = context.space_data.edit_tree - for node in context.selected_nodes: - if get_node_type(node) == "BioxelNodes_FetchLayer": - layer_obj = node.inputs[0].default_value - if layer_obj != None: - cache_filepath = Path(bpy.path.abspath( - layer_obj.data.filepath)).resolve() - if cache_filepath.is_file(): - select_objs.append(layer_obj) - return select_objs - - -class LayerOperator(): +class FetchLayer(bpy.types.Operator): + bl_idname = "bioxelnodes.fetch_layer" + bl_label = "Fetch Layer" + bl_description = "Fetch layer from current container" + bl_icon = "NODE" bl_options = {'UNDO'} + layer_obj_name: bpy.props.StringProperty( options={"HIDDEN"}) # type: ignore + @classmethod + def description(cls, context, properties): + layer_obj_name = properties.layer_obj_name + layer_obj = bpy.data.objects.get(layer_obj_name) + return "\n".join([f"{prop}: {get_layer_prop_value(layer_obj, prop)}" + for prop in ["kind", + "bioxel_size", + "shape", + "frame_count", + "channel_count", + "min", "max"]]) + @property def layer_obj(self): return bpy.data.objects.get(self.layer_obj_name) - @property - def is_lost(self): - if self.layer_obj is None: - return None - - cache_filepath = Path(bpy.path.abspath( - self.layer_obj.data.filepath)).resolve() - return not cache_filepath.is_file() - - -class FetchLayer(bpy.types.Operator, LayerOperator, AddCustomNode): - bl_idname = "bioxelnodes.fetch_layer" - bl_label = "Fetch Layer" - bl_description = "Fetch Layer" - bl_icon = "NODE" - def execute(self, context): if self.layer_obj == None: self.report({"WARNING"}, "Get no layer.") return {'FINISHED'} - if self.is_lost: + if is_missing_layer(self.layer_obj): self.report({"WARNING"}, "Selected layer is lost.") return {'FINISHED'} - self.get_node_tree(self.node_type, self.node_link) - prev_context = bpy.context.area.type - bpy.context.area.type = 'NODE_EDITOR' - bpy.ops.node.add_node('INVOKE_DEFAULT', - type='GeometryNodeGroup', - use_transform=True) - bpy.context.area.type = prev_context + bpy.ops.bioxelnodes.add_node('EXEC_DEFAULT', + node_name="FetchLayer", + node_label="Fetch Layer") node = bpy.context.active_node - self.assign_node_tree(node) - node.show_options = False - layer_obj = self.layer_obj - node.inputs[0].default_value = layer_obj - node.label = get_layer_label(layer_obj) + node.inputs[0].default_value = self.layer_obj + node.label = get_layer_label(self.layer_obj) return {"FINISHED"} - def invoke(self, context, event): - self.nodes_file = custom_nodes.nodes_file - self.node_type = "BioxelNodes_FetchLayer" - return self.execute(context) +def get_selected_layers(context, layer_filter=None): + def _layer_filter(layer_obj, context): + return True -class FetchLayerMenu(bpy.types.Menu): - bl_idname = "BIOXELNODES_MT_ADD_LAYER" - bl_label = "Fetch Layer" + layer_filter = layer_filter or _layer_filter + select_objs = [] + # node_group = context.space_data.edit_tree + for node in context.selected_nodes: + if get_node_type(node) == "BioxelNodes_FetchLayer": + layer_obj = node.inputs[0].default_value + if layer_obj is not None: + if layer_filter(layer_obj, context): + select_objs.append(layer_obj) - def draw(self, context): - container_obj = get_container_obj(bpy.context.active_object) - layer_objs = get_container_layer_objs(container_obj) - layout = self.layout + return list(set(select_objs)) - for layer_obj in layer_objs: - op = layout.operator(FetchLayer.bl_idname, - text=get_layer_item_label(context, layer_obj)) - op.layer_obj_name = layer_obj.name +def get_selected_layer(context, layer_filter=None): + layer_objs = get_selected_layers(context, layer_filter) + return layer_objs[0] if len(layer_objs) > 0 else None -class ModifyLayerOperator(LayerOperator): - def layer_operate(self, orig_layer: Layer): + +class OutputLayerOperator(): + new_layer_name: bpy.props.StringProperty(name="New Name", + options={"SKIP_SAVE"}) # type: ignore + + def operate(self, orig_layer: Layer, context): """do the operation""" return orig_layer def add_layer_node(self, context, layer): layer_obj = layer_to_obj(layer, - container_obj=self.layer_obj.parent, - cache_dir=get_cache_dir(context)) + container_obj=context.object, + cache_dir=get_cache_dir()) bpy.ops.bioxelnodes.fetch_layer('INVOKE_DEFAULT', layer_obj_name=layer_obj.name) def execute(self, context): - if self.layer_obj == None: + layer_obj = get_selected_layer(context) + if layer_obj == None: self.report({"WARNING"}, "Get no layer.") return {'FINISHED'} - if self.is_lost: + if is_missing_layer(layer_obj): self.report({"WARNING"}, "Selected layer is lost.") return {'FINISHED'} - orig_layer = obj_to_layer(self.layer_obj) - new_layer = self.layer_operate(orig_layer) + orig_layer = obj_to_layer(layer_obj) + try: + new_layer = self.operate(orig_layer, context) + except NoContent as e: + self.report({"WARNING"}, e.message) + return {'FINISHED'} + self.add_layer_node(context, new_layer) return {'FINISHED'} -class ResampleScalar(bpy.types.Operator, ModifyLayerOperator): - bl_idname = "bioxelnodes.resample_scalar" - bl_label = "Resample Scalar" - bl_description = "Resample Scalar" +class LayerOperator(): + + def operate(self, layer_obj: bpy.types.Object, context): + """do the operation""" + ... + + def execute(self, context): + layer_obj = get_selected_layer(context) + if layer_obj == None: + self.report({"WARNING"}, "Get no layer.") + return {'FINISHED'} + + if is_missing_layer(layer_obj): + self.report({"WARNING"}, "Selected layer is lost.") + return {'FINISHED'} + + self.operate(layer_obj, context) + + return {'FINISHED'} + + +class RetimeLayer(bpy.types.Operator, LayerOperator): + bl_idname = "bioxelnodes.retime_layer" + bl_label = "Retime Sequence" + bl_description = "Retime layer time sequence" + bl_icon = "TIME" + + frame_duration: bpy.props.IntProperty(name="Frames") # type: ignore + frame_start: bpy.props.IntProperty(name="Start") # type: ignore + frame_offset: bpy.props.IntProperty(name="Offset") # type: ignore + sequence_mode: bpy.props.EnumProperty(name="Mode", + default="REPEAT", + items=[("CLIP", "Clip", ""), + ("EXTEND", "Extend", ""), + ("REPEAT", "Repeat", ""), + ("PING_PONG", "Ping-Pong", "")]) # type: ignore + + def operate(self, layer_obj, context): + layer_obj.data.frame_duration = self.frame_duration + layer_obj.data.frame_start = self.frame_start + layer_obj.data.frame_offset = self.frame_offset + layer_obj.data.sequence_mode = self.sequence_mode + + def invoke(self, context, event): + layer_obj = get_selected_layer(context) + if layer_obj: + self.frame_duration = layer_obj.data.frame_duration + self.frame_start = layer_obj.data.frame_start + self.frame_offset = layer_obj.data.frame_offset + self.sequence_mode = layer_obj.data.sequence_mode + name = get_layer_label(layer_obj) + context.window_manager.invoke_props_dialog(self, + title=f"Retime {name}") + return {'RUNNING_MODAL'} + else: + return self.execute(context) + + +class RelocateLayer(bpy.types.Operator, LayerOperator): + bl_idname = "bioxelnodes.relocate_layer" + bl_label = "Relocate Layer Cache" + bl_description = "Relocate layer cache" + bl_icon = "FILE" + + filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore + + def operate(self, layer_obj, context): + layer_obj.data.filepath = self.filepath + + def invoke(self, context, event): + layer_obj = get_selected_layer(context) + if layer_obj: + context.window_manager.fileselect_add(self) + return {'RUNNING_MODAL'} + else: + return self.execute(context) + + +class RenameLayer(bpy.types.Operator, LayerOperator): + bl_idname = "bioxelnodes.rename_layer" + bl_label = "Rename Layer" + bl_description = "Rename layer" + bl_icon = "FILE_FONT" + + new_name: bpy.props.StringProperty(name="New Name", + options={"SKIP_SAVE"}) # type: ignore + + def operate(self, layer_obj, context): + name = f"{layer_obj.parent.name}_{self.new_name}" + layer_obj.name = name + layer_obj.data.name = name + + set_layer_prop_value(layer_obj, "name", self.new_name) + + node_group = context.space_data.edit_tree + for node in node_group.nodes: + if get_node_type(node) == "BioxelNodes_FetchLayer": + if node.inputs[0].default_value == layer_obj: + node.label = self.new_name + + def invoke(self, context, event): + layer_obj = get_selected_layer(context) + if layer_obj: + self.new_name = get_layer_name(layer_obj) + name = get_layer_label(layer_obj) + context.window_manager.invoke_props_dialog(self, + title=f"Rename {name}") + return {'RUNNING_MODAL'} + else: + return self.execute(context) + + +class ResampleLayer(bpy.types.Operator, OutputLayerOperator): + bl_idname = "bioxelnodes.resample_layer" + bl_label = "Resample Value" + bl_description = "Resample value" bl_icon = "ALIASED" + smooth: bpy.props.IntProperty(name="Smooth Iteration", + default=0, + soft_min=0, soft_max=5, + options={"SKIP_SAVE"}) # type: ignore + bioxel_size: bpy.props.FloatProperty( name="Bioxel Size", soft_min=0.1, soft_max=10.0, @@ -161,19 +268,21 @@ def get_new_shape(orig_shape, orig_size, new_size): int(orig_shape[1]*orig_size/new_size), int(orig_shape[2]*orig_size/new_size)) - def layer_operate(self, orig_layer: Layer): - modified_layer = orig_layer.copy() - new_shape = self.get_new_shape(modified_layer.shape, - modified_layer.bioxel_size[0], + def operate(self, orig_layer: Layer, context): + new_layer = orig_layer.copy() + new_shape = self.get_new_shape(new_layer.shape, + new_layer.bioxel_size[0], self.bioxel_size) - modified_layer.resize(new_shape) - modified_layer.name = f"{orig_layer.name}_R-{self.bioxel_size:.2f}" - return modified_layer + new_layer.resize(new_shape, self.smooth) + new_layer.name = self.new_layer_name \ + or f"{orig_layer.name}_R-{self.bioxel_size:.2f}" + return new_layer def draw(self, context): - orig_shape = get_layer_prop_value(self.layer_obj, "shape") - orig_size = get_layer_prop_value(self.layer_obj, "bioxel_size") + layer_obj = get_selected_layer(context) + orig_shape = get_layer_prop_value(layer_obj, "shape") + orig_size = get_layer_prop_value(layer_obj, "bioxel_size") new_shape = self.get_new_shape(orig_shape, orig_size, self.bioxel_size) @@ -186,45 +295,49 @@ def draw(self, context): layer_shape_text += "**TOO LARGE!**" layout = self.layout + layout.prop(self, "new_layer_name") layout.prop(self, "bioxel_size") + layout.prop(self, "smooth") layout.label(text=layer_shape_text) def invoke(self, context, event): - if self.layer_obj: - bioxel_size = get_layer_prop_value(self.layer_obj, "bioxel_size") - - self.bioxel_size = bioxel_size + layer_obj = get_selected_layer(context) + if layer_obj: + name = get_layer_label(layer_obj) + self.bioxel_size = get_layer_prop_value(layer_obj, + "bioxel_size") context.window_manager.invoke_props_dialog(self, - width=400, - title=f"Resample {self.layer_obj.name}") + title=f"Resample {name}") return {'RUNNING_MODAL'} else: return self.execute(context) -class SignScalar(bpy.types.Operator, ModifyLayerOperator): +class SignScalar(bpy.types.Operator, OutputLayerOperator): bl_idname = "bioxelnodes.sign_scalar" - bl_label = "Sign Scalar" - bl_description = "Sign the scalar value" + bl_label = "Sign Value" + bl_description = "Sign value" bl_icon = "REMOVE" - def layer_operate(self, orig_layer: Layer): - modified_layer = orig_layer.copy() - modified_layer.data = -orig_layer.data - modified_layer.name = f"{orig_layer.name}_Sign" - return modified_layer + def operate(self, orig_layer: Layer, context): + new_layer = orig_layer.copy() + new_layer.data = -orig_layer.data + new_layer.name = self.new_layer_name \ + or f"{orig_layer.name}_Sign" + return new_layer def invoke(self, context, event): - if self.layer_obj: - context.window_manager.invoke_confirm(self, - event, - message=f"Are you sure to sign {self.layer_obj.name}?") + layer_obj = get_selected_layer(context) + if layer_obj: + name = get_layer_label(layer_obj) + context.window_manager.invoke_props_dialog(self, + title=f"Sign {name}") return {'RUNNING_MODAL'} else: return self.execute(context) -class FillOperator(ModifyLayerOperator): +class FillOperator(OutputLayerOperator): fill_value: bpy.props.FloatProperty( name="Fill Value", @@ -238,13 +351,13 @@ class FillOperator(ModifyLayerOperator): ) # type: ignore def invoke(self, context, event): - if self.layer_obj: - scalar_min = get_layer_prop_value(self.layer_obj, "min") - + layer_obj = get_selected_layer(context) + if layer_obj: + scalar_min = get_layer_prop_value(layer_obj, "min") self.fill_value = min(scalar_min, 0) + name = get_layer_label(layer_obj) context.window_manager.invoke_props_dialog(self, - width=400, - title=f"Fill {self.layer_obj.name}") + title=f"Fill {name}") return {'RUNNING_MODAL'} else: return self.execute(context) @@ -253,7 +366,7 @@ def invoke(self, context, event): class FillByThreshold(bpy.types.Operator, FillOperator): bl_idname = "bioxelnodes.fill_by_threshold" bl_label = "Fill Value by Threshold" - bl_description = "Fill Value by Threshold" + bl_description = "Fill value by threshold" bl_icon = "EMPTY_SINGLE_ARROW" threshold: bpy.props.FloatProperty( @@ -262,21 +375,22 @@ class FillByThreshold(bpy.types.Operator, FillOperator): default=128, ) # type: ignore - def layer_operate(self, orig_layer: Layer): + def operate(self, orig_layer: Layer, context): data = np.amax(orig_layer.data, -1) mask = data <= self.threshold \ if self.invert else data > self.threshold - modified_layer = orig_layer.copy() - modified_layer.fill(self.fill_value, mask) - modified_layer.name = f"{orig_layer.name}_F-{self.threshold}" - return modified_layer + new_layer = orig_layer.copy() + new_layer.fill(self.fill_value, mask, 3) + new_layer.name = self.new_layer_name \ + or f"{orig_layer.name}_F-{self.threshold:.2f}" + return new_layer class FillByRange(bpy.types.Operator, FillOperator): bl_idname = "bioxelnodes.fill_by_range" bl_label = "Fill Value by Range" - bl_description = "Fill Value by Range" + bl_description = "Fill value by range" bl_icon = "IPO_CONSTANT" from_min: bpy.props.FloatProperty( @@ -291,82 +405,112 @@ class FillByRange(bpy.types.Operator, FillOperator): default=256, ) # type: ignore - def layer_operate(self, orig_layer: Layer): + def operate(self, orig_layer: Layer, context): data = np.amax(orig_layer.data, -1) mask = (data <= self.from_min) | (data >= self.from_max) if self.invert else \ (data > self.from_min) & (data < self.from_max) - modified_layer = orig_layer.copy() - modified_layer.fill(self.fill_value, mask) - modified_layer.name = f"{orig_layer.name}_F-{self.from_min}-{self.from_max}" - return modified_layer + new_layer = orig_layer.copy() + new_layer.fill(self.fill_value, mask, 3) + new_layer.name = self.new_layer_name \ + or f"{orig_layer.name}_F-{self.from_min:.2f}-{self.from_max:.2f}" + return new_layer class FillByLabel(bpy.types.Operator, FillOperator): bl_idname = "bioxelnodes.fill_by_label" bl_label = "Fill Value by Label" - bl_description = "Fill Value by Label Area" + bl_description = "Fill value by label" bl_icon = "MESH_CAPSULE" + smooth: bpy.props.IntProperty(name="Smooth Iteration", + default=0, + soft_min=0, soft_max=5, + options={"SKIP_SAVE"}) # type: ignore + label_obj_name: bpy.props.EnumProperty(name="Label Layer", items=get_label_layer_selection) # type: ignore - def layer_operate(self, orig_layer: Layer): + def operate(self, orig_layer: Layer, context): label_obj = bpy.data.objects.get(self.label_obj_name) - if not label_obj: - self.report({"WARNING"}, "Cannot find any label layer.") - return {'FINISHED'} + if label_obj is None: + raise NoContent("Cannot find any label layer.") label_layer = obj_to_layer(label_obj) - label_layer.resize(orig_layer.shape) + label_layer.resize(orig_layer.shape, self.smooth) mask = np.amax(label_layer.data, -1) if self.invert: mask = 1 - mask - modified_layer = orig_layer.copy() - modified_layer.fill(self.fill_value, mask) - modified_layer.name = f"{orig_layer.name}_F-{label_layer.name}" - return modified_layer + new_layer = orig_layer.copy() + new_layer.fill(self.fill_value, mask, 3) + new_layer.name = self.new_layer_name \ + or f"{orig_layer.name}_F-{label_layer.name}" + return new_layer -class CombineLabels(bpy.types.Operator, ModifyLayerOperator): +class CombineLabels(bpy.types.Operator, OutputLayerOperator): bl_idname = "bioxelnodes.combine_labels" bl_label = "Combine Labels" bl_description = "Combine all selected labels" bl_icon = "MOD_BUILD" def execute(self, context): - label_objs = [obj for obj in get_selected_objs_in_node_tree(context) - if get_layer_prop_value(obj, "kind") == "label"] + def layer_filter(layer_obj, context): + return get_layer_kind(layer_obj) == "label" + + label_objs = get_selected_layers(context, layer_filter) if len(label_objs) < 2: self.report({"WARNING"}, "Not enough layers.") return {'FINISHED'} + base_obj = label_objs[0] label_objs = label_objs[1:] base_layer = obj_to_layer(base_obj) - modified_layer = base_layer.copy() + new_layer = base_layer.copy() label_names = [base_layer.name] for label_obj in label_objs: label_layer = obj_to_layer(label_obj) label_layer.resize(base_layer.shape) - modified_layer.data = np.maximum( - modified_layer.data, label_layer.data) + new_layer.data = np.maximum( + new_layer.data, label_layer.data) label_names.append(label_layer.name) - modified_layer.name = f"C-{'-'.join(label_names)}" + new_layer.name = self.new_layer_name \ + or f"C-{'-'.join(label_names)}" - self.add_layer_node(context, modified_layer) + self.add_layer_node(context, new_layer) return {'FINISHED'} -class SaveLayerCache(bpy.types.Operator, LayerOperator): - bl_idname = "bioxelnodes.save_layer_cache" - bl_label = "Save Layer Cache" - bl_description = "Save Layer Cache" - bl_icon = "FILE_TICK" +class BatchLayerOperator(): + success_msg = "Successfully done selected layers." + fail_msg = "fails." + + def execute(self, context): + layer_objs = self.get_layers(context) + + fails = [] + for layer_obj in layer_objs: + try: + self.operate(layer_obj, context) + except: + fails.append(layer_obj) + + if len(fails) == 0: + self.report({"INFO"}, self.success_msg) + else: + self.report( + {"WARNING"}, f"{','.join([layer_obj.name for layer_obj in fails])} {self.fail_msg}") + + return {'FINISHED'} + + +class SaveLayersCache(BatchLayerOperator): + fail_msg = "fail to save." cache_dir: bpy.props.StringProperty( name="Cache Directory", @@ -374,23 +518,14 @@ class SaveLayerCache(bpy.types.Operator, LayerOperator): default="//" ) # type: ignore - def execute(self, context): - if self.layer_obj == None: - self.report({"WARNING"}, "Get no layer.") - return {'FINISHED'} - - if self.is_lost: - self.report({"WARNING"}, "Selected layer is lost.") - return {'FINISHED'} - - # "//" + def operate(self, layer_obj, context): output_dir = bpy.path.abspath(self.cache_dir) - source_dir = bpy.path.abspath(self.layer_obj.data.filepath) + source_dir = bpy.path.abspath(layer_obj.data.filepath) source_path: Path = Path(source_dir).resolve() - is_sequence = self.layer_obj.data.is_sequence + is_sequence = layer_obj.data.is_sequence - name = self.layer_obj.name if is_sequence else f"{self.layer_obj.name}.vdb" + name = layer_obj.name if is_sequence else f"{layer_obj.name}.vdb" output_path: Path = Path(output_dir, name, source_path.name).resolve() \ if is_sequence else Path(output_dir, name).resolve() @@ -401,124 +536,69 @@ def execute(self, context): blend_path = Path(bpy.path.abspath("//")).resolve() - self.layer_obj.data.filepath = bpy.path.relpath(str(output_path), - start=str(blend_path)) + layer_obj.data.filepath = bpy.path.relpath(str(output_path), + start=str(blend_path)) return {'FINISHED'} def invoke(self, context, event): - if self.layer_obj: - context.window_manager.invoke_props_dialog(self, - width=500) - return {'RUNNING_MODAL'} - else: - return self.execute(context) - - -class RenameLayer(bpy.types.Operator, LayerOperator): - bl_idname = "bioxelnodes.rename_layer" - bl_label = "Rename Layer" - bl_description = "Rename Layer" - bl_icon = "FILE_FONT" - - name: bpy.props.StringProperty(name="New Name") # type: ignore - - def execute(self, context): - if self.layer_obj == None: - self.report({"WARNING"}, "Get no layer.") - return {'FINISHED'} - - if self.is_lost: - self.report({"WARNING"}, "Selected layer is lost.") - return {'FINISHED'} - - name = f"{self.layer_obj.parent.name}_{self.name}" - self.layer_obj.name = name - self.layer_obj_name = name - self.layer_obj.data.name = name - - set_layer_prop_value(self.layer_obj, "name", self.name) - - node_group = context.space_data.edit_tree - for node in node_group.nodes: - if get_node_type(node) == "BioxelNodes_FetchLayer": - if node.inputs[0].default_value == self.layer_obj: - node.label = self.name - - return {'FINISHED'} - - def invoke(self, context, event): - if self.layer_obj: - self.name = get_layer_prop_value(self.layer_obj, "name") - context.window_manager.invoke_props_dialog(self, - width=500, - title=f"rename {self.layer_obj.name}") - return {'RUNNING_MODAL'} - else: - return self.execute(context) - + context.window_manager.invoke_props_dialog(self) + return {'RUNNING_MODAL'} -class RemoveLayer(bpy.types.Operator, LayerOperator): - bl_idname = "bioxelnodes.remove_layer" - bl_label = "Remove Selected Layer" - bl_description = "Remove Layer" - bl_icon = "TRASH" - def execute(self, context): - if self.layer_obj == None: - self.report({"WARNING"}, "Get no layer.") - return {'FINISHED'} +class RemoveLayers(BatchLayerOperator): + fail_msg = "fail to remove." - node_group = context.space_data.edit_tree - for node in node_group.nodes: - if get_node_type(node) == "BioxelNodes_FetchLayer": - if node.inputs[0].default_value == self.layer_obj: - node_group.nodes.remove(node) + def operate(self, layer_obj, context): + for node_group in bpy.data.node_groups: + for node in node_group.nodes: + if get_node_type(node) == "BioxelNodes_FetchLayer": + if node.inputs[0].default_value == layer_obj: + node_group.nodes.remove(node) cache_filepath = Path(bpy.path.abspath( - self.layer_obj.data.filepath)).resolve() + layer_obj.data.filepath)).resolve() if cache_filepath.is_file(): - if self.layer_obj.data.is_sequence: + if layer_obj.data.is_sequence: for f in cache_filepath.parent.iterdir(): f.unlink(missing_ok=True) else: cache_filepath.unlink(missing_ok=True) # also remove layer object - bpy.data.volumes.remove(self.layer_obj.data) + bpy.data.volumes.remove(layer_obj.data) return {'FINISHED'} def invoke(self, context, event): - if self.layer_obj: - context.window_manager.invoke_confirm(self, - event, - message=f"Are you sure to remove {self.layer_obj.name}?") - return {'RUNNING_MODAL'} - else: - return self.execute(context) + context.window_manager.invoke_confirm(self, + event, + message=f"Are you sure to remove them?") + return {'RUNNING_MODAL'} + +class SaveSelectedLayersCache(bpy.types.Operator, SaveLayersCache): + bl_idname = "bioxelnodes.save_selected_layers_cache" + bl_label = "Save Selected Layers Cache" + bl_description = "Save selected layers' Cache" + bl_icon = "FILE_TICK" -class RemoveMissingLayers(bpy.types.Operator): - bl_idname = "bioxelnodes.remove_lost_layers" - bl_label = "Remove All Missing Layers" - bl_description = "Remove all missing " - bl_icon = "BRUSH_DATA" + success_msg = "Successfully saved all selected layers." - def execute(self, context): - container_obj = context.object - for layer_obj in get_container_layer_objs(container_obj): - cache_filepath = Path(bpy.path.abspath( - layer_obj.data.filepath)).resolve() - if cache_filepath.is_file(): - continue - bpy.ops.bioxelnodes.remove_layer('EXEC_DEFAULT', - layer_obj_name=layer_obj.name) - return {'FINISHED'} + def get_layers(self, context): + def is_not_missing(layer_obj, context): + return not is_missing_layer(layer_obj) + return get_selected_layers(context, is_not_missing) - def invoke(self, context, event): - context.window_manager.invoke_confirm(self, - event, - message=f"Are you sure to remove all **Missing** layers?") - return {'RUNNING_MODAL'} + +class RemoveSelectedLayers(bpy.types.Operator, RemoveLayers): + bl_idname = "bioxelnodes.remove_selected_layers" + bl_label = "Remove Selected Layers" + bl_description = "Remove selected layers" + bl_icon = "TRASH" + + success_msg = "Successfully removed all selected layers." + + def get_layers(self, context): + return get_selected_layers(context) diff --git a/bioxelnodes/operators/misc.py b/bioxelnodes/operators/misc.py index 5d378c9..52e848b 100644 --- a/bioxelnodes/operators/misc.py +++ b/bioxelnodes/operators/misc.py @@ -1,54 +1,51 @@ import bpy from pathlib import Path import shutil -from .utils import get_cache_dir -from ..nodes import custom_nodes -from ..bioxelutils.utils import (get_container_objs_from_selection, - get_all_layer_objs, - get_container_layer_objs) -CLASS_PREFIX = "BIOXELNODES_MT_NODES" +from ..bioxelutils.common import get_all_layer_objs, is_missing_layer, set_file_prop +from .layer import RemoveLayers, SaveLayersCache +from ..constants import NODE_LIB_FILEPATH, VERSION +from ..utils import get_cache_dir -class ReLinkNodes(bpy.types.Operator): - bl_idname = "bioxelnodes.relink_nodes" - bl_label = "Relink Nodes to Addon" - bl_description = "Relink all nodes to addon source" + +class ReLinkNodeLib(bpy.types.Operator): + bl_idname = "bioxelnodes.relink_node_lib" + bl_label = "Relink Node Library" + bl_description = "Relink all nodes to addon library source" + bl_options = {'UNDO'} + + node_lib_filename: bpy.props.StringProperty( + default="" + ) # type: ignore def execute(self, context): - file_name = Path(custom_nodes.nodes_file).name - for lib in bpy.data.libraries: - lib_path = Path(bpy.path.abspath(lib.filepath)).resolve() - lib_name = lib_path.name - if lib_name == file_name: - if str(lib_path) != custom_nodes.nodes_file: - lib.filepath = custom_nodes.nodes_file + lib_filepath = Path(NODE_LIB_FILEPATH.parent, + f"{self.node_lib_filename}.blend") + node_libs = [] + for node_group in bpy.data.node_groups: + if node_group.name.startswith("BioxelNodes"): + node_lib = node_group.library + if node_lib: + node_libs.append(node_lib) + + node_libs = list(set(node_libs)) + + for node_lib in node_libs: + node_lib.filepath = str(lib_filepath) + # FIXME: may cause crash + node_lib.reload() self.report({"INFO"}, f"Successfully relinked.") return {'FINISHED'} -class SaveStagedData(bpy.types.Operator): - bl_idname = "bioxelnodes.save_staged_data" - bl_label = "Save Staged Data" - bl_description = "Save all staged data in this file for sharing" - - save_cache: bpy.props.BoolProperty( - name="Save Layer Caches", - default=True, - ) # type: ignore - - cache_dir: bpy.props.StringProperty( - name="Cache Directory", - subtype='DIR_PATH', - default="//" - ) # type: ignore - - save_lib: bpy.props.BoolProperty( - name="Save Node Library File", - default=True, - ) # type: ignore +class SaveNodeLib(bpy.types.Operator): + bl_idname = "bioxelnodes.save_node_lib" + bl_label = "Save Node Library" + bl_description = "Save node library file to local" + bl_options = {'UNDO'} lib_dir: bpy.props.StringProperty( name="Library Directory", @@ -57,98 +54,66 @@ class SaveStagedData(bpy.types.Operator): ) # type: ignore def execute(self, context): - if self.save_lib: - files = [] - for classname in dir(bpy.types): - if CLASS_PREFIX in classname: - cls = getattr(bpy.types, classname) - files.append(cls.nodes_file) - files = list(set(files)) - - for file in files: - file_name = Path(file).name - # "//" - lib_dir = bpy.path.abspath(self.lib_dir) - - output_path: Path = Path(lib_dir, file_name).resolve() - source_path: Path = Path(file).resolve() - - if output_path != source_path: - shutil.copy(source_path, output_path) - - for lib in bpy.data.libraries: - lib_path = Path(bpy.path.abspath(lib.filepath)).resolve() - if lib_path == source_path: - blend_path = Path(bpy.path.abspath("//")).resolve() - lib.filepath = bpy.path.relpath( - str(output_path), start=str(blend_path)) - - self.report({"INFO"}, f"Successfully saved to {output_path}") - - if self.save_cache: - fails = [] - for layer_obj in get_all_layer_objs(): - try: - bpy.ops.bioxelnodes.save_layer_cache('EXEC_DEFAULT', - layer_obj_name=layer_obj.name, - cache_dir=self.cache_dir) - except: - fails.append(layer_obj) - - if len(fails) == 0: - self.report({"INFO"}, f"Successfully saved bioxel layers.") - else: - self.report( - {"WARNING"}, f"{','.join([layer_obj.name for layer_obj in fails])} fail to save.") + lib_dir = bpy.path.abspath(self.lib_dir) + local_lib_path: Path = Path(lib_dir, NODE_LIB_FILEPATH.name).resolve() + addon_lib_path: Path = NODE_LIB_FILEPATH + blend_path = Path(bpy.path.abspath("//")).resolve() + + if local_lib_path != addon_lib_path: + shutil.copy(addon_lib_path, local_lib_path) + + libs = [] + for node_group in bpy.data.node_groups: + if node_group.name.startswith("BioxelNodes"): + if node_group.library: + libs.append(node_group.library) + + libs = list(set(libs)) + for lib in libs: + lib.filepath = bpy.path.relpath(str(local_lib_path), + start=str(blend_path)) + + set_file_prop("addon_version", VERSION) return {'FINISHED'} def invoke(self, context, event): - context.window_manager.invoke_props_dialog(self, - width=500) + context.window_manager.invoke_props_dialog(self) return {'RUNNING_MODAL'} @classmethod def poll(cls, context): return bpy.data.is_saved - def draw(self, context): - layout = self.layout - panel = layout.box() - panel.prop(self, "save_cache") - panel.prop(self, "cache_dir") - panel = layout.box() - panel.prop(self, "save_lib") - panel.prop(self, "lib_dir") - -class CleanAllCaches(bpy.types.Operator): - bl_idname = "bioxelnodes.clear_all_caches" - bl_label = "Clean All Caches in Temp" - bl_description = "Clean all caches saved in temp" +class CleanTemp(bpy.types.Operator): + bl_idname = "bioxelnodes.clear_temp" + bl_label = "Clean Temp" + bl_description = "Clean all cache in temp (include other project cache)" def execute(self, context): - cache_dir = get_cache_dir(context) + cache_dir = get_cache_dir() try: shutil.rmtree(cache_dir) - self.report({"INFO"}, f"Successfully cleaned caches.") + self.report({"INFO"}, f"Successfully cleaned temp.") return {'FINISHED'} except: self.report({"WARNING"}, - "Fail to clean caches, you may do it manually.") + "Fail to clean temp, you may do it manually.") return {'CANCELLED'} def invoke(self, context, event): context.window_manager.invoke_confirm(self, event, - message="All caches will be cleaned, include other project files, do you still want to clean?") + message="All temp files will be removed, include other project cache, do you still want to clean?") return {'RUNNING_MODAL'} class RenderSettingPreset(bpy.types.Operator): bl_idname = "bioxelnodes.render_setting_preset" - bl_label = "Render Setting Preset" - bl_description = "Render Setting Preset" + bl_label = "Render Setting Presets" + bl_description = "Render setting presets for bioxel" + bl_options = {'UNDO'} PRESETS = { "preview_e": "Preview (EEVEE)", @@ -190,21 +155,24 @@ def execute(self, context): bpy.context.scene.cycles.transparent_max_bounces = 16 bpy.context.scene.cycles.volume_preview_step_rate = 1 bpy.context.scene.cycles.volume_step_rate = 1 + # bpy.context.scene.cycles.use_fast_gi = True elif self.preset == "production_c": bpy.context.scene.render.engine = 'CYCLES' bpy.context.scene.cycles.shading_system = True bpy.context.scene.cycles.volume_bounces = 16 bpy.context.scene.cycles.transparent_max_bounces = 32 - bpy.context.scene.cycles.volume_preview_step_rate = 0.1 - bpy.context.scene.cycles.volume_step_rate = 0.1 + bpy.context.scene.cycles.volume_preview_step_rate = 0.5 + bpy.context.scene.cycles.volume_step_rate = 0.5 + # bpy.context.scene.cycles.use_fast_gi = False return {'FINISHED'} + class SliceViewer(bpy.types.Operator): bl_idname = "bioxelnodes.slice_viewer" bl_label = "Slice Viewer" - bl_description = "Slice Viewer" + bl_description = "A preview scene setting for viewing slicers" bl_icon = "FILE_VOLUME" def execute(self, context): @@ -215,7 +183,7 @@ def execute(self, context): bpy.context.scene.eevee.volumetric_samples = 128 bpy.context.scene.eevee.volumetric_ray_depth = 1 bpy.context.scene.eevee.use_volumetric_shadows = False - + for area in bpy.context.screen.areas: if area.type == 'VIEW_3D': area.spaces[0].shading.type = 'MATERIAL' @@ -224,5 +192,47 @@ def execute(self, context): area.spaces[0].shading.use_scene_lights = False area.spaces[0].shading.use_scene_world = False - - return {'FINISHED'} \ No newline at end of file + return {'FINISHED'} + + +def get_all_layers(layer_filter=None): + def _layer_filter(layer_obj): + return True + + layer_filter = layer_filter or _layer_filter + layer_objs = get_all_layer_objs() + return [obj for obj in layer_objs if layer_filter(obj)] + + +class SaveAllLayersCache(bpy.types.Operator, SaveLayersCache): + bl_idname = "bioxelnodes.save_all_layers_cache" + bl_label = "Save All Layers Cache" + bl_description = "Save all cache of this file" + bl_icon = "FILE_TICK" + + success_msg = "Successfully saved all layers." + + def get_layers(self, context): + def is_not_missing(layer_obj): + return not is_missing_layer(layer_obj) + return get_all_layers(is_not_missing) + + +class RemoveAllMissingLayers(bpy.types.Operator, RemoveLayers): + bl_idname = "bioxelnodes.remove_all_missing_layers" + bl_label = "Remove All Missing Layers" + bl_description = "Remove all current container missing layers" + bl_icon = "BRUSH_DATA" + + success_msg = "Successfully removed all missing layers." + + def get_layers(self, context): + def is_missing(layer_obj): + return is_missing_layer(layer_obj) + return get_all_layers(is_missing) + + def invoke(self, context, event): + context.window_manager.invoke_confirm(self, + event, + message=f"Are you sure to remove all **Missing** layers?") + return {'RUNNING_MODAL'} diff --git a/bioxelnodes/operators/node.py b/bioxelnodes/operators/node.py new file mode 100644 index 0000000..8ba94ce --- /dev/null +++ b/bioxelnodes/operators/node.py @@ -0,0 +1,62 @@ +import bpy + +from ..bioxelutils.common import get_file_prop, is_incompatible, local_lib_not_updated +from ..constants import VERSION +from ast import literal_eval +from ..bioxelutils.node import assign_node_group, get_node_group +from ..utils import get_use_link + + +class AddNode(bpy.types.Operator): + bl_idname = "bioxelnodes.add_node" + bl_label = "Add Node" + bl_options = {"REGISTER", "UNDO"} + + node_name: bpy.props.StringProperty( + default='', + ) # type: ignore + + node_label: bpy.props.StringProperty( + default='' + ) # type: ignore + + node_description: bpy.props.StringProperty( + default="", + ) # type: ignore + + @property + def node_type(self): + return f"BioxelNodes_{self.node_name}" + + def execute(self, context): + space = context.space_data + if space.type != "NODE_EDITOR": + self.report({"ERROR"}, "Not in node editor.") + return {'CANCELLED'} + + if not space.edit_tree.is_editable: + self.report({"ERROR"}, "Not editable.") + return {'CANCELLED'} + + if is_incompatible(): + self.report({"ERROR"}, + "Current addon verison is not compatible to this file. If you insist on editing this file please keep the same addon version.") + return {'CANCELLED'} + + get_node_group(self.node_type, get_use_link()) + bpy.ops.node.add_node( + 'INVOKE_DEFAULT', + type='GeometryNodeGroup', + use_transform=True + ) + node = bpy.context.active_node + assign_node_group(node, self.node_type) + node.label = self.node_label + + node.show_options = False + + if local_lib_not_updated(): + self.report({"WARNING"}, + "Local library version does not match the current addon version, which may cause problems, please save the node library again.") + + return {"FINISHED"} diff --git a/bioxelnodes/operators/utils.py b/bioxelnodes/operators/utils.py deleted file mode 100644 index 22de898..0000000 --- a/bioxelnodes/operators/utils.py +++ /dev/null @@ -1,60 +0,0 @@ -import bpy -from pathlib import Path - -from ..bioxelutils.utils import get_layer_prop_value -from .. import __package__ as base_package - - -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.bioxelnodes_progress_factor, - type="BAR", - text=context.window_manager.bioxelnodes_progress_text - ) - row.scale_x = 2 - - -def progress_update(context, factor, text=""): - context.window_manager.bioxelnodes_progress_factor = factor - context.window_manager.bioxelnodes_progress_text = text - - -def get_preferences(context): - return context.preferences.addons[base_package].preferences - - -def get_cache_dir(context): - preferences = get_preferences(context) - cache_path = Path(preferences.cache_dir, 'VDBs') - cache_path.mkdir(parents=True, exist_ok=True) - return str(cache_path) - - -def get_layer_item_label(context, layer_obj): - label = get_layer_label(layer_obj) - cache_filepath = Path(bpy.path.abspath(layer_obj.data.filepath)).resolve() - if cache_filepath.is_file(): - cache_dirpath = Path(get_cache_dir(context)) - if cache_dirpath in cache_filepath.parents: - label = "* " + label - - else: - label = "**MISSING**" + label - - return label - - -def get_layer_label(layer_obj): - name = get_layer_prop_value(layer_obj, "name") - kind = get_layer_prop_value(layer_obj, "kind") - - return f"{name}" diff --git a/bioxelnodes/preferences.py b/bioxelnodes/preferences.py index 754a64d..ff9f9ea 100644 --- a/bioxelnodes/preferences.py +++ b/bioxelnodes/preferences.py @@ -1,6 +1,7 @@ import bpy from pathlib import Path + class BioxelNodesPreferences(bpy.types.AddonPreferences): bl_idname = __package__ @@ -10,14 +11,19 @@ class BioxelNodesPreferences(bpy.types.AddonPreferences): default=str(Path(Path.home(), '.bioxelnodes')) ) # type: ignore - do_change_render_setting: bpy.props.BoolProperty( - name="Change Render Setting", + change_render_setting: bpy.props.BoolProperty( + name="Change Render Setting on First Import", default=True, ) # type: ignore + node_import_method: bpy.props.EnumProperty(name="Node Import Method", + default="LINK", + items=[("LINK", "Link", ""), + ("APPEND", "Append (Reuse Data)", "")]) # type: ignore + def draw(self, context): layout = self.layout - layout.label(text="Configuration") layout.prop(self, 'cache_dir') - layout.prop(self, "do_change_render_setting") + layout.prop(self, "change_render_setting") + layout.prop(self, "node_import_method") diff --git a/bioxelnodes/props.py b/bioxelnodes/props.py index 9dda4f8..e931831 100644 --- a/bioxelnodes/props.py +++ b/bioxelnodes/props.py @@ -1,6 +1,6 @@ import bpy -from .bioxelutils.utils import get_node_type +from .bioxelutils.common import get_node_type class BIOXELNODES_UL_layer_list(bpy.types.UIList): @@ -27,6 +27,8 @@ def select_layer(self, context): if node.inputs[0].default_value == layer_obj: node.select = True + layer_list_UL.layer_list_active = -1 + class BIOXELNODES_Layer(bpy.types.PropertyGroup): obj_name: bpy.props.StringProperty() # type: ignore diff --git a/bioxelnodes/utils.py b/bioxelnodes/utils.py index f9ae8ba..2bce786 100644 --- a/bioxelnodes/utils.py +++ b/bioxelnodes/utils.py @@ -1,5 +1,6 @@ from pathlib import Path import shutil +import bpy def copy_to_dir(source_path, dir_path, new_name=None, exist_ok=True): @@ -30,3 +31,42 @@ def copy_to_dir(source_path, dir_path, new_name=None, exist_ok=True): 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.bioxelnodes_progress_factor, + type="BAR", + text=context.window_manager.bioxelnodes_progress_text + ) + row.scale_x = 2 + + +def progress_update(context, factor, text=""): + context.window_manager.bioxelnodes_progress_factor = factor + context.window_manager.bioxelnodes_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, 'VDBs') + cache_path.mkdir(parents=True, exist_ok=True) + return str(cache_path) + + +def get_use_link(): + preferences = get_preferences() + return preferences.node_import_method == "LINK" diff --git a/pyproject.toml b/pyproject.toml index 14dd6f6..70a02b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bioxelnodes" -version = "0.4.0" +version = "1.0.0" description = "" authors = ["Ma Nan "] license = "MIT"