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 @@ auto_load.init() 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_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_4.2.blend b/bioxelnodes/assets/Nodes/BioxelNodes_v0.3.x.blend similarity index 100% rename from bioxelnodes/assets/Nodes/BioxelNodes_4.2.blend rename to bioxelnodes/assets/Nodes/BioxelNodes_v0.3.x.blend 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 0b46137..33020c1 100644 --- a/bioxelnodes/bioxel/layer.py +++ b/bioxelnodes/bioxel/layer.py @@ -2,12 +2,10 @@ import copy import numpy as np from . import scipy -from . import skimage as ski from . import scipy as ndi # 3rd-party import transforms3d - # TODO: turn to dataclasses @@ -71,23 +69,27 @@ class Layer(): 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,) @@ -98,20 +100,19 @@ class Layer(): _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']: - 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): @@ -122,13 +123,22 @@ class Layer(): # 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, :, :, :], - zoom_factors, + 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) @@ -137,7 +147,10 @@ class Layer(): # 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..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 @@ -42,7 +41,8 @@ DICOM_EXTS = ['', '.dcm', '.DCM', '.DICOM', '.ima', '.IMA'] 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 +64,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 +106,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 +140,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,7 +159,13 @@ def collect_sequence(filepath: Path): return sequence -def parse_volumetric_data(data_file: str, series_id="", progress_callback=None) -> Layer: +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): """Parse any volumetric data to numpy with shap (T,X,Y,Z,C) Args: @@ -139,7 +180,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 +222,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 +286,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 +338,10 @@ def parse_volumetric_data(data_file: str, series_id="", progress_callback=None) 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/common.py b/bioxelnodes/bioxelutils/common.py new file mode 100644 index 0000000..e2d2a4e --- /dev/null +++ b/bioxelnodes/bioxelutils/common.py @@ -0,0 +1,245 @@ +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] + 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_name: str): + node_group = layer_obj.modifiers[0].node_group + layer_node = get_nodes_by_type(node_group, "BioxelNodes__Layer")[0] + prop = layer_node.inputs.get(prop_name) + if prop is None: + return None + + value = prop.default_value + + if type(value).__name__ == "bpy_prop_array": + value = tuple(value) + return tuple([int(v) for v in value]) \ + if prop in ["shape"] else value + elif type(value).__name__ == "str": + return str(value) + elif type(value).__name__ == "float": + value = float(value) + return round(value, 2) \ + if prop in ["bioxel_size"] else value + elif type(value).__name__ == "int": + value = int(value) + return value + else: + return value + + +def get_layer_name(layer_obj): + return get_layer_prop_value(layer_obj, "name") + + +def get_layer_kind(layer_obj): + return get_layer_prop_value(layer_obj, "kind") + + +def get_layer_label(layer_obj): + name = get_layer_name(layer_obj) + # kind = get_layer_kind(layer_obj) + + label = f"{name}" + + if is_missing_layer(layer_obj): + return "**MISSING**" + label + elif is_temp_layer(layer_obj): + return "* " + label + else: + return label + + +def is_missing_layer(layer_obj): + cache_filepath = Path(bpy.path.abspath( + layer_obj.data.filepath)).resolve() + return not cache_filepath.is_file() + + +def is_temp_layer(layer_obj): + cache_filepath = Path(bpy.path.abspath( + layer_obj.data.filepath)).resolve() + cache_dirpath = Path(get_cache_dir()) + return cache_dirpath in cache_filepath.parents + + +def set_layer_prop_value(layer_obj: bpy.types.Object, prop: str, value): + node_group = layer_obj.modifiers[0].node_group + layer_node = get_nodes_by_type(node_group, "BioxelNodes__Layer")[0] + layer_node.inputs[prop].default_value = value + + +def get_layer_obj(current_obj: bpy.types.Object): + if current_obj: + if current_obj.get('bioxel_layer') and current_obj.parent: + if current_obj.parent.get('bioxel_container'): + return current_obj + return None + + +def get_container_layer_objs(container_obj: bpy.types.Object): + layer_objs = [] + for obj in bpy.data.objects: + if obj.parent == container_obj and get_layer_obj(obj): + layer_objs.append(obj) + + return layer_objs + + +def get_all_layer_objs(): + layer_objs = [] + for obj in bpy.data.objects: + if get_layer_obj(obj): + layer_objs.append(obj) + + return layer_objs + + +def add_driver(target, target_prop, var_sources, expression): + driver = target.driver_add(target_prop) + is_vector = isinstance(driver, list) + drivers = driver if is_vector else [driver] + + for i, driver in enumerate(drivers): + for j, var_source in enumerate(var_sources): + + source = var_source['source'] + prop = var_source['prop'] + + var = driver.driver.variables.new() + var.name = f"var{j}" + + var.targets[0].id_type = source.id_type + var.targets[0].id = source + var.targets[0].data_path = f'["{prop}"][{i}]'\ + if is_vector else f'["{prop}"]' + + driver.driver.expression = expression + + +def add_direct_driver(target, target_prop, source, source_prop): + drivers = [ + { + "source": source, + "prop": source_prop + } + ] + expression = "var0" + add_driver(target, target_prop, drivers, expression) + + +def read_file_prop(content: str): + props = {} + for line in content.split("\n"): + line = line.replace(" ", "") + p = line.split("=")[0] + if p != "": + v = line.split("=")[-1] + props[p] = v + return props + + +def write_file_prop(props: dict): + lines = [] + for p, v in props.items(): + lines.append(f"{p} = {v}") + return "\n".join(lines) + + +def set_file_prop(prop, value): + if bpy.data.texts.get("BioxelNodes") is None: + bpy.data.texts.new("BioxelNodes") + + props = read_file_prop(bpy.data.texts["BioxelNodes"].as_string()) + props[prop] = value + bpy.data.texts["BioxelNodes"].clear() + bpy.data.texts["BioxelNodes"].write(write_file_prop(props)) + + +def get_file_prop(prop): + if bpy.data.texts.get("BioxelNodes") is None: + bpy.data.texts.new("BioxelNodes") + + props = read_file_prop(bpy.data.texts["BioxelNodes"].as_string()) + return props.get(prop) + + +def 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 1f1dd8d..3be1f4c 100644 --- a/bioxelnodes/bioxelutils/container.py +++ b/bioxelnodes/bioxelutils/container.py @@ -5,10 +5,13 @@ from bpy_extras.io_utils import axis_conversion from mathutils import Matrix, Vector -from .layer import Layer, get_container_layer_objs, layer_to_obj, obj_to_layer -from ..nodes import custom_nodes -from .node import get_nodes_by_type, move_node_to_node - +from .layer import Layer, layer_to_obj, obj_to_layer +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", @@ -66,51 +69,6 @@ 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 add_layers(layers: list[Layer], - container_obj: bpy.types.Object, - cache_dir: str): - - container_node_group = container_obj.modifiers[0].node_group - - 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] - 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))) - - 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] @@ -119,8 +77,38 @@ def obj_to_container(container_obj: bpy.types.Object): return container +def add_layers(layers: list[Layer], + container_obj: bpy.types.Object, + cache_dir: str): + + node_group = container_obj.modifiers[0].node_group + 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 = 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 + + if len(output_node.inputs[0].links) == 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(fetch_node, output_node, (0, -100 * (i+1))) + + return container_obj + + def container_to_obj(container: Container, scene_scale: float, + step_size: float, cache_dir: str): # Wrapper a Container @@ -151,30 +139,33 @@ 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 - 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]) + modifier = container_obj.modifiers.new("GeometryNodes", 'NODES') + node_group = bpy.data.node_groups.new('GeometryNodes', 'GeometryNodeTree') + node_group.interface.new_socket(name="Output", + in_out="OUTPUT", + socket_type="NodeSocketGeometry") + modifier.node_group = node_group + node_group.nodes.new("NodeGroupOutput") - 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))) + 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..14a0df7 100644 --- a/bioxelnodes/bioxelutils/layer.py +++ b/bioxelnodes/bioxelutils/layer.py @@ -7,35 +7,11 @@ import pyopenvdb as vdb from pathlib import Path from uuid import uuid4 -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_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): @@ -51,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) @@ -59,15 +39,24 @@ 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 - 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 +99,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 +108,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 +126,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 +144,35 @@ 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_data = bpy.data.volumes.new(layer_display_name) + layer_data.render.space = 'WORLD' + 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]) - 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 + if layer.frame_count > 1: + layer_data.is_sequence = True + layer_data.frame_duration = layer.frame_count + else: + layer_data.is_sequence = False + layer_obj = bpy.data.objects.new(layer_display_name, layer_data) layer_obj['bioxel_layer'] = True - layer_obj.parent = container_obj - - for collection in layer_obj.users_collection: - collection.objects.unlink(layer_obj) - - for collection in container_obj.users_collection: - collection.objects.link(layer_obj) print(f"Creating Node for {layer.name}...") - bpy.ops.node.new_geometry_nodes_modifier() - node_group = layer_obj.modifiers[0].node_group - layer_node = custom_nodes.add_node(node_group, - "BioxelNodes__Layer") + 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 = 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 @@ -207,19 +183,18 @@ 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 - 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 +204,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 index bbea981..7279116 100644 --- a/bioxelnodes/bioxelutils/node.py +++ b/bioxelnodes/bioxelutils/node.py @@ -1,26 +1,68 @@ -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] +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 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) +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 - node.location.x = sum(xs) / len(xs) + offset[0] - node.location.y = sum(ys) / len(ys) + offset[1] + 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 get_node_type(node): - node_type = type(node).__name__ - if node_type == "GeometryNodeGroup": - node_type = node.node_tree.name - - return node_type +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 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 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 2477913..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.3.3" +version = "1.0.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/constants.py b/bioxelnodes/constants.py new file mode 100644 index 0000000..e93018e --- /dev/null +++ b/bioxelnodes/constants.py @@ -0,0 +1,224 @@ +from pathlib import Path + +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 = [ + { + 'label': 'Component', + 'icon': 'OUTLINER_DATA_VOLUME', + 'items': [ + { + 'label': 'Cutout by Threshold', + 'icon': 'EMPTY_SINGLE_ARROW', + 'name': 'CutoutByThreshold', + 'description': '' + }, + { + 'label': 'Cutout by Range', + 'icon': 'IPO_CONSTANT', + 'name': 'CutoutByRange', + 'description': '' + }, + { + 'label': 'Cutout by Color', + 'icon': 'COLOR', + 'name': 'CutoutByColor', + 'description': '' + }, + "separator", + { + 'label': 'To Surface', + 'icon': 'MESH_DATA', + 'name': 'ToSurface', + 'description': '' + }, + { + 'label': 'Join Component', + 'icon': 'CONSTRAINT_BONE', + 'name': 'JoinComponent', + 'description': '' + }, + { + 'label': 'Slice', + 'icon': 'TEXTURE', + 'name': 'Slice', + 'description': '' + } + ] + }, + { + 'label': 'Properties', + 'icon': 'PROPERTIES', + 'items': [ + { + 'label': 'Set Properties', + 'icon': 'PROPERTIES', + 'name': 'SetProperties', + 'description': '' + }, + "separator", + { + 'label': 'Set Color', + 'icon': 'IPO_SINE', + 'name': 'SetColor', + 'description': '' + }, + { + 'label': 'Set Color by Color', + 'icon': 'IPO_QUINT', + 'name': 'SetColorByColor', + 'description': '' + }, + { + 'label': 'Set Color by Ramp 2', + 'icon': 'IPO_QUAD', + 'name': 'SetColorByRamp2', + 'description': '' + }, + { + 'label': 'Set Color by Ramp 3', + 'icon': 'IPO_CUBIC', + 'name': 'SetColorByRamp3', + 'description': '' + }, + { + 'label': 'Set Color by Ramp 4', + 'icon': 'IPO_QUART', + 'name': 'SetColorByRamp4', + 'description': '' + }, + { + 'label': 'Set Color by Ramp 5', + 'icon': 'IPO_QUINT', + 'name': 'SetColorByRamp5', + 'description': '' + } + ] + }, + { + 'label': 'Surface', + 'icon': 'MESH_DATA', + 'items': [ + { + 'label': 'Membrane Shader', + 'icon': 'NODE_MATERIAL', + 'name': 'AssignMembraneShader', + 'description': '' + }, + { + 'label': 'Solid Shader', + 'icon': 'SHADING_SOLID', + 'name': 'AssignSolidShader', + 'description': '' + }, + { + 'label': 'Slime Shader', + 'icon': 'OUTLINER_DATA_META', + 'name': 'AssignSlimeShader', + 'description': '' + }, + + ] + }, + { + 'label': 'Transform', + 'icon': 'EMPTY_AXIS', + 'items': [ + { + 'label': 'Transform', + 'icon': 'EMPTY_AXIS', + 'name': 'Transform', + 'description': '' + }, + { + 'label': 'Transform Parent', + 'icon': 'ORIENTATION_PARENT', + 'name': 'TransformParent', + 'description': '' + }, + { + 'label': 'ReCenter', + 'icon': 'PROP_CON', + 'name': 'ReCenter', + 'description': '' + } + ] + }, + { + 'label': 'Cutters', + 'icon': 'MOD_BEVEL', + 'items': [ + { + 'label': 'Cut', + 'icon': 'MOD_BEVEL', + 'name': 'Cut', + 'description': '' + }, + "separator", + { + 'label': 'Primitive Cutter', + 'icon': 'MOD_LINEART', + 'name': 'PrimitiveCutter', + 'description': '' + }, + { + 'label': 'Object Cutter', + 'icon': 'MESH_PLANE', + 'name': 'ObjectCutter', + 'description': '' + } + ] + }, + { + 'label': 'Utils', + 'icon': 'MODIFIER', + 'items': [ + { + 'label': 'Pick Surface', + 'icon': 'OUTLINER_OB_MESH', + 'name': 'PickSurface', + 'description': '' + }, + { + 'label': 'Pick Volume', + 'icon': 'OUTLINER_OB_VOLUME', + 'name': 'PickVolume', + 'description': '' + }, + { + 'label': 'Pick Shape Wire', + 'icon': 'FILE_VOLUME', + 'name': 'PickShapeWire', + 'description': '' + }, + { + 'label': 'Pick Bbox Wire', + 'icon': 'MESH_CUBE', + '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': '' + } + ] + } +] 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 8f7652b..0000000 --- a/bioxelnodes/customnodes/nodes.py +++ /dev/null @@ -1,127 +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="Add custom node group.", - 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: - # 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 - 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 f289f9e..a120844 100644 --- a/bioxelnodes/menus.py +++ b/bioxelnodes/menus.py @@ -1,156 +1,160 @@ import bpy -from .bioxelutils.container import get_container_objs_from_selection -from .operators.layer import (CombineLabels, SignScalar, +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 (SaveContainer, LoadContainer, +from .operators.container import (AddLocator, AddSlicer, ExtractShapeWire, + SaveContainerLayersCache, RemoveContainerMissingLayers, + SaveContainer, LoadContainer, AddPieCutter, AddPlaneCutter, AddCylinderCutter, AddCubeCutter, AddSphereCutter, - PickBboxWire, PickMesh, PickVolume) -from .operators.io import (ExportVolumetricData, - ImportAsLabelLayer, ImportAsScalarLayer) -from .operators.misc import (CleanAllCaches, - ReLinkNodes, SaveCaches, SaveStagedData) + ExtractBboxWire, ExtractSurface, ExtractVolume, ContainerProps) + +from .operators.io import (ImportAsLabel, ImportAsScalar, ImportAsColor) +from .operators.misc import (CleanTemp, + ReLinkNodeLib, RemoveAllMissingLayers, RenderSettingPreset, SaveAllLayersCache, SaveNodeLib, SliceViewer) -def container_is_selected(): - container_objs = get_container_objs_from_selection() - return len(container_objs) > 0 +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) - - -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) + 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 Cutter to Container" + 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): - 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)" + 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): 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.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 + 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() - 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) + for version in versions: + op = layout.operator(ReLinkNodeLib.bl_idname, + text=version["label"]) + op.node_lib_filename = version["filename"] -def TOPBAR_FILE_IMPORT(self, context): - layout = self.layout - is_selected = container_is_selected() +class RenderSettingMenu(bpy.types.Menu): + bl_idname = "BIOXELNODES_MT_RENDER" + bl_label = "Render Setting Presets" - layout.separator() - layout.menu(ImportLayerMenu.bl_idname, text="Volumetric Data as Bioxel (Add to)" - if is_selected else "Volumetric Data as Bioxel (Init)") + 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_EXPORT(self, context): - layout = self.layout - layout.separator() - layout.operator(ExportVolumetricData.bl_idname, - text="Bioxel Layer (.vdb)") +class DangerZoneMenu(bpy.types.Menu): + bl_idname = "BIOXELNODES_MT_DANGER" + bl_label = "Danger Zone" - -def VIEW3D_OBJECT(self, context): - layout = self.layout - layout.separator() - layout.menu(BioxelNodesView3DMenu.bl_idname, icon="FILE_VOLUME") - - -def OUTLINER_OBJECT(self, context): - layout = self.layout - layout.separator() - layout.menu(BioxelNodesOutlinerMenu.bl_idname, icon="FILE_VOLUME") + def draw(self, context): + layout = self.layout + layout.operator(CleanTemp.bl_idname) class BioxelNodesTopbarMenu(bpy.types.Menu): @@ -159,21 +163,24 @@ class BioxelNodesTopbarMenu(bpy.types.Menu): def draw(self, context): 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.separator() + layout.menu(ImportLayerMenu.bl_idname, + icon=ImportLayerMenu.bl_icon) layout.operator(LoadContainer.bl_idname) - layout.operator(SaveContainer.bl_idname) + layout.separator() - layout.menu(AddCutterMenu.bl_idname) - layout.menu(PickFromContainerMenu.bl_idname) + layout.operator(SaveAllLayersCache.bl_idname) + layout.operator(RemoveAllMissingLayers.bl_idname) + layout.separator() - layout.operator(SaveStagedData.bl_idname) - layout.operator(ReLinkNodes.bl_idname) + layout.operator(SaveNodeLib.bl_idname) + layout.menu(ReLinkNodeLibMenu.bl_idname) + layout.separator() - layout.operator(CleanAllCaches.bl_idname) + layout.menu(DangerZoneMenu.bl_idname) + + layout.separator() + layout.menu(RenderSettingMenu.bl_idname) def TOPBAR(self, context): @@ -181,17 +188,194 @@ def TOPBAR(self, context): layout.menu(BioxelNodesTopbarMenu.bl_idname) +class NodeHeadMenu(bpy.types.Menu): + bl_idname = "BIOXELNODES_MT_NODE_HEAD" + bl_label = "Bioxel Nodes" + bl_icon = "FILE_VOLUME" + + def draw(self, context): + + layout = self.layout + layout.menu(AddLayerMenu.bl_idname, + icon=AddLayerMenu.bl_icon) + layout.operator(SaveContainer.bl_idname) + + layout.separator() + layout.operator(ContainerProps.bl_idname) + layout.menu(ExtractFromContainerMenu.bl_idname) + + layout.separator() + 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.separator() + layout.menu(FetchLayerMenu.bl_idname) + + +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.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_CONTEXT(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(NodeContextMenu.bl_idname) + + +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) + 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.clear() + + for layer_obj in get_container_layer_objs(container_obj): + layer_item = layer_list.add() + 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", + "frame_count", + "channel_count", + "min", "max"]]) + + 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(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): + layout = self.layout + layout.operator(SliceViewer.bl_idname, + icon=SliceViewer.bl_icon, text="") + + +node_menu = NodeMenu( + menu_items=MENU_ITEMS +) + + 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) + node_menu.register() + bpy.types.VIEW3D_HT_header.append(VIEW3D_TOPBAR) bpy.types.TOPBAR_MT_editor_menus.append(TOPBAR) + 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.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.VIEW3D_HT_header.remove(VIEW3D_TOPBAR) bpy.types.TOPBAR_MT_editor_menus.remove(TOPBAR) + 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/nodes.py b/bioxelnodes/nodes.py deleted file mode 100644 index a63e06e..0000000 --- a/bioxelnodes/nodes.py +++ /dev/null @@ -1,228 +0,0 @@ - -from pathlib import Path -from .customnodes import CustomNodes - -NODE_FILE = "BioxelNodes_4.2" - -MENU_ITEMS = [ - { - 'label': 'Methods', - 'icon': 'OUTLINER_DATA_VOLUME', - 'items': [ - { - 'label': 'Mask by Threshold', - 'icon': 'EMPTY_SINGLE_ARROW', - 'node_type': 'BioxelNodes_MaskByThreshold', - 'node_description': '' - }, - { - 'label': 'Mask by Range', - 'icon': 'IPO_CONSTANT', - 'node_type': 'BioxelNodes_MaskByRange', - 'node_description': '' - }, - { - 'label': 'Mask by Label', - 'icon': 'MESH_CAPSULE', - 'node_type': 'BioxelNodes_MaskByLabel', - 'node_description': '' - } - ] - }, - { - 'label': 'Shaders', - 'icon': 'SHADING_RENDERED', - 'items': [ - { - 'label': 'Membrane Shader', - 'icon': 'NODE_MATERIAL', - 'node_type': 'BioxelNodes_AssignMembraneShader', - 'node_description': '' - }, - { - 'label': 'Solid Shader', - 'icon': 'SHADING_SOLID', - 'node_type': 'BioxelNodes_AssignSolidShader', - 'node_description': '' - }, - { - 'label': 'Slime Shader', - 'icon': 'OUTLINER_DATA_META', - 'node_type': 'BioxelNodes_AssignSlimeShader', - 'node_description': '' - }, - { - 'label': 'Volume Shader', - 'icon': 'VOLUME_DATA', - 'node_type': 'BioxelNodes_AssignVolumeShader', - 'node_description': '' - }, - { - 'label': 'Universal Shader', - 'icon': 'MATSHADERBALL', - 'node_type': 'BioxelNodes_AssignUniversalShader', - 'node_description': '' - } - ] - }, - { - 'label': 'Colors', - 'icon': 'COLOR', - 'items': [ - { - 'label': 'Color Presets', - 'icon': 'COLOR', - 'node_type': 'BioxelNodes_ColorPresets', - 'node_description': '' - }, - { - 'label': 'Color Presets MRI', - 'icon': 'COLOR', - 'node_type': 'BioxelNodes_ColorPresets_MRI', - 'node_description': '' - }, - "separator", - { - 'label': 'Color Ramp 2', - 'icon': 'IPO_QUAD', - 'node_type': 'BioxelNodes_SetColorRamp2', - 'node_description': '' - }, - { - 'label': 'Color Ramp 3', - 'icon': 'IPO_CUBIC', - 'node_type': 'BioxelNodes_SetColorRamp3', - 'node_description': '' - }, - { - 'label': 'Color Ramp 4', - 'icon': 'IPO_QUART', - 'node_type': 'BioxelNodes_SetColorRamp4', - 'node_description': '' - }, - { - 'label': 'Color Ramp 5', - 'icon': 'IPO_QUINT', - 'node_type': 'BioxelNodes_SetColorRamp5', - 'node_description': '' - } - ] - }, - { - 'label': 'Cutters', - 'icon': 'MOD_BEVEL', - 'items': [ - { - 'label': 'Cut', - 'icon': 'MOD_BEVEL', - 'node_type': 'BioxelNodes_Cut', - 'node_description': '' - }, - "separator", - { - 'label': 'Primitive Cutter', - 'icon': 'MOD_LINEART', - 'node_type': 'BioxelNodes_PrimitiveCutter', - 'node_description': '' - }, - "separator", - { - 'label': 'Plane Cutter', - 'icon': 'MESH_PLANE', - 'node_type': 'BioxelNodes_PlaneObjectCutter', - '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': '', - } - ] - }, - { - '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', - 'node_type': 'BioxelNodes_PickMesh', - 'node_description': '' - }, - { - 'label': 'Pick Volume', - 'icon': 'OUTLINER_OB_VOLUME', - 'node_type': 'BioxelNodes_PickVolume', - 'node_description': '' - }, - { - 'label': 'Pick Bbox Wire', - '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': '' - } - ] - } -] - - -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/operators/container.py b/bioxelnodes/operators/container.py index 1106e78..ba9fc01 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 ..utils import get_cache_dir, get_use_link, select_object 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.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): @@ -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) @@ -65,11 +62,12 @@ class LoadContainer(bpy.types.Operator): 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: - 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'} @@ -79,39 +77,33 @@ class LoadContainer(bpy.types.Operator): return {'RUNNING_MODAL'} -class PickObject(): +class ExtractObject(): 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 - 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) @@ -120,71 +112,78 @@ class PickObject(): return {'FINISHED'} -class PickMesh(bpy.types.Operator, PickObject): - bl_idname = "bioxelnodes.pick_mesh" - bl_label = "Pick Mesh" - bl_description = "Pick Container Mesh" - object_type = "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 = "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" 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": + bpy.ops.mesh.primitive_plane_add( + size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1)) + pie_obj = bpy.context.active_object + orig_data = pie_obj.data + # Create mesh pie_mesh = bpy.data.meshes.new('Pie') + pie_obj.data = pie_mesh + bpy.data.meshes.remove(orig_data) - # Create object - pie = bpy.data.objects.new('Pie', pie_mesh) + # # 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 @@ -208,9 +207,22 @@ class AddCutter(): # 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 + + # 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 @@ -219,17 +231,28 @@ class AddCutter(): cutter_obj.visible_shadow = False cutter_obj.hide_render = True cutter_obj.display_type = 'WIRE' + cutter_obj.lineart.usage = 'EXCLUDE' + + 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 = 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 = 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: node_group.links.new(cut_node.outputs[0], output_node.inputs[0]) @@ -247,10 +270,13 @@ class AddCutter(): 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.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'} @@ -258,33 +284,242 @@ class AddCutter(): 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_description = "Add a plane cutter to current container" + 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_description = "Add a cylinder cutter to current container" + 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_description = "Add a cube cutter to current container" + 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_description = "Add a sphere cutter to current container" + 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_description = "Add a pie cutter to current container" + bl_icon = "MESH_CONE" + cutter_type = "pie" + + +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", + soft_min=0.0001, soft_max=10.0, + 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) + + 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 + container_obj["scene_scale"] = self.scene_scale + container_obj["step_size"] = self.step_size + + for layer_obj in get_container_layer_objs(container_obj): + layer_obj.data.render.space = 'WORLD' + layer_obj.data.render.step_size = self.scene_scale * self.step_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.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'} + + +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" + + success_msg = "Successfully saved all container layers." + + 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) + + +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" + + 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_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 9ba7910..7819256 100644 --- a/bioxelnodes/operators/io.py +++ b/bioxelnodes/operators/io.py @@ -5,20 +5,20 @@ import threading 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.common import (get_all_layer_objs, get_container_obj, + get_layer_obj, is_incompatible) 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, - 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() @@ -78,18 +78,28 @@ class ImportVolumetricData(): 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" - read_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" - read_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): @@ -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): @@ -123,9 +142,10 @@ class ParseVolumetricData(bpy.types.Operator): bl_options = {'UNDO'} meta = None + label_count = 0 + dtype = None thread = None _timer = None - container_obj_name = "" progress: bpy.props.FloatProperty(name="Progress", options={"SKIP_SAVE"}, @@ -142,9 +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", "")]) # 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 @@ -153,6 +174,15 @@ 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'} + data_path = Path(self.filepath).resolve() ext = get_ext(data_path) if ext not in SUPPORT_EXTS: @@ -182,6 +212,8 @@ class ParseVolumetricData(bpy.types.Operator): return self.meta = meta + self.label_count = int(np.max(data)) + self.dtype = data.dtype # Init cancel flag self.is_cancelled = False @@ -207,7 +239,7 @@ class ParseVolumetricData(bpy.types.Operator): # 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 +259,7 @@ class ParseVolumetricData(bpy.types.Operator): 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: @@ -246,25 +279,57 @@ class ParseVolumetricData(bpy.types.Operator): 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'] - 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) + 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) + + 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) - log10 = math.floor(math.log10(max(*layer_size))) - log10 = max(1, log10) - log10 = min(3, log10) - scene_scale = math.pow(10, -log10) + bioxel_size, + 0.01) + min_log10 = math.floor(math.log10(min(*layer_size))) + max_log10 = math.floor(math.log10(max(*layer_size))) - if self.container_obj_name: - container_obj = bpy.data.objects.get(self.container_obj_name) + 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 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,8 +343,9 @@ class ParseVolumetricData(bpy.types.Operator): 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, + label_count=self.label_count, scene_scale=scene_scale ) @@ -287,18 +353,22 @@ class ParseVolumetricData(bpy.types.Operator): return {'FINISHED'} def invoke(self, context, event): - if not self.filepath: + # 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'} + 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 +437,7 @@ class ParseVolumetricData(bpy.types.Operator): 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, @@ -386,17 +455,17 @@ class ParseVolumetricData(bpy.types.Operator): 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 @@ -426,10 +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", "")]) # 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, @@ -447,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 - sequence_source: bpy.props.EnumProperty(name="Time Sequence From", - items=get_sequence_sources) # type: ignore + split_channel: bpy.props.BoolProperty(name="Split Channels", + default=False) # 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): @@ -481,26 +556,26 @@ class ImportVolumetricDataDialog(bpy.types.Operator): 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) @@ -516,6 +591,7 @@ class ImportVolumetricDataDialog(bpy.types.Operator): 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 = [] @@ -540,12 +616,13 @@ class ImportVolumetricDataDialog(bpy.types.Operator): 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,10 +630,82 @@ class ImportVolumetricDataDialog(bpy.types.Operator): 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']: + # 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) + + 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" - 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): @@ -573,12 +722,13 @@ class ImportVolumetricDataDialog(bpy.types.Operator): 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 +748,13 @@ class ImportVolumetricDataDialog(bpy.types.Operator): 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 +785,7 @@ class ImportVolumetricDataDialog(bpy.types.Operator): 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 +798,7 @@ class ImportVolumetricDataDialog(bpy.types.Operator): 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.") @@ -670,20 +822,25 @@ class ImportVolumetricDataDialog(bpy.types.Operator): container_obj = add_layers(self.layers, container_obj=container_obj, - cache_dir=get_cache_dir(context)) + cache_dir=get_cache_dir()) else: - container = Container(name=self.container_name, + 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) # Change render setting for better result if is_first_import: - change_render_setting(context) + bpy.ops.bioxelnodes.render_setting_preset('EXEC_DEFAULT', + preset="preview_c") + # bpy.ops.bioxelnodes.slice_viewer('EXEC_DEFAULT') self.report({"INFO"}, "Successfully Imported") return {'FINISHED'} @@ -706,54 +863,69 @@ class ImportVolumetricDataDialog(bpy.types.Operator): # 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.prop(self, "frame_source") - panel = layout.box() - panel.prop(self, "sequence_source") + if self.read_as == "SCALAR": + panel.prop(self, "split_channel", + text=f"Split Channel as Multi Layer") - 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.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 8c86923..16c8c0e 100644 --- a/bioxelnodes/operators/layer.py +++ b/bioxelnodes/operators/layer.py @@ -3,21 +3,14 @@ import bpy import numpy as np - +from ..exceptions import NoContent 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 ..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, copy_to_dir def get_label_layer_selection(self, context): @@ -25,86 +18,327 @@ 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 -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'} - base_obj: bpy.types.Object = None - def add_layer(self, context, layer): - preferences = get_preferences(context) - cache_dir = Path(preferences.cache_dir, 'VDBs') - container_obj = self.base_obj.parent + layer_obj_name: bpy.props.StringProperty( + options={"HIDDEN"}) # type: ignore - add_layers([layer], - container_obj, - cache_dir) - - select_object(container_obj) - - def invoke(self, context, event): - self.base_obj = get_layer_obj(bpy.context.active_object) - 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 + 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"]]) - -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 - - -class SignScalar(bpy.types.Operator, ScalarOperator): - bl_idname = "bioxelnodes.sign_scalar" - bl_label = "Sign Scalar" - bl_description = "Sign the scalar value" + @property + def layer_obj(self): + return bpy.data.objects.get(self.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 is_missing_layer(self.layer_obj): + self.report({"WARNING"}, "Selected layer is lost.") + return {'FINISHED'} - self.add_layer(context, modified_layer) + bpy.ops.bioxelnodes.add_node('EXEC_DEFAULT', + node_name="FetchLayer", + node_label="Fetch Layer") + node = bpy.context.active_node + + node.inputs[0].default_value = self.layer_obj + node.label = get_layer_label(self.layer_obj) + + return {"FINISHED"} + + +def get_selected_layers(context, layer_filter=None): + def _layer_filter(layer_obj, context): + return True + + 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) + + return list(set(select_objs)) + + +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 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=context.object, + cache_dir=get_cache_dir()) + + bpy.ops.bioxelnodes.fetch_layer('INVOKE_DEFAULT', + layer_obj_name=layer_obj.name) + + 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'} + + 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 FillOperator(ScalarOperator): +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): - 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'} + 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 FillByThreshold(bpy.types.Operator, FillOperator): - bl_idname = "bioxelnodes.fill_by_threshold" - bl_label = "Fill Value by Threshold" - bl_description = "Fill Value by Threshold" +class RelocateLayer(bpy.types.Operator, LayerOperator): + bl_idname = "bioxelnodes.relocate_layer" + bl_label = "Relocate Layer Cache" + bl_description = "Relocate layer cache" + bl_icon = "FILE" - threshold: bpy.props.FloatProperty( - name="Threshold", - soft_min=0, soft_max=1024, - default=128, + 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, + 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 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) + + 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): + 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) + + 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, "new_layer_name") + layout.prop(self, "bioxel_size") + layout.prop(self, "smooth") + layout.label(text=layer_shape_text) + + def invoke(self, context, event): + 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, + title=f"Resample {name}") + return {'RUNNING_MODAL'} + else: + return self.execute(context) + + +class SignScalar(bpy.types.Operator, OutputLayerOperator): + bl_idname = "bioxelnodes.sign_scalar" + bl_label = "Sign Value" + bl_description = "Sign value" + bl_icon = "REMOVE" + + 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): + 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(OutputLayerOperator): + fill_value: bpy.props.FloatProperty( name="Fill Value", soft_min=0, soft_max=1024.0, @@ -112,30 +346,52 @@ 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): + 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, + title=f"Fill {name}") + return {'RUNNING_MODAL'} + else: + return self.execute(context) - data = np.amax(base_layer.data, -1) + +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 + + 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 = base_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'} + 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( name="From Min", @@ -149,102 +405,200 @@ 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 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 = base_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'} + 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" - label_obj_name: bpy.props.EnumProperty( - name="Label Layer", - items=get_label_layer_selection - ) # type: ignore + smooth: bpy.props.IntProperty(name="Smooth Iteration", + default=0, + soft_min=0, soft_max=5, + options={"SKIP_SAVE"}) # type: ignore - fill_value: bpy.props.FloatProperty( - name="Fill Value", - soft_min=0, soft_max=1024.0, - default=0, - ) # type: ignore + label_obj_name: bpy.props.EnumProperty(name="Label Layer", + items=get_label_layer_selection) # type: ignore - invert: bpy.props.BoolProperty( - name="Invert Label", - default=True, - ) # type: ignore - - def execute(self, context): + 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'} - - base_layer = obj_to_layer(self.base_obj) + if label_obj is None: + raise NoContent("Cannot find any label layer.") label_layer = obj_to_layer(label_obj) - label_layer.resize(base_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 = base_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'} + 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, LabelOperator): +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 context.selected_ids - 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"{'-'.join(label_names)}-Combined" + new_layer.name = self.new_layer_name \ + or f"C-{'-'.join(label_names)}" - self.add_layer(context, modified_layer) + self.add_layer_node(context, new_layer) + return {'FINISHED'} + + +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", + subtype='DIR_PATH', + default="//" + ) # type: ignore + + def operate(self, layer_obj, context): + output_dir = bpy.path.abspath(self.cache_dir) + source_dir = bpy.path.abspath(layer_obj.data.filepath) + + source_path: Path = Path(source_dir).resolve() + is_sequence = layer_obj.data.is_sequence + + 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)) + + return {'FINISHED'} + + def invoke(self, context, event): + context.window_manager.invoke_props_dialog(self) + return {'RUNNING_MODAL'} + + +class RemoveLayers(BatchLayerOperator): + fail_msg = "fail to remove." + + 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( + layer_obj.data.filepath)).resolve() + + if cache_filepath.is_file(): + 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(layer_obj.data) + + return {'FINISHED'} + + def invoke(self, context, event): + 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" + + success_msg = "Successfully saved all selected layers." + + 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) + + +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 c2a7a27..52e848b 100644 --- a/bioxelnodes/operators/misc.py +++ b/bioxelnodes/operators/misc.py @@ -1,81 +1,51 @@ -import re import bpy from pathlib import Path import shutil +from ..bioxelutils.common import get_all_layer_objs, is_missing_layer, set_file_prop +from .layer import RemoveLayers, SaveLayersCache -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 - -CLASS_PREFIX = "BIOXELNODES_MT_NODES" +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'} -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" - 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", @@ -84,132 +54,185 @@ 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)) + 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() - for file in files: - file_name = Path(file).name - # "//" - lib_dir = bpy.path.abspath(self.lib_dir) + if local_lib_path != addon_lib_path: + shutil.copy(addon_lib_path, local_lib_path) - output_path: Path = Path(lib_dir, file_name).resolve() - source_path: Path = Path(file).resolve() + libs = [] + for node_group in bpy.data.node_groups: + if node_group.name.startswith("BioxelNodes"): + if node_group.library: + libs.append(node_group.library) - if output_path != source_path: - shutil.copy(source_path, output_path) + libs = list(set(libs)) + for lib in libs: + lib.filepath = bpy.path.relpath(str(local_lib_path), + start=str(blend_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 in get_all_layer_objs(): - try: - save_layer_cache(layer, self.cache_dir) - except: - fails.append(layer) - - 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.") + 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 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 +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): - 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" - bl_description = "Clean all caches saved in temp" - - 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 Presets" + bl_description = "Render setting presets for bioxel" + bl_options = {'UNDO'} + + PRESETS = { + "preview_e": "Preview (EEVEE)", + "preview_c": "Preview (Cycles)", + "production_e": "Production (EEVEE)", + "production_c": "Production (Cycles)" + } + + preset: bpy.props.EnumProperty(name="Preset", + default="preview_c", + items=[(k, v, "") + for k, v in PRESETS.items()]) # type: ignore + + def execute(self, context): + 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 + 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 == "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 + 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 == "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 = 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.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 = "A preview scene setting for viewing slicers" + 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'} + + +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 d1fda4f..0000000 --- a/bioxelnodes/operators/utils.py +++ /dev/null @@ -1,60 +0,0 @@ -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 - - -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) 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 d46bfd6..e931831 100644 --- a/bioxelnodes/props.py +++ b/bioxelnodes/props.py @@ -1,5 +1,44 @@ import bpy +from .bioxelutils.common 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 + + layer_list_UL.layer_list_active = -1 + + +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/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/build.py b/build.py index 24a4b35..0efcc3d 100644 --- a/build.py +++ b/build.py @@ -16,7 +16,8 @@ required_packages = ["SimpleITK==2.3.1", "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..70a02b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bioxelnodes" -version = "0.3.3" +version = "1.0.0" description = "" authors = ["Ma Nan "] license = "MIT"