From dce9627d8212b598aea9a7c5a07ed85b5f69ac57 Mon Sep 17 00:00:00 2001 From: Ma Nan Date: Mon, 15 Apr 2024 09:36:13 +0800 Subject: [PATCH] first release --- .github/release-drafter.yml | 57 +++ .github/workflows/release-drafter.yml | 24 + .github/workflows/upload-assets.yml | 72 +++ README.md | 3 + {mednodes => bioxelnodes}/__init__.py | 9 +- bioxelnodes/assets/Nodes.blend | 3 + {mednodes => bioxelnodes}/auto_load.py | 2 +- bioxelnodes/convert.py | 45 ++ .../custom_nodes/__init__.py | 0 .../custom_nodes/node_menu.py | 17 +- .../custom_nodes/nodes.py | 63 +-- .../external_package/__init__.py | 0 .../external_package/package.py | 0 .../external_package/preferences.py | 0 bioxelnodes/io.py | 474 ++++++++++++++++++ bioxelnodes/menu.py | 33 ++ bioxelnodes/nodes.py | 109 ++++ {mednodes => bioxelnodes}/preferences.py | 4 +- {mednodes => bioxelnodes}/requirements.txt | 0 bioxelnodes/utils.py | 111 ++++ mednodes/assets/Nodes.blend | 3 - mednodes/dicom.py | 200 -------- mednodes/nodes.py | 75 --- 23 files changed, 956 insertions(+), 348 deletions(-) create mode 100644 .github/release-drafter.yml create mode 100644 .github/workflows/release-drafter.yml create mode 100644 .github/workflows/upload-assets.yml create mode 100644 README.md rename {mednodes => bioxelnodes}/__init__.py (69%) create mode 100644 bioxelnodes/assets/Nodes.blend rename {mednodes => bioxelnodes}/auto_load.py (98%) create mode 100644 bioxelnodes/convert.py rename {mednodes => bioxelnodes}/custom_nodes/__init__.py (100%) rename {mednodes => bioxelnodes}/custom_nodes/node_menu.py (89%) rename {mednodes => bioxelnodes}/custom_nodes/nodes.py (65%) rename {mednodes => bioxelnodes}/external_package/__init__.py (100%) rename {mednodes => bioxelnodes}/external_package/package.py (100%) rename {mednodes => bioxelnodes}/external_package/preferences.py (100%) create mode 100644 bioxelnodes/io.py create mode 100644 bioxelnodes/menu.py create mode 100644 bioxelnodes/nodes.py rename {mednodes => bioxelnodes}/preferences.py (79%) rename {mednodes => bioxelnodes}/requirements.txt (100%) create mode 100644 bioxelnodes/utils.py delete mode 100644 mednodes/assets/Nodes.blend delete mode 100644 mednodes/dicom.py delete mode 100644 mednodes/nodes.py diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..d2ef2fb --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,57 @@ +# release title (appears, for example, in repo's sidebar) +# NOTE: $RESOLVED_VERSION == $MAJOR.$MINOR.$PATCH +name-template: "v$RESOLVED_VERSION" + +# git tag to be used for the release +tag-template: "v$RESOLVED_VERSION" + +# Release Notes template (keep it as is) +template: | + ## What’s Changed + + $CHANGES + +change-template: '- $TITLE @$AUTHOR (#$NUMBER)' +change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. + +# Define which PR label will cause which kind of +# version bump (following semantic versioning). +# If no labels match, the default "patch". +version-resolver: + major: + labels: + - 'major' + minor: + labels: + - 'minor' + patch: + labels: + - 'patch' + default: patch + +# Define which PR label will be listed in which +# category of changes in the Release Notes. +# If no labels match, the default is to be +# listed on the top, before the sections. +categories: + - title: "🚨 BREAKING CHANGES 🚨" + labels: + - "BREAKING CHANGE" + - title: "πŸš€ New Features" + labels: + - "feature" + - "enhancement" + - title: "πŸ› Bug Fixes" + labels: + - "fix" + - "bug" + - title: "πŸ› οΈ Other Changes" + labels: + - "chore" + - "refactor" + - "documentation" + - "style" + - "test" + - "revert" + - "dependencies" + - "ci" \ No newline at end of file diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..ad0cf20 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,24 @@ +name: Release Drafter + +on: + push: + branches: + - main + pull_request: + types: [opened, reopened, synchronize, labeled, unlabeled] + +permissions: + contents: read + +jobs: + update_release_draft: + permissions: + # write permission required to create a github release + contents: write + # write permission required for autolabeler + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/upload-assets.yml b/.github/workflows/upload-assets.yml new file mode 100644 index 0000000..93eb9d3 --- /dev/null +++ b/.github/workflows/upload-assets.yml @@ -0,0 +1,72 @@ +name: Upload Assets to Release + +on: + push: + branches: + - "release/*" + +jobs: + # draft_release: + # name: Draft Release + # runs-on: ubuntu-latest + # outputs: + # upload_url: ${{ steps.draft_release.outputs.upload_url }} + # version: ${{ steps.set_env.outputs.version }} + # steps: + # - name: Set Version Env + # id: set_env + # run: | + # ref_name=${{ github.ref_name }} + # echo "version=${ref_name#release/}" >> "$GITHUB_OUTPUT" + # - name: Get release + # id: draft_release + # uses: cardinalby/git-get-release-action@1.2.4 + # with: + # releaseName: Draft + # env: + # GITHUB_TOKEN: ${{ github.token }} + + draft_release: + name: Draft Release + permissions: + contents: write + pull-requests: write + runs-on: ubuntu-latest + outputs: + upload_url: ${{ steps.draft_release.outputs.upload_url }} + version: ${{ steps.set_env.outputs.version }} + steps: + - name: Set Version Env + id: set_env + run: | + ref_name=${{ github.ref_name }} + echo "version=${ref_name#release/}" >> "$GITHUB_OUTPUT" + - name: Draft Release ${{ steps.set_env.outputs.version }} + uses: release-drafter/release-drafter@v5 + id: draft_release + with: + name: ${{ steps.set_env.outputs.version }} + tag: ${{ steps.set_env.outputs.version }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + upload_blender_addon: + name: Upload Blender Add-on + needs: draft_release + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + - name: Zip Add-on + run: | + zip -r package.zip bioxelnodes + - name: Upload Release Asset + id: upload-release-asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.draft_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps + asset_path: ./package.zip + asset_name: BioxelNodes_${{ needs.draft_release.outputs.version }}.zip + asset_content_type: application/zip diff --git a/README.md b/README.md new file mode 100644 index 0000000..18abd4a --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# BioxelNodes +BioxelNodes is a blender add-on for better volume data visualization with cycles. + diff --git a/mednodes/__init__.py b/bioxelnodes/__init__.py similarity index 69% rename from mednodes/__init__.py rename to bioxelnodes/__init__.py index a27affd..6270f64 100644 --- a/mednodes/__init__.py +++ b/bioxelnodes/__init__.py @@ -1,11 +1,12 @@ from .nodes import custom_nodes from . import auto_load -from .dicom import MEDNODES_add_topbar_menu +from . import menu + import bpy bl_info = { - "name": "MedNodes", + "name": "BiovelNodes", "author": "Ma Nan", "description": "", "blender": (4, 0, 0), @@ -21,12 +22,12 @@ auto_load.init() def register(): auto_load.register() custom_nodes.register() - bpy.types.TOPBAR_MT_file_import.prepend(MEDNODES_add_topbar_menu) + menu.create_menu() def unregister(): try: - bpy.types.TOPBAR_MT_file_import.remove(MEDNODES_add_topbar_menu) + menu.remove_menu() custom_nodes.unregister() auto_load.unregister() except RuntimeError: diff --git a/bioxelnodes/assets/Nodes.blend b/bioxelnodes/assets/Nodes.blend new file mode 100644 index 0000000..1e1f82b --- /dev/null +++ b/bioxelnodes/assets/Nodes.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:930786ba62844df527a9c7b7ee02920a117e80d7f4f899b91d807379e5e9a780 +size 1665260 diff --git a/mednodes/auto_load.py b/bioxelnodes/auto_load.py similarity index 98% rename from mednodes/auto_load.py rename to bioxelnodes/auto_load.py index 86fc69c..ba6d428 100644 --- a/mednodes/auto_load.py +++ b/bioxelnodes/auto_load.py @@ -129,7 +129,7 @@ def iter_classes_in_module(module): def get_register_base_types(): return set(getattr(bpy.types, name) for name in [ - "Panel", "Operator", "PropertyGroup", + "Panel", "Operator", "PropertyGroup", "FileHandler", "AddonPreferences", "Header", "Menu", "Node", "NodeSocket", "NodeTree", "UIList", "RenderEngine", diff --git a/bioxelnodes/convert.py b/bioxelnodes/convert.py new file mode 100644 index 0000000..8046fad --- /dev/null +++ b/bioxelnodes/convert.py @@ -0,0 +1,45 @@ +import bpy +from .utils import get_bioxels_obj + + +class ConvertToMesh(bpy.types.Operator): + bl_idname = "bioxelnodes.convert_to_mesh" + bl_label = "Bioxels To Mesh" + bl_description = "Convert Bioxels To Mesh." + bl_options = {'UNDO'} + + 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: + bpy.ops.mesh.primitive_cube_add( + size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1)) + mesh_obj = bpy.context.active_object + + mesh_obj.name = f"{bioxels_obj.parent.name}_Mesh" + bpy.ops.node.new_geometry_nodes_modifier() + modifier = mesh_obj.modifiers[0] + nodes = modifier.node_group.nodes + links = modifier.node_group.links + + output_node = nodes.get("Group Output") + object_node = nodes.new("GeometryNodeObjectInfo") + realize_nodes = nodes.new("GeometryNodeRealizeInstances") + object_node.inputs[0].default_value = bioxels_obj + object_node.transform_space = 'RELATIVE' + + links.new(object_node.outputs['Geometry'], realize_nodes.inputs[0]) + links.new(realize_nodes.outputs[0], output_node.inputs[0]) + bpy.ops.object.convert(target='MESH') + bpy.context.object.active_material_index = 1 + bpy.ops.object.material_slot_remove() + + return {'FINISHED'} diff --git a/mednodes/custom_nodes/__init__.py b/bioxelnodes/custom_nodes/__init__.py similarity index 100% rename from mednodes/custom_nodes/__init__.py rename to bioxelnodes/custom_nodes/__init__.py diff --git a/mednodes/custom_nodes/node_menu.py b/bioxelnodes/custom_nodes/node_menu.py similarity index 89% rename from mednodes/custom_nodes/node_menu.py rename to bioxelnodes/custom_nodes/node_menu.py index 9280b95..28b1645 100644 --- a/mednodes/custom_nodes/node_menu.py +++ b/bioxelnodes/custom_nodes/node_menu.py @@ -9,7 +9,7 @@ class CustomNodes(): menu_items, nodes_file, root_label='CustomNodes', - root_icon=88 + root_icon='NONE' ) -> None: if not Path(nodes_file).is_file(): raise FileNotFoundError(Path(nodes_file).resolve().as_posix()) @@ -34,7 +34,7 @@ class CustomNodes(): if ('GeometryNodeTree' == bpy.context.area.spaces[0].tree_type): layout = self.layout layout.separator() - layout.menu(idname, icon_value=root_icon) + layout.menu(idname, icon=root_icon) self.add_node_menu = add_node_menu @@ -71,17 +71,20 @@ class CustomNodes(): elif item.get('menu_class'): layout.menu( item.get('menu_class').bl_idname, - icon_value=item.get('icon') or 0 + icon=item.get('icon') or 'NONE' ) else: op = layout.operator( - 'customnodes.add_custom_node', text=item.get('label')) + '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_driver = item.get('node_driver') or "" + op.node_callback = item.get('node_callback') or "" menu_classes.append(Menu) return Menu @@ -108,9 +111,7 @@ class CustomNodes(): op.nodes_file = self.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_driver = item.get('node_driver') or "" + op.node_callback = item.get('node_callback') or "" return op.add_node(node_tree) else: raise RuntimeError("No custom node type found.") diff --git a/mednodes/custom_nodes/nodes.py b/bioxelnodes/custom_nodes/nodes.py similarity index 65% rename from mednodes/custom_nodes/nodes.py rename to bioxelnodes/custom_nodes/nodes.py index a15cf4c..3d2a248 100644 --- a/mednodes/custom_nodes/nodes.py +++ b/bioxelnodes/custom_nodes/nodes.py @@ -1,41 +1,6 @@ import bpy -def add_driver(target_prop, var_sources, expression): - - driver = target_prop.driver_add("default_value") - 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): - target_prop = target.inputs.get(target_prop) - drivers = [ - { - "source": source, - "prop": source_prop - } - ] - expression = "var0" - add_driver(target_prop, drivers, expression) - - class AddCustomNode(): nodes_file: bpy.props.StringProperty( @@ -57,11 +22,6 @@ class AddCustomNode(): default='' ) # type: ignore - node_driver: bpy.props.StringProperty( - name='node_driver', - default='' - ) # type: ignore - node_description: bpy.props.StringProperty( name="node_description", description="", @@ -69,9 +29,9 @@ class AddCustomNode(): subtype="NONE" ) # type: ignore - node_link: bpy.props.BoolProperty( - name='node_link', - default=True + node_callback: bpy.props.StringProperty( + name='node_callback', + default='' ) # type: ignore @classmethod @@ -85,19 +45,12 @@ class AddCustomNode(): node.label = self.node_label or self.node_type node.name = self.node_type - if self.node_driver: - source_prop = self.node_driver.split(":")[0] - target_prop = self.node_driver.split(":")[1] - add_direct_driver( - target=node, - target_prop=target_prop, - source=bpy.context.active_object, - source_prop=source_prop - ) + if self.node_callback: + exec(self.node_callback) return node - def append_node_tree(self, node_type): + def get_node_tree(self, node_type): # 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. @@ -128,7 +81,7 @@ class AddCustomNode(): ... def add_node(self, node_tree): - self.append_node_tree(self.node_type) + self.get_node_tree(self.node_type) node = node_tree.new("GeometryNodeGroup") @@ -143,7 +96,7 @@ class CUSTOMNODES_OT_Add_Custom_Node(bpy.types.Operator, AddCustomNode): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - self.append_node_tree(self.node_type) + self.get_node_tree(self.node_type) # intended to be called upon button press in the node tree prev_context = bpy.context.area.type diff --git a/mednodes/external_package/__init__.py b/bioxelnodes/external_package/__init__.py similarity index 100% rename from mednodes/external_package/__init__.py rename to bioxelnodes/external_package/__init__.py diff --git a/mednodes/external_package/package.py b/bioxelnodes/external_package/package.py similarity index 100% rename from mednodes/external_package/package.py rename to bioxelnodes/external_package/package.py diff --git a/mednodes/external_package/preferences.py b/bioxelnodes/external_package/preferences.py similarity index 100% rename from mednodes/external_package/preferences.py rename to bioxelnodes/external_package/preferences.py diff --git a/bioxelnodes/io.py b/bioxelnodes/io.py new file mode 100644 index 0000000..dca0600 --- /dev/null +++ b/bioxelnodes/io.py @@ -0,0 +1,474 @@ +import bpy +import shutil +from bpy_extras.io_utils import axis_conversion +import pyopenvdb as vdb +import numpy as np +from pathlib import Path +from uuid import uuid4 +import mathutils +from .utils import calc_bbox_verts, get_bioxels_obj, show_message +from .nodes import custom_nodes + +SUPPORT_EXTS = ['.dcm', '.tif', '.tiff', '.png', '.bmp'] + + +def on_orig_spacing_changed(self, context): + if self.auto: + self.auto = False + new_orig_spacing = tuple(self.orig_spacing) + + orig_shape = tuple(self.orig_shape) + bioxel_size = float(self.bioxel_size) + + self.bioxels_shape = ( + int(orig_shape[0] / bioxel_size * new_orig_spacing[0]), + int(orig_shape[1] / bioxel_size * new_orig_spacing[1]), + int(orig_shape[2] / bioxel_size * new_orig_spacing[2]), + ) + else: + self.auto = True + + +def on_bioxel_size_changed(self, context): + if self.auto: + self.auto = False + new_bioxel_size = float(self.bioxel_size) + + orig_shape = tuple(self.orig_shape) + orig_spacing = tuple(self.orig_spacing) + + self.bioxels_shape = ( + int(orig_shape[0] / new_bioxel_size * orig_spacing[0]), + int(orig_shape[1] / new_bioxel_size * orig_spacing[1]), + int(orig_shape[2] / new_bioxel_size * orig_spacing[2]), + ) + else: + self.auto = True + + +def on_info_changed(self, context): + for region in context.area.regions: + if region.type == "UI": + region.tag_redraw() + + +class ImportDICOMDialog(bpy.types.Operator): + bl_idname = "bioxelnodes.import_dicom_dialog" + bl_label = "Volume Data as Biovels" + bl_description = "Import Volume Data as Biovels (VDB)." + bl_options = {'UNDO'} + + filepath: bpy.props.StringProperty( + subtype="FILE_PATH", + options={'HIDDEN'} + ) # type: ignore + + bioxels_shape: bpy.props.IntVectorProperty( + name="Bioxels Shape", + min=0, + default=(100, 100, 100) + ) # type: ignore + + bioxels_scale: bpy.props.FloatProperty( + name="Bioxel Size Scale", + soft_min=0.001, soft_max=100.0, + min=1e-6, max=1e6, + default=0.01, + ) # type: ignore + + bioxel_size: bpy.props.FloatProperty( + name="Bioxel Size", + soft_min=0.1, soft_max=10.0, + min=1e-2, max=1e2, + default=1, + update=on_bioxel_size_changed + ) # type: ignore + + orig_spacing: bpy.props.FloatVectorProperty( + name="Original Spacing", + default=(1, 1, 1), + update=on_orig_spacing_changed + ) # type: ignore + + orig_shape: bpy.props.IntVectorProperty( + name="Original Shape", + default=(100, 100, 100), + options={'HIDDEN'} + ) # type: ignore + + auto: bpy.props.BoolProperty( + name="Auto Setting", + default=False, + options={'HIDDEN'} + ) # type: ignore + + do_add_segmentnode: bpy.props.BoolProperty( + name="Add Segment Node", + default=True, + ) # type: ignore + + do_change_render_setting: bpy.props.BoolProperty( + name="Change Render Setting", + default=True, + ) # type: ignore + + def execute(self, context): + if not self.filepath: + self.report({"WARNING"}, "Get no file path.") + return {'CANCELLED'} + + if Path(self.filepath).suffix not in SUPPORT_EXTS: + self.report({"WARNING"}, "Not Supported extension.") + return {'CANCELLED'} + + file_path = Path(self.filepath).resolve() + name = file_path.parent.stem + suffix = file_path.suffix + + files = list(file_path.parent.iterdir()) + files = [f.as_posix() + for f in files if f.is_file() and f.suffix == suffix] + + import SimpleITK as sitk + image = sitk.ReadImage(files) + + bioxel_size = float(self.bioxel_size) + + orig_spacing = tuple(self.orig_spacing) + image_spacing = image.GetSpacing() + image_shape = image.GetSize() + + bioxels_spacing = ( + image_spacing[0] / orig_spacing[0] * bioxel_size, + image_spacing[1] / orig_spacing[1] * bioxel_size, + image_spacing[2] / orig_spacing[2] * bioxel_size + ) + + bioxels_shape = ( + int(image_shape[0] / bioxel_size * orig_spacing[0]), + int(image_shape[1] / bioxel_size * orig_spacing[1]), + int(image_shape[2] / bioxel_size * orig_spacing[2]), + ) + + print("Bioxel Size:", bioxel_size) + print("Bioxels Shape:", bioxels_shape) + + print("Resampling...") + image = sitk.Resample( + image1=image, + size=bioxels_shape, + transform=sitk.Transform(), + interpolator=sitk.sitkLinear, + outputOrigin=image.GetOrigin(), + outputSpacing=bioxels_spacing, + outputDirection=image.GetDirection(), + defaultPixelValue=0, + outputPixelType=image.GetPixelID(), + ) + + try: + image = sitk.DICOMOrient(image, 'LPS') + except: + ... + + image_origin = image.GetOrigin() + bioxels_scale = float(self.bioxels_scale) + bioxel_size *= bioxels_scale + bioxel_origin = ( + image_origin[0] * bioxels_scale, + image_origin[1] * bioxels_scale, + image_origin[2] * bioxels_scale, + ) + bioxels_size = ( + bioxels_shape[0] * bioxel_size, + bioxels_shape[1] * bioxel_size, + bioxels_shape[2] * bioxel_size, + ) + direction = image.GetDirection() + + array = sitk.GetArrayFromImage(image) + orig_dtype = str(array.dtype) + print(f"Coverting Dtype {orig_dtype} -> float") + array = array.astype(float) + + # ITK indices, by convention, are [i,j,k] while NumPy indices are [k,j,i] + # https://www.slicer.org/wiki/Coordinate_systems + + # ITK Numpy 3D + # L (eft) i -> k -> x + # P (osterior) j -> j -> y + # S (uperior) k -> i -> z + + array = np.transpose(array) + value_max = float(np.max(array)) + value_min = float(np.min(array)) + + value_offset = 0.0 + if value_min < 0 and orig_dtype[0] != "u": + value_offset = -value_min + array = array + np.full_like(array, value_offset) + value_max = float(np.max(array)) + value_min = float(np.min(array)) + print("Offseted Max:", value_max) + print("Offseted Min:", value_min) + + # Build VDB + grid = vdb.FloatGrid() + grid.copyFromArray(array.copy()) + grid.name = "value" + + preferences = context.preferences.addons[__package__].preferences + vdb_dirpath = Path(preferences.cache_dir, 'VDBs') + vdb_dirpath.mkdir(parents=True, exist_ok=True) + vdb_path = Path(vdb_dirpath, f"{uuid4()}.vdb") + + print(f"Storing the VDB file ({vdb_path.as_posix()})...") + vdb.write(vdb_path.as_posix(), grids=[grid]) + + # Read VDB + print(f"Importing the VDB file to Blender scene...") + bpy.ops.object.volume_import( + filepath=vdb_path.as_posix(), align='WORLD', location=(0, 0, 0), scale=(1, 1, 1)) + + bioxels_obj = bpy.context.active_object + + # Set props to VDB object + bioxels_obj.name = f"{name}_Values" + bioxels_obj.data.name = f"{name}_Values" + + # Make transformation + + # (S)uperior -Z -> Y + # (P)osterior -Y -> Z + axis_rot = axis_conversion( + from_forward='-Z', + from_up='-Y', + to_forward='Y', + to_up='Z' + ).to_4x4() + + mat_loc = mathutils.Matrix.Translation( + mathutils.Vector(bioxel_origin)) + mat_sca = mathutils.Matrix.Scale(bioxel_size, 4) + mat_rot = mathutils.Matrix( + np.array(direction).reshape((3, 3)) + ).to_4x4() + + bioxels_obj.matrix_world = axis_rot @ mat_loc @ mat_rot @ mat_sca + + bioxels_obj.lock_location[0] = True + bioxels_obj.lock_location[1] = True + bioxels_obj.lock_location[2] = True + bioxels_obj.lock_rotation[0] = True + bioxels_obj.lock_rotation[1] = True + bioxels_obj.lock_rotation[2] = True + bioxels_obj.lock_scale[0] = True + bioxels_obj.lock_scale[1] = True + bioxels_obj.lock_scale[2] = True + bioxels_obj.hide_select = True + bioxels_obj['value_max'] = value_max + bioxels_obj['value_min'] = value_min + bioxels_obj['value_offset'] = value_offset + bioxels_obj['bioxels_shape'] = bioxels_shape + bioxels_obj['bioxels_size'] = bioxels_size + bioxels_obj['bioxel_size'] = bioxel_size + bioxels_obj['bioxel_origin'] = bioxel_origin + bioxels_obj['bioxels_values'] = True + + bpy.ops.node.new_geometry_nodes_modifier() + node_tree = bioxels_obj.modifiers[0].node_group + + # Wrapper a Container + bpy.ops.mesh.primitive_cube_add( + enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1) + ) + container_obj = bpy.context.active_object + + bbox_verts = calc_bbox_verts(bioxel_origin, bioxels_size) + for index, vert in enumerate(container_obj.data.vertices): + vert.co = bbox_verts[index] + + bioxels_obj.parent = container_obj + container_obj.name = name + container_obj.data.name = f"{name}_Container" + container_obj.visible_camera = False + container_obj.visible_diffuse = False + container_obj.visible_glossy = False + container_obj.visible_transmission = False + container_obj.visible_volume_scatter = False + container_obj.visible_shadow = False + container_obj.display_type = 'WIRE' + container_obj['bioxels'] = True + + bpy.ops.object.modifier_add(type='NODES') + container_obj.modifiers[0].node_group = node_tree + container_obj.modifiers[0].show_in_editmode = False + container_obj.modifiers[0].show_viewport = False + container_obj.modifiers[0].show_render = False + + # Create BioxelNodes to VDB object + print(f"Creating BioxelNodes to the VDB...") + + if self.do_add_segmentnode: + nodes = node_tree.nodes + links = node_tree.links + + input_node = nodes.get("Group Input") + output_node = nodes.get("Group Output") + segment_node = custom_nodes.add_node(nodes, 'BioxelNodes_Segment') + + links.new(input_node.outputs[0], segment_node.inputs[0]) + links.new(segment_node.outputs[0], output_node.inputs[0]) + + # Change render setting for better result + if self.do_change_render_setting: + bpy.context.scene.render.engine = 'CYCLES' + bpy.context.scene.cycles.volume_bounces = 12 + bpy.context.scene.cycles.transparent_max_bounces = 64 + + self.report({"INFO"}, "Successfully Imported") + + return {'FINISHED'} + + def invoke(self, context, event): + # print(tuple(self.orig_shape)) + self.auto = True + self.bioxel_size = 1 + context.window_manager.invoke_props_dialog(self) + return {'RUNNING_MODAL'} + + +class ReadDICOM(bpy.types.Operator): + bl_idname = "bioxelnodes.read_dicom" + bl_label = "Volume Data as Biovels" + bl_description = "Import Volume Data as Biovels (VDB)." + bl_options = {'UNDO'} + + filepath: bpy.props.StringProperty( + subtype="FILE_PATH" + ) # type: ignore + + ok: bpy.props.BoolProperty( + default=True + ) # type: ignore + + def execute(self, context): + + file_path = Path(self.filepath).resolve() + suffix = file_path.suffix + + files = list(file_path.parent.iterdir()) + files = [f.as_posix() + for f in files if f.is_file() and f.suffix == suffix] + + import SimpleITK as sitk + image = sitk.ReadImage(files) + + print("Collecting Meta Data...") + print("Original Shape:", image.GetSize()) + print("Original Spacing:", image.GetSpacing()) + print("Original Origin:", image.GetOrigin()) + print("Original Direction:", image.GetDirection()) + + bpy.ops.bioxelnodes.import_dicom_dialog( + 'INVOKE_DEFAULT', + filepath=self.filepath, + orig_shape=image.GetSize(), + orig_spacing=image.GetSpacing() + ) + + self.report({"INFO"}, "Successfully Readed.") + + return {'FINISHED'} + + def modal(self, context, event): + self.execute(context) + return {'FINISHED'} + + def invoke(self, context, event): + if not self.filepath: + return {'CANCELLED'} + + if Path(self.filepath).suffix not in SUPPORT_EXTS: + return {'CANCELLED'} + + show_message('Reading image data, it may take a while', + 'Please be patient...') + context.window_manager.modal_handler_add(self) + return {'RUNNING_MODAL'} + + +class ImportDICOM(bpy.types.Operator): + bl_idname = "bioxelnodes.import_dicom" + bl_label = "Volume Data as Biovels" + bl_description = "Import Volume Data as Biovels (VDB)." + bl_options = {'UNDO'} + + filepath: bpy.props.StringProperty( + subtype="FILE_PATH" + ) # type: ignore + + def execute(self, context): + bpy.ops.bioxelnodes.read_dicom( + 'INVOKE_DEFAULT', + filepath=self.filepath + ) + return {'FINISHED'} + + def invoke(self, context, event): + context.window_manager.fileselect_add(self) + return {'RUNNING_MODAL'} + + +class BIOXELNODES_FH_ImportDicom(bpy.types.FileHandler): + bl_idname = "BIOXELNODES_FH_ImportDicom" + bl_label = "File handler for dicom import" + bl_import_operator = "bioxelnodes.read_dicom" + bl_file_extensions = ".dcm" + + @classmethod + def poll_drop(cls, context): + return (context.area and context.area.type == 'VIEW_3D') + + +class ExportVDB(bpy.types.Operator): + bl_idname = "bioxelnodes.export_vdb" + bl_label = "Biovels as VDB" + bl_description = "Export Biovels original VDB data." + bl_options = {'UNDO'} + + filepath: bpy.props.StringProperty( + subtype="FILE_PATH" + ) # type: ignore + + filename_ext = ".vdb" + + def invoke(self, context, event): + 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: + context.window_manager.fileselect_add(self) + return {'RUNNING_MODAL'} + else: + self.report({"WARNING"}, "Cannot find any bioxels.") + return {'CANCELLED'} + + 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) + + bioxels_obj = bioxels_objs[0] + output_path: Path = Path(self.filepath).resolve() + source_path: Path = Path(bioxels_obj.data.filepath).resolve() + # print('output_path', output_path) + # print('source_path', source_path) + shutil.copy(source_path, output_path) + + return {'FINISHED'} diff --git a/bioxelnodes/menu.py b/bioxelnodes/menu.py new file mode 100644 index 0000000..8971ec8 --- /dev/null +++ b/bioxelnodes/menu.py @@ -0,0 +1,33 @@ +import bpy +from .convert import ConvertToMesh +from .io import ExportVDB, ImportDICOM + + +def TOPBAR_FILE_IMPORT(self, context): + layout = self.layout + layout.separator() + layout.operator(ImportDICOM.bl_idname) + + +def TOPBAR_FILE_EXPORT(self, context): + layout = self.layout + layout.separator() + layout.operator(ExportVDB.bl_idname) + + +def VIEW3D_OBJECT(self, context): + layout = self.layout + layout.operator(ConvertToMesh.bl_idname) + layout.separator() + + +def create_menu(): + bpy.types.TOPBAR_MT_file_import.append(TOPBAR_FILE_IMPORT) + bpy.types.TOPBAR_MT_file_export.append(TOPBAR_FILE_EXPORT) + bpy.types.VIEW3D_MT_object_context_menu.prepend(VIEW3D_OBJECT) + + +def remove_menu(): + bpy.types.TOPBAR_MT_file_import.remove(TOPBAR_FILE_IMPORT) + bpy.types.TOPBAR_MT_file_export.remove(TOPBAR_FILE_EXPORT) + bpy.types.VIEW3D_MT_object_context_menu.remove(VIEW3D_OBJECT) diff --git a/bioxelnodes/nodes.py b/bioxelnodes/nodes.py new file mode 100644 index 0000000..9a8ed9e --- /dev/null +++ b/bioxelnodes/nodes.py @@ -0,0 +1,109 @@ + +from .custom_nodes import CustomNodes +import bpy + + +def add_driver_to_node_factory(source_prop, target_prop): + callback_str = f""" +import bpy +from bioxelnodes.utils import add_direct_driver, get_bioxels_obj +bioxels_obj = get_bioxels_obj(bpy.context.active_object) +if bioxels_obj: + add_direct_driver( + target=node, + target_prop='{target_prop}', + source=bioxels_obj, + source_prop='{source_prop}' + ) +else: + print('Cannot find any bioxels.') + """ + return callback_str + + +MENU_ITEMS = [ + { + 'label': 'Present', + 'icon': 'MESH_MONKEY', + 'items': [ + { + 'label': 'Segment', + 'icon': 'OUTLINER_OB_META', + 'node_type': 'BioxelNodes_Segment', + 'node_description': '', + 'node_callback': add_driver_to_node_factory("value_offset", "Offset") + } + ] + }, + { + 'label': 'Segment', + 'icon': 'OUTLINER_DATA_VOLUME', + 'items': [ + { + 'label': 'Segment by Level', + 'icon': 'AREA_JOIN', + 'node_type': 'BioxelNodes_SegmentByLevel', + 'node_description': '', + 'node_callback': add_driver_to_node_factory("value_offset", "Offset") + }, + { + 'label': 'Segment by Range', + 'icon': 'AREA_SWAP', + 'node_type': 'BioxelNodes_SegmentByRange', + 'node_description': '', + 'node_callback': add_driver_to_node_factory("value_offset", "Offset") + }, + { + 'label': 'Segment by Layers', + 'icon': 'OUTLINER_OB_FORCE_FIELD', + 'node_type': 'BioxelNodes_SegmentsByLayers', + 'node_description': '', + 'node_callback': add_driver_to_node_factory("value_offset", "Offset") + } + ] + }, + { + 'label': 'Shader', + 'icon': 'SHADING_RENDERED', + 'items': [ + { + 'label': 'Segment Shader', + 'icon': 'OUTLINER_OB_VOLUME', + 'node_type': 'BioxelNodes_SegmentShader', + 'node_description': '' + } + ] + }, + { + 'label': 'Slicer', + 'icon': 'CLIPUV_DEHLT', + 'items': [ + { + 'label': 'LPS Slicer', + 'icon': 'EMPTY_DATA', + 'node_type': 'BioxelNodes_LPS-Slicer', + 'node_description': '', + 'node_callback': add_driver_to_node_factory("origin", "Origin") + }, + { + 'label': 'Axis Slicer', + 'icon': 'OUTLINER_OB_EMPTY', + 'node_type': 'BioxelNodes_AxisSlicer', + 'node_description': '', + }, + { + 'label': 'Box Slicer', + 'icon': 'MOD_WIREFRAME', + 'node_type': 'BioxelNodes_BoxSlicer', + 'node_description': '', + } + ] + }, +] + +custom_nodes = CustomNodes( + menu_items=MENU_ITEMS, + nodes_file="assets/Nodes.blend", + root_label='BioxelNodes', + root_icon="FILE_VOLUME", +) diff --git a/mednodes/preferences.py b/bioxelnodes/preferences.py similarity index 79% rename from mednodes/preferences.py rename to bioxelnodes/preferences.py index 7b1cde4..8f6214f 100644 --- a/mednodes/preferences.py +++ b/bioxelnodes/preferences.py @@ -3,13 +3,13 @@ from pathlib import Path from .external_package import ExternalPackagePreferences -class MedNodesPreferences(bpy.types.AddonPreferences, ExternalPackagePreferences): +class BioxelNodesPreferences(bpy.types.AddonPreferences, ExternalPackagePreferences): bl_idname = __package__ cache_dir: bpy.props.StringProperty( name="Python Log Directory", subtype='DIR_PATH', - default=Path(Path.home(), '.mednodes').as_posix() + default=Path(Path.home(), '.bioxelnodes').as_posix() ) # type: ignore diff --git a/mednodes/requirements.txt b/bioxelnodes/requirements.txt similarity index 100% rename from mednodes/requirements.txt rename to bioxelnodes/requirements.txt diff --git a/bioxelnodes/utils.py b/bioxelnodes/utils.py new file mode 100644 index 0000000..c12d4c7 --- /dev/null +++ b/bioxelnodes/utils.py @@ -0,0 +1,111 @@ +import bpy +import mathutils + +def show_message(message = "", title = "Message Box", icon = 'INFO'): + + def draw(self, context): + self.layout.label(text=message) + + bpy.context.window_manager.popup_menu(draw, title = title, icon = icon) + +def calc_bbox_verts(origin, size): + bbox_origin = mathutils.Vector( + (-origin[0], -origin[2], -origin[1])) + bbox_size = mathutils.Vector( + (-size[0], -size[2], -size[1])) + bbox_verts = [ + ( + bbox_origin[0] + 0, + bbox_origin[1] + 0, + bbox_origin[2] + 0 + ), + ( + bbox_origin[0] + 0, + bbox_origin[1] + 0, + bbox_origin[2] + bbox_size[2] + ), + ( + bbox_origin[0] + 0, + bbox_origin[1] + bbox_size[1], + bbox_origin[2] + 0 + ), + ( + bbox_origin[0] + 0, + bbox_origin[1] + bbox_size[1], + bbox_origin[2] + bbox_size[2] + ), + ( + bbox_origin[0] + bbox_size[0], + bbox_origin[1] + 0, + bbox_origin[2] + 0 + ), + ( + bbox_origin[0] + bbox_size[0], + bbox_origin[1] + 0, + bbox_origin[2] + bbox_size[2], + ), + ( + bbox_origin[0] + bbox_size[0], + bbox_origin[1] + bbox_size[1], + bbox_origin[2] + 0, + ), + ( + bbox_origin[0] + bbox_size[0], + bbox_origin[1] + bbox_size[1], + bbox_origin[2] + bbox_size[2], + ), + ] + return bbox_verts + + +def get_bioxels_obj(current_obj): + bioxels_obj = None + if current_obj.get('bioxels'): + bioxels_values = [] + for obj in bpy.data.objects: + if obj.parent == current_obj and obj.get('bioxels_values'): + bioxels_values.append(obj) + + if len(bioxels_values) > 0: + bioxels_obj = bioxels_values[0] + + elif current_obj.get('bioxels_values') and current_obj.parent: + if current_obj.parent.get('bioxels'): + bioxels_obj = current_obj + + return bioxels_obj + + +def add_driver(target_prop, var_sources, expression): + + driver = target_prop.driver_add("default_value") + 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): + target_prop = target.inputs.get(target_prop) + drivers = [ + { + "source": source, + "prop": source_prop + } + ] + expression = "var0" + add_driver(target_prop, drivers, expression) diff --git a/mednodes/assets/Nodes.blend b/mednodes/assets/Nodes.blend deleted file mode 100644 index 1933a41..0000000 --- a/mednodes/assets/Nodes.blend +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:fad3bd63d4c86693814b67c9efe09efc445415fa9e67ff7ab9eff20e51c70a53 -size 1769197 diff --git a/mednodes/dicom.py b/mednodes/dicom.py deleted file mode 100644 index 0871763..0000000 --- a/mednodes/dicom.py +++ /dev/null @@ -1,200 +0,0 @@ -import bpy -from bpy_extras.io_utils import axis_conversion, ImportHelper -import pyopenvdb as vdb -import numpy as np -from pathlib import Path -from uuid import uuid4 -import mathutils -from .nodes import custom_nodes - - -class ImportDICOM(bpy.types.Operator, ImportHelper): - bl_idname = "mednodes.import_dicom" - bl_label = "DICOM as VDB (.dcm)" - bl_description = "Load DICOM as VDB" - bl_options = {'UNDO'} - - filepath: bpy.props.StringProperty( - subtype="FILE_PATH" - ) # type: ignore - - global_scale: bpy.props.FloatProperty( - name="Scale", - soft_min=0.001, soft_max=100.0, - min=1e-6, max=1e6, - default=0.01, - ) # type: ignore - - do_resample: bpy.props.BoolProperty( - name="Uniform Spacing (Resample)", - default=True, - ) # type: ignore - - do_change_render_setting: bpy.props.BoolProperty( - name="Change Render Setting", - default=True, - ) # type: ignore - - def execute(self, context): - import SimpleITK as sitk - - preferences = context.preferences.addons[__package__].preferences - - file_path: Path = Path(self.filepath).resolve() - name = file_path.parent.stem - - reader = sitk.ImageSeriesReader() - dicom_names = reader.GetGDCMSeriesFileNames( - file_path.parent.as_posix()) - reader.SetFileNames(dicom_names) - image = reader.Execute() - - print("Spacing:", image.GetSpacing()) - print("Origin:", image.GetOrigin()) - print("Direction:", image.GetDirection()) - - image = sitk.DICOMOrient(image, 'LPS') - - size = image.GetSize() - spacing = image.GetSpacing() - origin = image.GetOrigin() - direction = image.GetDirection() - - print("Size:", size) - print("Spacing:", spacing) - print("Origin:", origin) - print("Direction:", direction) - - if self.do_resample: - resampled_spacing = (1, 1, 1) - resampled_size = ( - int(size[0]*spacing[0]), - int(size[1]*spacing[1]), - int(size[2]*spacing[2]), - ) - - print("Resampling...") - image = sitk.Resample( - image1=image, - size=resampled_size, - transform=sitk.Transform(), - interpolator=sitk.sitkLinear, - outputOrigin=origin, - outputSpacing=resampled_spacing, - outputDirection=direction, - defaultPixelValue=0, - outputPixelType=image.GetPixelID(), - ) - print("Resampled Size:", resampled_size) - - spacing = resampled_spacing - size = resampled_size - - array = sitk.GetArrayFromImage(image) - - # ITK indices, by convention, are [i,j,k] while NumPy indices are [k,j,i] - # https://www.slicer.org/wiki/Coordinate_systems - - # ITK Numpy 3D - # L (eft) i -> k -> x - # P (osterior) j -> j -> y - # S (uperior) k -> i -> z - - array = np.transpose(array) - value_max = float(np.max(array)) - value_min = float(np.min(array)) - - value_offset = 0.0 - if value_min < 0: - value_offset = -value_min - array = array + np.full_like(array, value_offset) - value_max = float(np.max(array)) - value_min = float(np.min(array)) - print("offset max value:", value_max) - print("offset min value:", value_min) - - # Build VDB - grid = vdb.FloatGrid() - grid.copyFromArray(array.copy()) - grid.name = "density" - - vdb_dirpath = Path(preferences.cache_dir, 'VDBs') - vdb_dirpath.mkdir(parents=True, exist_ok=True) - vdb_path = Path(vdb_dirpath, f"{uuid4()}.vdb") - vdb.write(vdb_path.as_posix(), grids=[grid]) - - # Read VDB - bpy.ops.object.volume_import( - filepath=vdb_path.as_posix(), align='WORLD', location=(0, 0, 0), scale=(1, 1, 1)) - - vdb_obj = bpy.context.active_object - - # Set props to VDB object - vdb_obj.name = name - vdb_obj.data.name = name - - vdb_obj['value_max'] = value_max - vdb_obj['value_min'] = value_min - vdb_obj['value_offset'] = value_offset - - vdb_obj['size'] = size - vdb_obj['spacing'] = spacing - vdb_obj['origin'] = origin - - # Make transformation - - # (S)uperior -Z -> Y - # (P)osterior -Y -> Z - axis_rot = axis_conversion( - from_forward='-Z', - from_up='-Y', - to_forward='Y', - to_up='Z' - ).to_4x4() - - mat_loc = mathutils.Matrix.Translation( - mathutils.Vector(origin) * self.global_scale) - mat_sca = mathutils.Matrix.Scale(self.global_scale, 4) - mat_rot = mathutils.Matrix( - np.array(direction).reshape((3, 3)) - ).to_4x4() - - vdb_obj.matrix_world = axis_rot @ mat_loc @ mat_rot @ mat_sca - - # Wrapper a Locator - bpy.ops.object.empty_add( - type='PLAIN_AXES', - align='WORLD', - location=(0, 0, 0), - scale=(1, 1, 1) - ) - loc_obj = bpy.context.active_object - loc_obj.name = f"LOC_{name}" - vdb_obj.parent = loc_obj - - # Create MedNodes to VDB object - bpy.context.view_layer.objects.active = vdb_obj - bpy.ops.node.new_geometry_nodes_modifier() - modifier = vdb_obj.modifiers[0] - nodes = modifier.node_group.nodes - links = modifier.node_group.links - - input_node = nodes.get("Group Input") - output_node = nodes.get("Group Output") - segment_node = custom_nodes.add_node(nodes, 'MedNodes_Segment') - - links.new(input_node.outputs[0], segment_node.inputs[0]) - links.new(segment_node.outputs[0], output_node.inputs[0]) - - # Change render setting for better result - if self.do_change_render_setting: - bpy.context.scene.render.engine = 'CYCLES' - bpy.context.scene.cycles.volume_bounces = 12 - bpy.context.scene.cycles.transparent_max_bounces = 64 - - return {'FINISHED'} - - -def MEDNODES_add_topbar_menu(self, context): - layout = self.layout - layout.operator(ImportDICOM.bl_idname) diff --git a/mednodes/nodes.py b/mednodes/nodes.py deleted file mode 100644 index 46a2ca9..0000000 --- a/mednodes/nodes.py +++ /dev/null @@ -1,75 +0,0 @@ -from .custom_nodes import CustomNodes - -MENU_ITEMS = [ - { - 'label': 'Present', - 'items': [ - { - 'label': 'Segment', - 'node_type': 'MedNodes_Segment', - 'node_description': '', - 'node_driver': 'value_offset:Offset' - } - ] - }, - { - 'label': 'Segment', - 'items': [ - { - 'label': 'Segment by Level', - 'node_type': 'MedNodes_SegmentByLevel', - 'node_description': '', - 'node_driver': 'value_offset:Offset' - }, - { - 'label': 'Segment by Range', - 'node_type': 'MedNodes_SegmentByRange', - 'node_description': '', - 'node_driver': 'value_offset:Offset' - }, - { - 'label': 'Segment by Layers', - 'node_type': 'MedNodes_SegmentsByLayers', - 'node_description': '', - 'node_driver': 'value_offset:Offset' - } - ] - }, - { - 'label': 'Shader', - 'items': [ - { - 'label': 'Segment Shader', - 'node_type': 'MedNodes_SegmentShader', - 'node_description': '' - } - ] - }, - { - 'label': 'Slicer', - 'items': [ - { - 'label': 'LPS Slicer', - 'node_type': 'MedNodes_LPS-Slicer', - 'node_description': '', - 'node_driver': 'origin:Origin' - }, - { - 'label': 'Axis Slicer', - 'node_type': 'MedNodes_AxisSlicer', - 'node_description': '', - }, - { - 'label': 'Box Slicer', - 'node_type': 'MedNodes_BoxSlicer', - 'node_description': '', - } - ] - }, -] - -custom_nodes = CustomNodes( - menu_items=MENU_ITEMS, - nodes_file="assets/Nodes.blend", - root_label='MedNodes' -)