diff --git a/README.md b/README.md index 2b7082a..89e3b08 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,20 @@ Bioxel Nodes is a Blender add-on for scientific volumetric data visualization. It using Blender's powerful Geometry Nodes | Cycles to process and render volumetric data. +# About + Before us, there have been many tutorials and add-ons for importing volumetric data into Blender. However, we found that there were many details that were not addressed in place, some scientific facts were ignored, and the volume rendering was not pretty enough. With Bioxel Nodes, you can easily import the volumetric data into Blender, and more importantly, it can quickly make a beautiful realistic rendering of it. -Below are some examples with BioxelNodes. Thanks to Cycles Render, the volumetric data can be rendered with great detail: +Below are some examples with Bioxel Nodes. Thanks to Cycles Render, the volumetric data can be rendered with great detail: ![gallery](docs/images/gallery.png) +### Known limitations + +- Cannot read DICOM with multiple series. +- Sections cannot be generated +- Only sequence files are supported, packed volume data formats are not supported yet (will be supported soon) + # Getting Started Currently only support Blender 4.1 or above, make sure you have the correct version of Blender. @@ -76,12 +84,6 @@ You can add a "Bake" node between segmentation node and shader node, if the reco ![bake node](docs/images/bake_node.png) -# Known limitations - -- Cannot read DICOM with multiple series. -- Sections cannot be generated -- Only sequence files are supported, packed volume data formats are not supported yet (will be supported soon) - # Future Features - Support more volumetric data formats (.nii, .map, .txm...) @@ -91,7 +93,7 @@ You can add a "Bake" node between segmentation node and shader node, if the reco # Technical Notes -The "Bioxel" in "BioxelNodes", is a combination of the words "Bio-" and "Voxel". Bioxel is a voxel that stores biological data, so maybe its chinese name should be "生素" 😂? The volumetric data made up of Bioxel is called Bioxels. We are developing a toolkit around Bioxels for better biological data visualization. but before its release, we made this Blender version of bioxels toolkit first, in order to let more people to have fun with volumetric data. Because we have been pursuing one thing: +The "Bioxel" in "Bioxel Nodes", is a combination of the words "Bio-" and "Voxel". Bioxel is a voxel that stores biological data, so maybe its chinese name should be "生素" 😂? The volumetric data made up of Bioxel is called Bioxels. We are developing a toolkit around Bioxels for better biological data visualization. but before its release, we made this Blender version of bioxels toolkit first, in order to let more people to have fun with volumetric data. Because we have been pursuing one thing: **Science that can be beautiful at the same time.** ### Bioxels @@ -106,4 +108,4 @@ Bioxels is based entirely on OpenVDB for storage and rendering. The main reason ### Based on Geometry Nodes -Bioxel Nodes relies on Blender Geometry Nodes to reconstruct and render volumetric data. Node-based operations ensure that the original data is not permanently altered during reconstruction and rendering operations. The fact that the processing is based on Geometry Nodes without any additional dependencies also ensures that Blender can open files without this plugin installed. Look for more support for OpenVDB in GeometryNodes so that BioxelNodes can do more in the future. +Bioxel Nodes relies on Blender Geometry Nodes to reconstruct and render volumetric data. Node-based operations ensure that the original data is not permanently altered during reconstruction and rendering operations. The fact that the processing is based on Geometry Nodes without any additional dependencies also ensures that Blender can open files without this plugin installed. Look for more support for OpenVDB in GeometryNodes so that Bioxel Nodes can do more in the future. diff --git a/bioxelnodes/__init__.py b/bioxelnodes/__init__.py index bdbdbda..a664e24 100644 --- a/bioxelnodes/__init__.py +++ b/bioxelnodes/__init__.py @@ -18,6 +18,12 @@ bl_info = { auto_load.init() +bpy.types.Scene.bioxels_dir = bpy.props.StringProperty( + name="Bioxels Directory", + subtype='DIR_PATH', + default="//" +) + def register(): auto_load.register() diff --git a/bioxelnodes/convert.py b/bioxelnodes/convert.py index bfbc12c..d083800 100644 --- a/bioxelnodes/convert.py +++ b/bioxelnodes/convert.py @@ -1,5 +1,5 @@ import bpy -from .utils import get_bioxels_obj +from .utils import get_bioxels_obj, get_node_by_type class ConvertToMesh(bpy.types.Operator): @@ -8,6 +8,15 @@ class ConvertToMesh(bpy.types.Operator): bl_description = "Convert Bioxels To Mesh." bl_options = {'UNDO'} + @classmethod + def poll(cls, context): + bioxels_obj = None + for obj in bpy.context.selected_objects: + bioxels_obj = get_bioxels_obj(obj) + break + + return True if bioxels_obj else False + def execute(self, context): bioxels_objs = [] @@ -16,7 +25,7 @@ class ConvertToMesh(bpy.types.Operator): if bioxels_obj: bioxels_objs.append(bioxels_obj) - if len(bioxels_objs)==0: + if len(bioxels_objs) == 0: self.report({"WARNING"}, "Cannot find any bioxels.") return {'FINISHED'} @@ -31,7 +40,7 @@ class ConvertToMesh(bpy.types.Operator): nodes = modifier.node_group.nodes links = modifier.node_group.links - output_node = nodes.get("Group Output") + output_node = get_node_by_type(nodes, 'NodeGroupOutput')[0] object_node = nodes.new("GeometryNodeObjectInfo") realize_nodes = nodes.new("GeometryNodeRealizeInstances") object_node.inputs[0].default_value = bioxels_obj @@ -43,4 +52,6 @@ class ConvertToMesh(bpy.types.Operator): bpy.context.object.active_material_index = 1 bpy.ops.object.material_slot_remove() + self.report({"INFO"}, f"Successfully convert to mesh") + return {'FINISHED'} diff --git a/bioxelnodes/customnodes/menu.py b/bioxelnodes/customnodes/menu.py index 1bdf719..d9f115a 100644 --- a/bioxelnodes/customnodes/menu.py +++ b/bioxelnodes/customnodes/menu.py @@ -12,10 +12,10 @@ class CustomNodes(): root_icon='NONE' ) -> None: if not Path(nodes_file).is_file(): - raise FileNotFoundError(Path(nodes_file).resolve().as_posix()) + raise FileNotFoundError(str(Path(nodes_file).resolve())) self.menu_items = menu_items - self.nodes_file = Path(nodes_file).resolve().as_posix() + self.nodes_file = str(Path(nodes_file).resolve()) self.root_label = root_label self.root_icon = root_icon @@ -28,7 +28,7 @@ class CustomNodes(): ) self.menu_classes = menu_classes - idname = f"CUSTOMNODES_MT_NODES__{root_label.replace(' ', '').upper()}" + idname = f"CUSTOMNODES_MT_NODES_{root_label.replace(' ', '').upper()}" def add_node_menu(self, context): if ('GeometryNodeTree' == bpy.context.area.spaces[0].tree_type): @@ -40,7 +40,7 @@ class CustomNodes(): 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 "CUSTOMNODES_MT_NODES_" + idname_namespace = idname_namespace or "CUSTOMNODES_MT_NODES" idname = f"{idname_namespace}_{label.replace(' ', '').upper()}" # create submenu class if item is menu. diff --git a/bioxelnodes/externalpackage/package.py b/bioxelnodes/externalpackage/package.py index bcde48c..c55ba62 100644 --- a/bioxelnodes/externalpackage/package.py +++ b/bioxelnodes/externalpackage/package.py @@ -231,7 +231,7 @@ class PackageInstaller(): """ # path to python.exe - python_exe = Path(sys.executable).resolve().as_posix() + python_exe = str(Path(sys.executable).resolve()) # build the command list cmd_list = [python_exe] + cmd_list diff --git a/bioxelnodes/externalpackage/preferences.py b/bioxelnodes/externalpackage/preferences.py index 092f258..3c99fed 100644 --- a/bioxelnodes/externalpackage/preferences.py +++ b/bioxelnodes/externalpackage/preferences.py @@ -14,13 +14,13 @@ class ExternalPackagePreferences(): requirements_dir: bpy.props.StringProperty( name="requirements.txt Directory", subtype='DIR_PATH', - default=Path(__file__).parent.as_posix() + default=str(Path(__file__).parent) ) # type: ignore log_dir: bpy.props.StringProperty( name="Python Log Directory", subtype='DIR_PATH', - default=Path(Path.home(), '.externalpackage', 'logs').as_posix() + default=str(Path(Path.home(), '.externalpackage', 'logs')) ) # type: ignore pypi_mirror_provider: bpy.props.StringProperty( @@ -40,7 +40,6 @@ class ExternalPackagePreferences(): row_import = col_main.row() row_import.prop(self, 'pypi_mirror_provider', text='Set PyPI Mirror') - installer = PackageInstaller( pypi_mirror_provider=self.pypi_mirror_provider, log_dir=self.log_dir, diff --git a/bioxelnodes/io.py b/bioxelnodes/io.py index 762bf4f..cbd153d 100644 --- a/bioxelnodes/io.py +++ b/bioxelnodes/io.py @@ -446,18 +446,18 @@ class ExportVDB(bpy.types.Operator): filename_ext = ".vdb" - def invoke(self, context, event): + @classmethod + def poll(cls, context): bioxels_obj = None for obj in bpy.context.selected_objects: bioxels_obj = get_bioxels_obj(obj) break - if bioxels_obj: - context.window_manager.fileselect_add(self) - return {'RUNNING_MODAL'} - else: - self.report({"WARNING"}, "Cannot find any bioxels.") - return {'CANCELLED'} + return True if bioxels_obj else False + + def invoke(self, context, event): + context.window_manager.fileselect_add(self) + return {'RUNNING_MODAL'} def execute(self, context): bioxels_obj = None @@ -466,12 +466,15 @@ class ExportVDB(bpy.types.Operator): break filepath = f"{self.filepath.split('.')[0]}.vdb" + # "//" + source_dir = bpy.path.abspath(bioxels_obj.data.filepath) + output_path: Path = Path(filepath).resolve() - source_path: Path = Path(bioxels_obj.data.filepath).resolve() + source_path: Path = Path(source_dir).resolve() # print('output_path', output_path) # print('source_path', source_path) shutil.copy(source_path, output_path) - self.report({"INFO"}, "Successfully Exported") + self.report({"INFO"}, f"Successfully exported to {output_path}") return {'FINISHED'} diff --git a/bioxelnodes/menu.py b/bioxelnodes/menu.py index 8971ec8..930a53a 100644 --- a/bioxelnodes/menu.py +++ b/bioxelnodes/menu.py @@ -1,8 +1,20 @@ +from bioxelnodes.misc import SaveBioxels import bpy from .convert import ConvertToMesh from .io import ExportVDB, ImportDICOM +class ObjectMenu(bpy.types.Menu): + bl_idname = "BIOXELNODES_MT_OBJECT" + bl_label = "Bioxels" + + def draw(self, context): + layout = self.layout + layout.operator(SaveBioxels.bl_idname) + layout.separator() + layout.operator(ConvertToMesh.bl_idname) + + def TOPBAR_FILE_IMPORT(self, context): layout = self.layout layout.separator() @@ -17,7 +29,8 @@ def TOPBAR_FILE_EXPORT(self, context): def VIEW3D_OBJECT(self, context): layout = self.layout - layout.operator(ConvertToMesh.bl_idname) + layout.menu(ObjectMenu.bl_idname) + layout.separator() diff --git a/bioxelnodes/misc.py b/bioxelnodes/misc.py new file mode 100644 index 0000000..a6183a8 --- /dev/null +++ b/bioxelnodes/misc.py @@ -0,0 +1,65 @@ +from pathlib import Path +import shutil +from bioxelnodes.utils import get_bioxels_obj +import bpy + + +class BIOXELNODES_PT_Bioxels(bpy.types.Panel): + bl_idname = "BIOXELNODES_PT_Bioxels" + bl_label = "Bioxels" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + + def draw(self, context): + layout = self.layout + scene = context.scene + layout.prop(scene, 'bioxels_dir') + + +class SaveBioxels(bpy.types.Operator): + bl_idname = "bioxelnodes.save_bioxels" + bl_label = "Save Bioxels" + bl_description = "Save Bioxels to Directory." + bl_options = {'UNDO'} + + @classmethod + def poll(cls, context): + bioxels_obj = None + for obj in bpy.context.selected_objects: + bioxels_obj = get_bioxels_obj(obj) + break + + return True if bioxels_obj else False + + def execute(self, context): + bioxels_objs = [] + + for obj in bpy.context.selected_objects: + bioxels_obj = get_bioxels_obj(obj) + if bioxels_obj: + bioxels_objs.append(bioxels_obj) + + if len(bioxels_objs) == 0: + self.report({"WARNING"}, "Cannot find any bioxels.") + return {'FINISHED'} + + for bioxels_obj in bioxels_objs: + name = bioxels_obj.parent.name + + # "//" + bioxels_dir = bpy.path.abspath(context.scene.bioxels_dir) + source_dir = bpy.path.abspath(bioxels_obj.data.filepath) + + output_path: Path = Path(bioxels_dir, f"{name}.vdb").resolve() + source_path: Path = Path(source_dir).resolve() + + if output_path != source_path: + shutil.copy(source_path, output_path) + + blend_path = Path(bpy.path.abspath("//")).resolve() + bioxels_obj.data.filepath = bpy.path.relpath( + str(output_path), start=str(blend_path)) + self.report({"INFO"}, f"Successfully saved to {output_path}") + + return {'FINISHED'} diff --git a/bioxelnodes/preferences.py b/bioxelnodes/preferences.py index cfb53d8..bb1036d 100644 --- a/bioxelnodes/preferences.py +++ b/bioxelnodes/preferences.py @@ -7,9 +7,9 @@ class BioxelNodesPreferences(bpy.types.AddonPreferences, ExternalPackagePreferen bl_idname = __package__ cache_dir: bpy.props.StringProperty( - name="Python Log Directory", + name="VDB Cache Directory", subtype='DIR_PATH', - default=Path(Path.home(), '.bioxelnodes').as_posix() + default=str(Path(Path.home(), '.bioxelnodes')) ) # type: ignore def draw(self, context): @@ -18,5 +18,5 @@ class BioxelNodesPreferences(bpy.types.AddonPreferences, ExternalPackagePreferen layout.prop(self, 'cache_dir', text='Set Cache Directory') # ExternalPackagePreferences Config - self.requirements_dir = Path(__file__).parent.as_posix() + self.requirements_dir = str(Path(__file__).parent) super().draw(context) diff --git a/docs/images/gallery.png b/docs/images/gallery.png index 0dc6427..2c1d3d6 100644 Binary files a/docs/images/gallery.png and b/docs/images/gallery.png differ