feat: dramatic reduction in rendering time 🚀

feat: add more support to  eevee render
fix: fail to add utils node inflate
fix: file import button missing
chore: change operator names
BREAKING CHANGE: remove support to blender 4.0
BREAKING CHANGE: remove support to gpu render with cuda
This commit is contained in:
2024-07-09 15:20:26 +08:00
parent f90bf5c532
commit 99ce5c28d0
16 changed files with 576 additions and 623 deletions

View File

@@ -7,11 +7,11 @@
![GitHub Release](https://img.shields.io/github/v/release/OmooLab/BioxelNodes?style=for-the-badge)
![GitHub Repo stars](https://img.shields.io/github/stars/OmooLab/BioxelNodes?style=for-the-badge)
Bioxel Nodes is a Blender extension for scientific volumetric data visualization. It using Blender's powerful Geometry Nodes and Cycles to process and render volumetric data. You are free to share your blender file to anyone who does not install this extension, since most processes were done by Blender's native nodes.
Bioxel Nodes is a Blender addon for scientific volumetric data visualization. It using Blender's powerful Geometry Nodes and Cycles to process and render volumetric data. You are free to share your blender file to anyone who does not install this extension, since most processes were done by Blender's native nodes.
## About
Before us, there have been many tutorials and extensions for importing volumetric data into Blender. However, we found that there were many scientific issues that were not addressed in place, and the volume render results were not epic. With Bioxel Nodes, you can easily import any format volumetric data into Blender, and more importantly, make a beautiful realistic volume rendering quickly.
Before us, there have been many tutorials and addons for importing volumetric data into Blender. However, we found that there were many scientific issues that were not addressed in place, and the volume render results were not epic. With Bioxel Nodes, you can easily import any format volumetric data into Blender, and more importantly, make a beautiful realistic volume rendering quickly.
Below are some examples with Bioxel Nodes. Thanks to Cycles Render, the volumetric data can be rendered with great detail:
@@ -41,30 +41,29 @@ So how to use this extension? please check [Getting Started](https://omoolab.git
## Known Limitations
- Cycles CPUs only, Cycles GPUs (Optix), EEVEE (partial support)
- Sections cannot be generated (will be supported soon)
- Time sequence volume not supported (will be supported soon)
## Upgrade from 0.1.x to 0.2.x
## Compatibility to Newer Version
You need to do the following:
Addon are updating, and it is possible that newer versions of the addon will not work on old project files properly. In order to make the old files work, you can do the following:
1. Remove the old version of Bioxel Nodes at Preferences > Add-ons
2. Add the new version and restart Blender.
### For project files that need to be archived
It is not support editing the same blender file across extension versions. In order to make sure that the previous file works properly. You need to save the staged data before upgrading ( read the last section of [Getting Started](https://omoolab.github.io/BioxelNodes/latest/getting-started/#share-your-file) ).
Persistently save the Addon nodes before the addon is updated. This will put the nodes out of sync with the addon functionality, but it will ensure that the entire file can be calculated and rendered correctly, it fit to project files that you need to archive.
Bioxel Nodes > Save All Staged Data
But even then, there is still no guarantee that the new version of the extension will work on the old blender file. Therefore, it is highly recommended to open a new blender file to start the creating, not based on the old one.
### For working project files
Alternatively, objects from the old file that have nothing to do with Bioxel Nodes could be append to the new blender file.
If you've ever done a persistent save of Bioxel Nodes nodes, it's possible that after the addon update, there may be new features of the addon that don't synergize with the saved nodes. In order for the new version to work with the old nodes, you need to relink them.
Bioxel Nodes > Relink Nodes to Addon
## About EEVEE Render
Bioxel Nodes is designed for Cycles Render. However, it does support eevee render partially. "Solid Shader" node and "Volume Shader" node have a toggle called "EEVEE Render". If you want to render Bioxel Component in real-time, turn it on.
Also, there are some limitations:
Bioxel Nodes is designed for Cycles Render. However, it does support eevee render partially.Also, there are some limitations:
1. Only one cutter supported.
2. You cannot use "Color Ramp" over 2 colors.
3. EEVEE Render result is not that great as Cycles does.
2. EEVEE Render result is not that great as Cycles does.
> "Volume Shader" node is not work properly in EEVEE Next since 4.2. It is because EEVEE Next is not support attributes from instances of volume shader by now. But the Blender 4.2 docs still say attributes reading is ok, so I suppose this feature will eventually be implemented.
> Volume Shader is not work properly in EEVEE Next since 4.2. It is because EEVEE Next is not support attributes from instances of volume shader by now. But Blender 4.3 is ok, so I suppose this issue will eventually be fixed.

View File

@@ -8,8 +8,8 @@ bl_info = {
"name": "Bioxel Nodes",
"author": "Ma Nan",
"description": "",
"blender": (4, 0, 0),
"version": (0, 2, 4),
"blender": (4, 1, 0),
"version": (0, 2, 5),
"location": "File -> Import",
"warning": "",
"category": "Node"
@@ -21,11 +21,6 @@ auto_load.init()
def register():
auto_load.register()
menus.add()
bpy.types.Scene.bioxel_layer_dir = bpy.props.StringProperty(
name="Layer Directory",
subtype='DIR_PATH',
default="//"
)
def unregister():

View File

@@ -4,62 +4,12 @@ from pathlib import Path
from .nodes import AddCustomNode
class SaveAllNodes(bpy.types.Operator):
bl_idname = "customnodes.save_all_nodes"
bl_label = "Save All Nodes"
bl_description = "Save All Custom Nodes to Directory."
bl_options = {'UNDO'}
def execute(self, context):
files = []
for classname in dir(bpy.types):
if "CUSTOMNODES_MT_NODES_" in classname:
cls = getattr(bpy.types, classname)
files.append(cls.nodes_file)
files = list(set(files))
for file in files:
file_name = Path(file).name
# "//"
customnodes_node_dir = bpy.path.abspath(context.scene.customnodes_node_dir)
output_path: Path = Path(customnodes_node_dir, file_name).resolve()
source_path: Path = Path(file).resolve()
if output_path != source_path:
shutil.copy(source_path, output_path)
for lib in bpy.data.libraries:
lib_path = str(Path(bpy.path.abspath(lib.filepath)).resolve())
if lib_path == file:
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}")
return {'FINISHED'}
class CUSTOMNODES_PT_CustomNodes(bpy.types.Panel):
bl_idname = "CUSTOMNODES_PT_CustomNodes"
bl_label = "Custom Nodes"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
def draw(self, context):
layout = self.layout
scene = context.scene
layout.prop(scene, 'customnodes_node_dir')
layout.operator(SaveAllNodes.bl_idname)
class CustomNodes():
def __init__(
self,
menu_items,
nodes_file,
class_prefix="CUSTOMNODES_MT_NODES",
root_label='CustomNodes',
root_icon='NONE'
) -> None:
@@ -68,9 +18,10 @@ class CustomNodes():
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(
@@ -80,7 +31,7 @@ class CustomNodes():
)
self.menu_classes = menu_classes
idname = f"CUSTOMNODES_MT_NODES_{root_label.replace(' ', '').upper()}"
idname = f"{class_prefix}_{root_label.replace(' ', '').upper()}"
def add_node_menu(self, context):
if ('GeometryNodeTree' == bpy.context.area.spaces[0].tree_type):
@@ -92,7 +43,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 self.class_prefix
idname = f"{idname_namespace}_{label.replace(' ', '').upper()}"
# create submenu class if item is menu.
@@ -161,7 +112,7 @@ class CustomNodes():
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_tree, node_type: str):
def add_node(self, node_group, node_type: str):
item = self.find_item(node_type)
op = AddCustomNode()
op.nodes_file = self.nodes_file
@@ -174,7 +125,7 @@ class CustomNodes():
op.node_label = ""
op.node_link = True
op.node_callback = ""
return op.add_node(node_tree)
return op.add_node(node_group)
def register(self):
for cls in self.menu_classes:

View File

@@ -85,9 +85,14 @@ class AddCustomNode():
except:
...
def add_node(self, node_tree):
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_tree.new("GeometryNodeGroup")
node = node_group.nodes.new("GeometryNodeGroup")
self.assign_node_tree(node)
return node

View File

@@ -13,7 +13,7 @@ from . import skimage as ski
from .nodes import custom_nodes
from .props import BIOXELNODES_Series
from .utils import (calc_bbox_verts, get_all_layers, get_container_from_selection, get_layer, get_text_index_str,
get_node_by_type, hide_in_ray, lock_transform, save_vdb, show_message)
get_nodes_by_type, hide_in_ray, lock_transform, move_node_between_nodes, move_node_to_node, save_vdb, show_message)
try:
import SimpleITK as sitk
@@ -233,6 +233,8 @@ class ImportVolumeDataDialog(bpy.types.Operator):
meta['spacing'][2] / orig_spacing[2] * bioxel_size
)
dtype_index = volume.dtype.num
layer_shape = get_layer_shape(
bioxel_size, meta['shape'], orig_spacing)
@@ -285,10 +287,9 @@ class ImportVolumeDataDialog(bpy.types.Operator):
)
container = bpy.context.active_object
bbox_verts = calc_bbox_verts((0, 0, 0), volume.shape)
bbox_verts = calc_bbox_verts((0, 0, 0), layer_shape)
for index, vert in enumerate(container.data.vertices):
bbox_transform = transfrom
vert.co = bbox_transform @ mathutils.Vector(bbox_verts[index])
vert.co = transfrom @ mathutils.Vector(bbox_verts[index])
container.matrix_world = mat_ras2blender @ mat_scene_scale
container.name = container_name
@@ -297,14 +298,14 @@ class ImportVolumeDataDialog(bpy.types.Operator):
container['bioxel_container'] = True
container['scene_scale'] = scene_scale
bpy.ops.node.new_geometry_nodes_modifier()
container_node_tree = container.modifiers[0].node_group
input_node = get_node_by_type(container_node_tree.nodes,
container_node_group = container.modifiers[0].node_group
input_node = get_nodes_by_type(container_node_group,
'NodeGroupInput')[0]
container_node_tree.links.remove(input_node.outputs[0].links[0])
container_node_group.links.remove(input_node.outputs[0].links[0])
else:
container = bpy.data.objects[self.container]
container_node_tree = container.modifiers[0].node_group
container_node_group = container.modifiers[0].node_group
preferences = context.preferences.addons[__package__].preferences
loc, rot, sca = transfrom.decompose()
@@ -316,7 +317,7 @@ class ImportVolumeDataDialog(bpy.types.Operator):
grid = vdb.FloatGrid()
volume = volume.copy().astype(np.float32)
grid.copyFromArray(volume)
grid.transform = vdb.createLinearTransform(transfrom.transposed())
# grid.transform = vdb.createLinearTransform(transfrom.transposed())
grid.name = layer_type
vdb_path = save_vdb([grid], context)
@@ -353,26 +354,31 @@ class ImportVolumeDataDialog(bpy.types.Operator):
print(f"Creating layer ...")
bpy.ops.node.new_geometry_nodes_modifier()
node_tree = layer.modifiers[0].node_group
nodes = node_tree.nodes
links = node_tree.links
node_group = layer.modifiers[0].node_group
input_node = get_node_by_type(nodes, 'NodeGroupInput')[0]
output_node = get_node_by_type(nodes, 'NodeGroupOutput')[0]
input_node = get_nodes_by_type(node_group, 'NodeGroupInput')[0]
output_node = get_nodes_by_type(node_group, 'NodeGroupOutput')[0]
to_layer_node = custom_nodes.add_node(nodes,
to_layer_node = custom_nodes.add_node(node_group,
"BioxelNodes__ConvertToLayer")
links.new(input_node.outputs[0], to_layer_node.inputs[0])
links.new(to_layer_node.outputs[0], output_node.inputs[0])
node_group.links.new(input_node.outputs[0],
to_layer_node.inputs[0])
node_group.links.new(to_layer_node.outputs[0],
output_node.inputs[0])
# for compatibility to old vdb
to_layer_node.inputs['Not Transfromed'].default_value = True
to_layer_node.inputs['Layer ID'].default_value = random.randint(-200000000,
200000000)
to_layer_node.inputs['Data Type'].default_value = dtype_index
to_layer_node.inputs['Bioxel Size'].default_value = bioxel_size
to_layer_node.inputs['Shape'].default_value = layer_shape
to_layer_node.inputs['Origin'].default_value = layer_origin
to_layer_node.inputs['Rotation'].default_value = layer_rotation
move_node_between_nodes(to_layer_node, [input_node, output_node])
return layer
if self.read_as == "labels":
@@ -386,29 +392,29 @@ class ImportVolumeDataDialog(bpy.types.Operator):
for i in range(orig_max):
label = volume == np.full_like(volume, i+1)
print(f"Resampling...")
label = ski.resize(label.astype(np.float32),
label = ski.resize(label,
layer_shape,
anti_aliasing=True)
preserve_range=True,
anti_aliasing=False)
layer = create_layer(volume=label,
layer_name=f"{container_name}_{layer_name}_{i+1}",
layer_type="label")
# Deselect all nodes first
for node in container_node_tree.nodes:
if node.select:
node.select = False
mask_node = custom_nodes.add_node(container_node_tree.nodes,
mask_node = custom_nodes.add_node(container_node_group,
'BioxelNodes_MaskByLabel')
mask_node.label = f"{layer_name}_{i+1}"
mask_node.inputs[0].default_value = layer
# Connect to output if no output linked
output_node = get_node_by_type(container_node_tree.nodes,
output_node = get_nodes_by_type(container_node_group,
'NodeGroupOutput')[0]
if len(output_node.inputs[0].links) == 0:
container_node_tree.links.new(mask_node.outputs[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)))
else:
if volume.ndim == 4:
@@ -442,27 +448,27 @@ class ImportVolumeDataDialog(bpy.types.Operator):
layer_name=f"{container_name}_{layer_name}",
layer_type="scalar")
layer_node_tree = layer.modifiers[0].node_group
to_layer_node = layer_node_tree.nodes['BioxelNodes__ConvertToLayer']
layer_node_group = layer.modifiers[0].node_group
to_layer_node = layer_node_group.nodes['BioxelNodes__ConvertToLayer']
to_layer_node.inputs['Scalar Offset'].default_value = scalar_offset
to_layer_node.inputs['Scalar Max'].default_value = orig_max
to_layer_node.inputs['Scalar Min'].default_value = orig_min
# Deselect all nodes first
for node in container_node_tree.nodes:
if node.select:
node.select = False
mask_node = custom_nodes.add_node(container_node_tree.nodes,
mask_node = custom_nodes.add_node(container_node_group,
'BioxelNodes_MaskByThreshold')
mask_node.label = layer_name
mask_node.inputs[0].default_value = layer
# Connect to output if no output linked
output_node = get_node_by_type(container_node_tree.nodes,
output_node = get_nodes_by_type(container_node_group,
'NodeGroupOutput')[0]
if len(output_node.inputs[0].links) == 0:
container_node_tree.links.new(mask_node.outputs[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))
bpy.context.view_layer.objects.active = container
@@ -470,6 +476,7 @@ class ImportVolumeDataDialog(bpy.types.Operator):
if preferences.do_change_render_setting and is_first_import:
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
@@ -489,8 +496,8 @@ class ImportVolumeDataDialog(bpy.types.Operator):
volume_dtype = "Label"
elif self.read_as == "scalar":
volume_dtype = "Scalar"
title = f"Import '{volume_dtype}' Layer (Add to Container: {self.container})" \
if self.container != "" else f"Import '{volume_dtype}' Layer (Init a Container)"
title = f"Import as **{volume_dtype}** (Add to Container: {self.container})" \
if self.container != "" else f"Import as **{volume_dtype}** (Init a Container)"
context.window_manager.invoke_props_dialog(self,
width=500,
title=title)
@@ -512,10 +519,9 @@ class ImportVolumeDataDialog(bpy.types.Operator):
layer_size_text = f"Size will be: ({layer_size[0]:.2f}, {layer_size[1]:.2f}, {layer_size[2]:.2f}) m"
layout = self.layout
panel = layout.box()
if self.container == "":
panel.prop(self, "container_name")
panel.prop(self, "layer_name")
layout.prop(self, "container_name")
layout.prop(self, "layer_name")
panel = layout.box()
panel.prop(self, "bioxel_size")
@@ -575,7 +581,16 @@ class ParseVolumeData(bpy.types.Operator):
print("Collecting Meta Data...")
volume, meta = parse_volume_data(self.filepath)
do_orient = ext not in SEQUENCE_EXTS or ext in DICOM_EXTS
if self.read_as == "labels":
not_int = volume.dtype.kind != "b" and volume.dtype.kind != "i" and volume.dtype.kind != "u"
too_large = np.max(volume) > 100
if not_int or too_large:
self.report(
{"WARNING"}, "This volume data does not looks like labels, please check again.")
return {'CANCELLED'}
# do_orient = ext not in SEQUENCE_EXTS or ext in DICOM_EXTS
orig_shape = meta['shape']
orig_spacing = meta['spacing']
@@ -601,7 +616,7 @@ class ParseVolumeData(bpy.types.Operator):
orig_spacing=orig_spacing,
bioxel_size=bioxel_size,
series_id=self.series_id or "",
do_orient=do_orient,
# do_orient=do_orient,
container=self.container,
read_as=self.read_as,
scene_scale=scene_scale
@@ -619,7 +634,7 @@ class ParseVolumeData(bpy.types.Operator):
if not self.filepath and not self.directory:
return {'CANCELLED'}
show_message('Reading image data, it may take a while...',
show_message('Parsing volume data, it may take a while...',
'Please be patient...')
if get_ext(self.filepath) == '.dcm':
@@ -675,7 +690,8 @@ class ImportVolumeData():
bpy.ops.bioxelnodes.parse_volume_data(
'INVOKE_DEFAULT',
filepath=self.filepath,
directory=self.directory
directory=self.directory,
read_as=self.read_as
)
return {'FINISHED'}
@@ -745,7 +761,7 @@ class AddVolumeData(bpy.types.Operator):
class ExportVolumeData(bpy.types.Operator):
bl_idname = "bioxelnodes.export_volume_data"
bl_label = "Export Layer"
bl_label = "Export Bioxel as VDB"
bl_description = "Export Bioxel Layer as VDB"
bl_options = {'UNDO'}

View File

@@ -1,8 +1,9 @@
import bpy
from .operators import (AddPlaneCutter, AddCylinderCutter, AddCubeCutter, AddSphereCutter, CombineLabels,
ConvertToMesh, InvertScalar, FillByLabel, FillByThreshold, FillByRange)
from .io import ExportVolumeData, ImportAsLabelLayer, ImportAsScalarLayer, ImportVolumeData, AddVolumeData
from .misc import SaveLayers
from .save import ReLinkNodes, SaveLayers, SaveAllToShare
class ModifyLayer(bpy.types.Menu):
@@ -20,7 +21,7 @@ class ModifyLayer(bpy.types.Menu):
class AddCutterMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_CUTTERS"
bl_label = "Add Cutter"
bl_label = "Add a Cutter to Container"
def draw(self, context):
layout = self.layout
@@ -32,7 +33,7 @@ class AddCutterMenu(bpy.types.Menu):
class ImportLayerMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_LAYERS"
bl_label = "Import Layer"
bl_label = "Import Volume Data as Bioxel"
def draw(self, context):
layout = self.layout
@@ -68,28 +69,28 @@ class BioxelNodesOutlinerMenu(bpy.types.Menu):
layout.operator(SaveLayers.bl_idname)
# def TOPBAR_FILE_IMPORT(self, context):
# layout = self.layout
# layout.separator()
# layout.operator(ImportVolumeData.bl_idname)
def TOPBAR_FILE_IMPORT(self, context):
layout = self.layout
layout.separator()
layout.menu(ImportLayerMenu.bl_idname)
# def TOPBAR_FILE_EXPORT(self, context):
# layout = self.layout
# layout.separator()
# layout.operator(ExportVolumeData.bl_idname)
def TOPBAR_FILE_EXPORT(self, context):
layout = self.layout
layout.separator()
layout.operator(ExportVolumeData.bl_idname)
def VIEW3D_OBJECT(self, context):
layout = self.layout
layout.menu(BioxelNodesView3DMenu.bl_idname)
layout.separator()
layout.menu(BioxelNodesView3DMenu.bl_idname, icon="FILE_VOLUME")
def OUTLINER_OBJECT(self, context):
layout = self.layout
layout.menu(BioxelNodesOutlinerMenu.bl_idname)
layout.separator()
layout.menu(BioxelNodesOutlinerMenu.bl_idname, icon="FILE_VOLUME")
class BioxelNodesTopbarMenu(bpy.types.Menu):
@@ -104,7 +105,8 @@ class BioxelNodesTopbarMenu(bpy.types.Menu):
layout.menu(AddCutterMenu.bl_idname)
layout.operator(ConvertToMesh.bl_idname)
layout.separator()
layout.operator(SaveLayers.bl_idname)
layout.operator(SaveAllToShare.bl_idname)
layout.operator(ReLinkNodes.bl_idname)
def TOPBAR(self, context):
@@ -113,16 +115,16 @@ def TOPBAR(self, context):
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.prepend(OUTLINER_OBJECT)
bpy.types.VIEW3D_MT_object_context_menu.prepend(VIEW3D_OBJECT)
bpy.types.TOPBAR_MT_file_import.append(TOPBAR_FILE_IMPORT)
bpy.types.TOPBAR_MT_file_export.append(TOPBAR_FILE_EXPORT)
bpy.types.OUTLINER_MT_object.append(OUTLINER_OBJECT)
bpy.types.VIEW3D_MT_object_context_menu.append(VIEW3D_OBJECT)
bpy.types.TOPBAR_MT_editor_menus.append(TOPBAR)
def remove():
# bpy.types.TOPBAR_MT_file_import.remove(TOPBAR_FILE_IMPORT)
# bpy.types.TOPBAR_MT_file_export.remove(TOPBAR_FILE_EXPORT)
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.TOPBAR_MT_editor_menus.remove(TOPBAR)

View File

@@ -1,92 +0,0 @@
import bpy
from pathlib import Path
import shutil
from .utils import get_all_layers, get_container, get_container_layers
def save_layer(layer, output_dir):
name = layer.name
# "//"
output_dir = bpy.path.abspath(output_dir)
source_dir = bpy.path.abspath(layer.data.filepath)
output_path: Path = Path(output_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()
layer.data.filepath = bpy.path.relpath(
str(output_path), start=str(blend_path))
class SaveLayers(bpy.types.Operator):
bl_idname = "bioxelnodes.save_layers"
bl_label = "Save Layers"
bl_description = "Save Bioxel Layers to Directory."
bl_options = {'UNDO'}
@classmethod
def poll(cls, context):
container = get_container(bpy.context.active_object)
return True if container else False
def execute(self, context):
container = get_container(bpy.context.active_object)
if not container:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
for layer in get_container_layers(container):
try:
save_layer(layer, context.scene.bioxel_layer_dir)
except:
self.report(
{"WARNING"}, f"Fail to save {layer.name}, skiped")
self.report({"INFO"}, f"Successfully saved bioxel layers.")
return {'FINISHED'}
class SaveAllLayers(bpy.types.Operator):
bl_idname = "bioxelnodes.save_all_layers"
bl_label = "Save All Layers"
bl_description = "Save All Bioxel Layers to Directory."
bl_options = {'UNDO'}
def execute(self, context):
layers = get_all_layers()
if len(layers) == 0:
self.report({"WARNING"}, "Cannot find any bioxel layer.")
return {'FINISHED'}
for layer in layers:
try:
save_layer(layer, context.scene.bioxel_layer_dir)
except:
self.report(
{"WARNING"}, f"Fail to save {layer.name}, skiped")
self.report({"INFO"}, f"Successfully saved bioxel layers.")
return {'FINISHED'}
class BIOXELNODES_PT_Bioxels(bpy.types.Panel):
bl_idname = "BIOXELNODES_PT_Bioxels"
bl_label = "Bioxel Nodes"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
def draw(self, context):
layout = self.layout
scene = context.scene
layout.prop(scene, 'bioxel_layer_dir')
layout.operator(SaveAllLayers.bl_idname)

View File

@@ -56,10 +56,9 @@ import bpy
# return callback_str
if bpy.app.version >= (4, 1, 0):
NODE_FILE = "BioxelNodes_4.1"
NODE_FILE = "BioxelNodes_4.1"
MENU_ITEMS = [
MENU_ITEMS = [
{
'label': 'Methods',
'icon': 'OUTLINER_DATA_VOLUME',
@@ -127,7 +126,13 @@ if bpy.app.version >= (4, 1, 0):
{
'label': 'Color Presets',
'icon': 'COLOR',
'node_type': 'BioxelNodes_SetColorPresets',
'node_type': 'BioxelNodes_ColorPresets',
'node_description': ''
},
{
'label': 'Color Presets MRI',
'icon': 'COLOR',
'node_type': 'BioxelNodes_ColorPresets_MRI',
'node_description': ''
},
"separator",
@@ -206,28 +211,28 @@ if bpy.app.version >= (4, 1, 0):
},
"separator",
{
'label': 'To Mesh',
'label': 'Pick Mesh',
'icon': 'OUTLINER_OB_MESH',
'node_type': 'BioxelNodes_ToMesh',
'node_type': 'BioxelNodes_PickMesh',
'node_description': ''
},
{
'label': 'To Volume',
'label': 'Pick Volume',
'icon': 'OUTLINER_OB_VOLUME',
'node_type': 'BioxelNodes_ToVolume',
'node_type': 'BioxelNodes_PickVolume',
'node_description': ''
},
{
'label': 'To Bbox Wire',
'label': 'Pick Bbox Wire',
'icon': 'MESH_CUBE',
'node_type': 'BioxelNodes_ToBboxWire',
'node_type': 'BioxelNodes_PickBboxWire',
'node_description': ''
},
"separator",
{
'label': 'Inflate',
'icon': 'OUTLINER_OB_META',
'node_type': 'META_DATA',
'node_type': 'BioxelNodes_M_Inflate',
'node_description': ''
},
{
@@ -244,150 +249,13 @@ if bpy.app.version >= (4, 1, 0):
}
]
}
]
else:
NODE_FILE = "BioxelNodes_4.0"
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': 'Solid Shader',
'icon': 'SHADING_SOLID',
'node_type': 'BioxelNodes_AssignSolidShader',
'node_description': ''
},
{
'label': 'Slime Shader',
'icon': 'OUTLINER_OB_META',
'node_type': 'BioxelNodes_AssignSlimeShader',
'node_description': ''
},
{
'label': 'Volume Shader',
'icon': 'OUTLINER_OB_VOLUME',
'node_type': 'BioxelNodes_AssignVolumeShader',
'node_description': ''
},
{
'label': 'Universal Shader',
'icon': 'SHADING_RENDERED',
'node_type': 'BioxelNodes_AssignUniversalShader',
'node_description': ''
}
]
},
{
'label': 'Colors',
'icon': 'COLOR',
'items': [
{
'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': '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': 'Utils',
'icon': 'MODIFIER',
'items': [
{
'label': 'Join Component',
'icon': 'CONSTRAINT_BONE',
'node_type': 'BioxelNodes_JoinComponent',
'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",
)

View File

@@ -8,7 +8,7 @@ from . import skimage as ski
from . import scipy
from .nodes import custom_nodes
from .utils import (get_container, get_container_from_selection, get_container_layers,
get_layer, get_node_by_type, hide_in_ray, lock_transform, save_vdb)
get_layer, get_nodes_by_type, hide_in_ray, lock_transform, move_node_between_nodes, move_node_to_node, save_vdb)
def get_layer_name(layer):
@@ -52,23 +52,22 @@ def set_layer_meta(layer, key: str, value):
def add_mask_node(container, layer, node_type: str, node_label: str):
modifier = container.modifiers[0]
node_tree = modifier.node_group
container_node_group = modifier.node_group
# Deselect all nodes first
for node in node_tree.nodes:
if node.select:
node.select = False
mask_node = custom_nodes.add_node(node_tree.nodes, node_type)
mask_node = custom_nodes.add_node(container_node_group, node_type)
mask_node.label = node_label
mask_node.inputs[0].default_value = layer
# Connect to output if no output linked
output_node = get_node_by_type(node_tree.nodes,
output_node = get_nodes_by_type(container_node_group,
'NodeGroupOutput')[0]
if len(output_node.inputs[0].links) == 0:
node_tree.links.new(mask_node.outputs[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))
return mask_node
@@ -106,6 +105,7 @@ def deep_copy_layer(vdb_path, base_layer, name):
# add convert to layer node
base_layer_node = base_layer.modifiers[0].node_group.nodes['BioxelNodes__ConvertToLayer']
dtype_index = base_layer_node.inputs['Data Type'].default_value
bioxel_size = base_layer_node.inputs['Bioxel Size'].default_value
layer_shape = base_layer_node.inputs['Shape'].default_value
layer_origin = base_layer_node.inputs['Origin'].default_value
@@ -115,19 +115,22 @@ def deep_copy_layer(vdb_path, base_layer, name):
scalar_max = base_layer_node.inputs['Scalar Max'].default_value
bpy.ops.node.new_geometry_nodes_modifier()
node_tree = copyed_layer.modifiers[0].node_group
node_group = copyed_layer.modifiers[0].node_group
input_node = get_node_by_type(node_tree.nodes, 'NodeGroupInput')[0]
output_node = get_node_by_type(node_tree.nodes, 'NodeGroupOutput')[0]
input_node = get_nodes_by_type(node_group, 'NodeGroupInput')[0]
output_node = get_nodes_by_type(node_group, 'NodeGroupOutput')[0]
copyed_layer_node = custom_nodes.add_node(node_tree.nodes,
copyed_layer_node = custom_nodes.add_node(node_group,
"BioxelNodes__ConvertToLayer")
node_tree.links.new(input_node.outputs[0], copyed_layer_node.inputs[0])
node_tree.links.new(copyed_layer_node.outputs[0], output_node.inputs[0])
node_group.links.new(input_node.outputs[0], copyed_layer_node.inputs[0])
node_group.links.new(copyed_layer_node.outputs[0], output_node.inputs[0])
# for compatibility to old vdb
copyed_layer_node.inputs['Not Transfromed'].default_value = True
copyed_layer_node.inputs['Layer ID'].default_value = random.randint(-200000000,
200000000)
copyed_layer_node.inputs['Data Type'].default_value = dtype_index
copyed_layer_node.inputs['Bioxel Size'].default_value = bioxel_size
copyed_layer_node.inputs['Shape'].default_value = layer_shape
copyed_layer_node.inputs['Origin'].default_value = layer_origin
@@ -501,6 +504,7 @@ class FillByLabel(bpy.types.Operator):
mask = get_volume(label_grids, 0, label_shape)
mask = ski.resize(mask,
base_shape,
preserve_range=True,
anti_aliasing=True)
mask = scipy.median_filter(mask.astype(np.float32), size=2)
@@ -564,6 +568,7 @@ class CombineLabels(bpy.types.Operator):
label_volume = get_volume(label_grids, 0, label_shape)
label_volume = ski.resize(label_volume,
base_shape,
preserve_range=True,
anti_aliasing=True)
base_volume = np.maximum(base_volume, label_volume)
label_names.append(get_layer_name(label))
@@ -586,8 +591,8 @@ class CombineLabels(bpy.types.Operator):
class ConvertToMesh(bpy.types.Operator):
bl_idname = "bioxelnodes.convert_to_mesh"
bl_label = "Convert To Mesh"
bl_description = "Convert Bioxel Components To Mesh"
bl_label = "Convert Container To Mesh"
bl_description = "Convert Bioxel Container To Mesh"
bl_options = {'UNDO'}
@classmethod
@@ -615,21 +620,24 @@ class ConvertToMesh(bpy.types.Operator):
bpy.ops.node.new_geometry_nodes_modifier()
modifier = mesh.modifiers[0]
node_tree = modifier.node_group
node_group = modifier.node_group
output_node = get_node_by_type(node_tree.nodes, 'NodeGroupOutput')[0]
to_mesh_node = custom_nodes.add_node(node_tree.nodes,
"BioxelNodes_ToMesh")
output_node = get_nodes_by_type(node_group, 'NodeGroupOutput')[0]
to_mesh_node = custom_nodes.add_node(node_group,
"BioxelNodes_PickMesh")
to_mesh_node.inputs[0].default_value = container
node_tree.links.new(to_mesh_node.outputs[0], output_node.inputs[0])
node_group.links.new(to_mesh_node.outputs[0], output_node.inputs[0])
bpy.ops.object.convert(target='MESH')
# bpy.ops.constraint.apply(
# constraint=mesh.constraints[0].name, owner='OBJECT')
bpy.ops.object.modifier_apply(modifier=mesh.modifiers[0].name)
bpy.context.object.active_material_index = 1
bpy.ops.object.material_slot_remove()
bpy.context.view_layer.objects.active = mesh
self.report({"INFO"}, f"Successfully convert to mesh")
return {'FINISHED'}
@@ -681,10 +689,34 @@ class AddCutter():
cutter.display_type = 'WIRE'
modifier = container.modifiers[0]
node_tree = modifier.node_group
cutter_node = custom_nodes.add_node(node_tree.nodes, node_type)
node_group = modifier.node_group
cutter_node = custom_nodes.add_node(node_group, node_type)
cutter_node.inputs[0].default_value = cutter
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')
if len(output_node.inputs[0].links) == 0:
node_group.links.new(cut_node.outputs[0],
output_node.inputs[0])
move_node_to_node(cut_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],
cut_node.inputs[0])
node_group.links.new(cut_node.outputs[0],
output_node.inputs[0])
move_node_between_nodes(cut_node,
[pre_output_node, output_node])
node_group.links.new(cutter_node.outputs[0],
cut_node.inputs[1])
move_node_to_node(cutter_node, cut_node, (-300, -300))
else:
move_node_to_node(cutter_node, output_node, (0, -100))
bpy.context.view_layer.objects.active = container
return {'FINISHED'}
@@ -692,31 +724,31 @@ class AddCutter():
class AddPlaneCutter(bpy.types.Operator, AddCutter):
bl_idname = "bioxelnodes.add_plane_cutter"
bl_label = "Add Plane Cutter"
bl_description = "Add Plane Cutter to Container"
bl_label = "Add a Plane Cutter"
bl_description = "Add a Plane Cutter to Container"
bl_options = {'UNDO'}
object_type = "plane"
class AddCylinderCutter(bpy.types.Operator, AddCutter):
bl_idname = "bioxelnodes.add_cylinder_cutter"
bl_label = "Add Cylinder Cutter"
bl_description = "Add Cylinder Cutter to Container"
bl_label = "Add a Cylinder Cutter"
bl_description = "Add a Cylinder Cutter to Container"
bl_options = {'UNDO'}
object_type = "cylinder"
class AddCubeCutter(bpy.types.Operator, AddCutter):
bl_idname = "bioxelnodes.add_cube_cutter"
bl_label = "Add Cube Cutter"
bl_description = "Add Cube Cutter to Container"
bl_label = "Add a Cube Cutter"
bl_description = "Add a Cube Cutter to Container"
bl_options = {'UNDO'}
object_type = "cube"
class AddSphereCutter(bpy.types.Operator, AddCutter):
bl_idname = "bioxelnodes.add_sphere_cutter"
bl_label = "Add Sphere Cutter"
bl_description = "Add Sphere Cutter to Container"
bl_label = "Add a Sphere Cutter"
bl_description = "Add a Sphere Cutter to Container"
bl_options = {'UNDO'}
object_type = "sphere"

165
bioxelnodes/save.py Normal file
View File

@@ -0,0 +1,165 @@
import bpy
from pathlib import Path
import shutil
from .utils import get_all_layers, get_container, get_container_layers
from .nodes import custom_nodes
CLASS_PREFIX = "BIOXELNODES_MT_NODES"
class ReLinkNodes(bpy.types.Operator):
bl_idname = "bioxelnodes.relink_nodes"
bl_label = "Relink Nodes to Addon"
bl_description = "Relink all nodes to addon source"
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
self.report({"INFO"}, f"Successfully relinked.")
return {'FINISHED'}
class SaveAllToShare(bpy.types.Operator):
bl_idname = "bioxelnodes.save_all_to_share"
bl_label = "Save All Staged Data"
bl_description = "Save all staged data for sharing"
layer_dir: bpy.props.StringProperty(
name="Layer Directory",
subtype='DIR_PATH',
default="//"
) # type: ignore
save_node: bpy.props.BoolProperty(
name="Save Node Library File",
default=True,
) # type: ignore
node_file_dir: bpy.props.StringProperty(
name="Library Directory",
subtype='DIR_PATH',
default="//"
) # type: ignore
def execute(self, context):
files = []
for classname in dir(bpy.types):
if CLASS_PREFIX in classname:
cls = getattr(bpy.types, classname)
files.append(cls.nodes_file)
files = list(set(files))
for file in files:
file_name = Path(file).name
# "//"
node_file_dir = bpy.path.abspath(self.node_file_dir)
output_path: Path = Path(node_file_dir, file_name).resolve()
source_path: Path = Path(file).resolve()
if output_path != source_path:
shutil.copy(source_path, output_path)
for lib in bpy.data.libraries:
lib_path = Path(bpy.path.abspath(lib.filepath)).resolve()
if lib_path == source_path:
blend_path = Path(bpy.path.abspath("//")).resolve()
lib.filepath = bpy.path.relpath(
str(output_path), start=str(blend_path))
self.report({"INFO"}, f"Successfully saved to {output_path}")
layers = get_all_layers()
for layer in layers:
try:
save_layer(layer, self.layer_dir)
except:
self.report(
{"WARNING"}, f"Fail to save {layer.name}, skiped")
self.report({"INFO"}, f"Successfully saved bioxel layers.")
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.invoke_props_dialog(self,
width=500)
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, "layer_dir")
panel = layout.box()
panel.prop(self, "save_node")
panel.prop(self, "node_file_dir")
layout.label(text="Save your blender file first.")
def save_layer(layer, output_dir):
name = layer.name
# "//"
output_dir = bpy.path.abspath(output_dir)
source_dir = bpy.path.abspath(layer.data.filepath)
output_path: Path = Path(output_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()
layer.data.filepath = bpy.path.relpath(
str(output_path), start=str(blend_path))
class SaveLayers(bpy.types.Operator):
bl_idname = "bioxelnodes.save_layers"
bl_label = "Save Container's Layers"
bl_description = "Save Bioxel Layers to Directory."
layer_dir: bpy.props.StringProperty(
name="Layer Directory",
subtype='DIR_PATH',
default="//"
) # type: ignore
@classmethod
def poll(cls, context):
container = get_container(bpy.context.active_object)
return True if container else False
def execute(self, context):
container = get_container(bpy.context.active_object)
if not container:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
for layer in get_container_layers(container):
try:
save_layer(layer, self.layer_dir)
except:
self.report(
{"WARNING"}, f"Fail to save {layer.name}, skiped")
self.report({"INFO"}, f"Successfully saved bioxel layers.")
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.invoke_props_dialog(self,
width=500)
return {'RUNNING_MODAL'}

View File

@@ -5,12 +5,32 @@ import pyopenvdb as vdb
from uuid import uuid4
def get_type(cls):
return type(cls).__name__
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 get_node_by_type(nodes, type_name: str):
return [node for node in nodes if get_type(node) == type_name]
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 show_message(message="", title="Message Box", icon='INFO'):

View File

@@ -10,11 +10,6 @@ auto_load.init()
def register():
auto_load.register()
menus.add()
bpy.types.Scene.bioxel_layer_dir = bpy.props.StringProperty(
name="Layer Directory",
subtype='DIR_PATH',
default="//"
)
def unregister():

View File

@@ -3,7 +3,7 @@ schema_version = "1.0.0"
# Example of manifest file for a Blender extension
# Change the values according to your extension
id = "bioxelnodes"
version = "0.2.4"
version = "0.2.5"
name = "Bioxel Nodes"
tagline = "For scientific volumetric data visualization in Blender"
maintainer = "Ma Nan <icrdr2010@outlook.com>"

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "bioxelnodes"
version = "0.2.4"
version = "0.2.5"
description = ""
authors = ["Ma Nan <icrdr2010@outlook.com>"]
license = "MIT"