feat: Add Bioxel asset catalog and layer management functionality

- Introduced a new asset catalog definition file for Blender to manage Bioxel assets.
- Implemented layer caching and snapshot functionality in `layer.py`, allowing for VDB file generation and low-resolution snapshots.
- Created node management functions in `node.py` for handling Bioxel nodes within Blender's Geometry Node Editor.
- Added an operator to extract mesh data from selected nodes in `structure.py`.
- Developed UI panels in `panels.py` for layer management, including import options and layer library display.
This commit is contained in:
2026-01-21 12:42:12 +08:00
parent afae532a2c
commit a558d1ae30
43 changed files with 2933 additions and 3701 deletions

12
.gitignore vendored
View File

@@ -125,14 +125,14 @@ dmypy.json
pythonlib*
*.blend1
blendcache_*
*.ipynb
.secrets
.vdb
!scipy_ndimage/*/**
!scipy_ndimage/*/**
# Blender
*.blend1
blendcache_*
*.cats.txt~

222
AGENTS.md Normal file
View File

@@ -0,0 +1,222 @@
# BioxelNodes - Agent Development Guide
This file contains essential information for agentic coding agents working on the BioxelNodes codebase.
## Project Overview
BioxelNodes is a Blender addon for scientific volumetric data visualization. It integrates with Blender's Geometry Nodes and Cycles rendering engine to process and visualize medical/scientific data.
**Language:** Python 3.11
**Framework:** Blender Python API (bpy)
**Package Manager:** uv
## Development Commands
### Environment Setup
```bash
# Install dependencies
uv sync
```
### Build Commands
```bash
# Build for specific platform
uv run build.py <platform> # platforms: windows-x64, linux-x64, macos-arm64, macos-x64
```
### Code Quality
```bash
# Format code (autopep8)
autopep8 --in-place --recursive src/
```
### Documentation
```bash
# Serve documentation locally
uv run mkdocs serve
# Build documentation
uv run mike deploy --push --update-aliases 0.2.x latest
```
## Code Style Guidelines
### Formatting
- **4-space indentation**
- **PEP 8** compliance enforced via autopep8
### Naming Conventions
- **snake_case** for functions, variables, and files
- **PascalCase** for classes (Blender convention)
- **UPPER_CASE** for constants
- **bioxel_** prefix for addon-specific properties
### Import Organization
```python
# Standard library imports first
import pathlib
import uuid
# Third-party imports
import bpy
import numpy as np
import SimpleITK as sitk
# Relative imports for internal modules
from ..exceptions import CancelledByUser
from ..utils import get_layer_obj
```
### Type Hints
- Use sparingly, mainly for Blender property annotations
- Focus on function signatures and complex data structures
- Example: `def process_data(data: np.ndarray) -> np.ndarray:`
## Architecture Patterns
### Auto-Registration System
- Uses `auto_load.py` for automatic class registration
- Classes are discovered automatically via reflection
- Registration order resolved via topological sorting
- All Blender classes must inherit from appropriate base types
### Module Structure
```
src/bioxelnodes/
├── __init__.py # Addon entry point, asset library setup
├── auto_load.py # Auto-registration system
├── constants.py # Constants and paths
├── utils.py # Utility functions
├── preferences.py # Addon preferences
├── props.py # Blender properties
├── panels.py # UI panels
├── menus.py # UI menus
├── node.py # Node utilities
├── layer.py # Layer management
├── operators/ # Blender operators
├── bioxel/ # Core bioxel functionality
└── assets/ # Blender assets and node libraries
```
### Blender Integration Patterns
#### Property Groups
```python
class BioxelProperties(bpy.types.PropertyGroup):
bl_label = "Bioxel Properties"
bioxel_custom_prop: bpy.props.StringProperty(
name="Custom Property",
default="",
description="Description for UI"
)
```
#### Operators
```python
class BIOXEL_OT_custom_operator(bpy.types.Operator):
bl_idname = "bioxel.custom_operator"
bl_label = "Custom Operator"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
# Operator logic here
return {'FINISHED'}
```
#### Panels
```python
class BIOXEL_PT_custom_panel(bpy.types.Panel):
bl_label = "Custom Panel"
bl_idname = "BIOXEL_PT_custom_panel"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = "Bioxel"
def draw(self, context):
layout = self.layout
# UI drawing code here
```
## Error Handling
### Error Handling Patterns
- Use try-catch blocks for file operations
- Handle user cancellation gracefully
- Report progress for long operations using `bioxel_progress_factor` and `bioxel_progress_text`
- Log warnings with print statements for debugging
## Key Dependencies
### Core Libraries
- **bpy** (Blender Python API) - Core integration
- **numpy** - Array processing
- **SimpleITK** - Medical image processing
- **h5py** - HDF5 file handling
### Scientific Libraries
- **matplotlib** - Plotting and visualization
- **transforms3d** - 3D transformations
- **pyometiff** - OME-TIFF support
- **mrcfile** - MRC file format support
## Development Workflow
### Making Changes
1. Identify the appropriate module (operators, panels, bioxel, etc.)
2. Follow existing naming conventions and patterns
3. Use auto-registration - no manual registration needed
4. Test with Blender's Python console
5. Format code with autopep8 before committing
### Adding New Features
1. Create appropriate classes in relevant modules
2. Use proper prefixes (BIOXEL_OT_, BIOXEL_PT_, etc.)
3. Add to auto_load system automatically discovers new classes
4. Update documentation if needed
### File Operations
- Use `pathlib.Path` for path operations
- Check file existence before operations
- Handle permissions and missing files gracefully
- Use appropriate file formats (HDF5 for data, PNG for images)
## Blender Integration Notes
### Asset Library
- Automatically managed via `add_asset_library_if_missing()`
- Located at `NODE_LIB_DIRPATH`
- Uses "PACK" import method
- Name: "O Bioxel"
### Progress Reporting
- Use `bioxel_progress_factor` (0.0 to 1.0) for progress
- Use `bioxel_progress_text` for status messages
- Update via WindowManager properties
### Layer Management
- Layers stored as HDF5 files
- Container objects manage multiple layers
- Use `layer.py` for layer operations
- Support for various medical image formats
## Common Pitfalls
### Blender API
- Always check context validity
- Handle different Blender versions (check `bpy.app.version`)
- Use proper property types for UI
- Register classes in correct order (auto_load handles this)
### Performance
- Use NumPy for array operations
- Avoid expensive operations in UI draw calls
- Cache computed values where appropriate
- Use background threads for long operations
### File I/O
- Always close file handles
- Use context managers for file operations
- Handle large files in chunks
- Validate file formats before processing

View File

@@ -17,7 +17,9 @@ required_packages = ["SimpleITK==2.3.1",
"mrcfile==1.5.1",
"h5py==3.11.0",
"transforms3d==0.4.2",
"tifffile==2024.7.24"]
"tifffile==2024.7.24",
"matplotlib==3.10.7",
"pillow==11.2.1"]
platforms = {"windows-x64": Platform(pypi_suffix="win_amd64",

92
pack.py Normal file
View File

@@ -0,0 +1,92 @@
"""
Create a zip archive of the src/bioxelnodes package with a top-level folder
named `bioxelnodes/`. Excludes specified temporary/cache files and folders.
Does not modify any files in the project.
Usage:
python build_package.py <platform_name> [output_zip_path]
Example:
python build_package.py windows-x64
"""
from pathlib import Path
import sys
import zipfile
import fnmatch
from datetime import datetime
platforms = {
"windows-x64": "windows-x64",
"linux-x64": "linux-x64",
"macos-arm64": "macos-arm64",
"macos-x64": "macos-x64",
}
# patterns to exclude (filename matching)
EXCLUDE_FILENAME_PATTERNS = [
"*.blend1",
"blendcache_*",
"*.cats.txt~",
"*.pyc",
"*.pyo",
"*$py.class",
]
# directory names to exclude entirely
EXCLUDE_DIR_NAMES = {
"__pycache__",
}
def should_exclude(path: Path) -> bool:
name = path.name
# exclude directories by name anywhere in the path
if any(part in EXCLUDE_DIR_NAMES for part in path.parts):
return True
# only apply filename patterns to files
if path.is_file():
for pat in EXCLUDE_FILENAME_PATTERNS:
if fnmatch.fnmatch(name, pat):
return True
return False
def build_package(platform_name: str, out_zip: Path):
src_root = Path("src") / "bioxelnodes"
if not src_root.exists():
raise SystemExit(f"Source folder not found: {src_root.resolve()}")
out_zip.parent.mkdir(parents=True, exist_ok=True)
total = 0
with zipfile.ZipFile(out_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for p in src_root.rglob("*"):
if p.is_dir():
continue
if should_exclude(p):
continue
# arcname should place files under top-level 'bioxelnodes/...'
rel = p.relative_to(src_root)
arcname = Path("bioxelnodes") / rel
zf.write(p, arcname.as_posix())
total += 1
print(f"Packaged {total} files into {out_zip}")
def main():
if len(sys.argv) < 2:
print("Usage: python build_package.py <platform_name> [output_zip_path]")
print("Available platforms:", ", ".join(platforms.keys()))
sys.exit(1)
platform_key = sys.argv[1]
if platform_key not in platforms:
print("Unknown platform:", platform_key)
print("Available platforms:", ", ".join(platforms.keys()))
sys.exit(1)
tag = platforms[platform_key]
timestamp = datetime.now().strftime("%Y%m%dT%H%M%S")
default_name = f"bioxelnodes-{tag}-{timestamp}.zip"
out_path = Path(sys.argv[2]) if len(sys.argv) >= 3 else Path(default_name)
build_package(platform_key, out_path)
if __name__ == "__main__":
main()

View File

@@ -12,6 +12,7 @@ dependencies = [
"mrcfile==1.5.1",
"h5py==3.11.0",
"transforms3d==0.4.2",
"matplotlib==3.10.7",
]
[dependency-groups]
@@ -25,6 +26,7 @@ dev = [
"autopep8>=2.3.1,<3",
"mike>=2.1.2,<3",
"tomlkit>=0.13.0,<0.14",
"pip>=25.3",
]
[build-system]

View File

@@ -1,26 +1,109 @@
import bpy
import bpy.utils.previews as previews
from .constants import NODE_LIB_DIRPATH, PREVIEW_COLLECTIONS
from .props import _bioxel_layer_items, _update_layer_gallery, _update_snapshot_z
from .props import BIOXELNODES_LayerListUL
from . import auto_load
from . import menus
auto_load.init()
def add_asset_library_if_missing():
"""检查并添加Asset Library(如果不存在),使用link导入方式"""
# 检查路径是否存在
if not NODE_LIB_DIRPATH.exists():
print(f"警告: 节点库路径不存在 - {NODE_LIB_DIRPATH}")
return
# 转换为字符串路径用于Blender设置
lib_path_str = str(NODE_LIB_DIRPATH)
# 检查是否已存在该资产库
prefs = bpy.context.preferences.filepaths.asset_libraries
for lib in prefs:
if lib.path == lib_path_str:
return # 已存在,无需添加
# 添加新的资产库,使用link导入方式
new_lib = prefs.new()
new_lib.name = "O Bioxel" # 资产库名称
new_lib.path = lib_path_str # 资产库路径(字符串形式)
new_lib.import_method = "PACK" # 设置导入方式为link
print(f"Add Bioxel Nodes library: {lib_path_str}")
def remove_asset_library_if_exists():
"""Remove the Bioxel asset library entry if it was added (by path or name)."""
# if path missing, nothing to remove
lib_path_str = str(NODE_LIB_DIRPATH)
prefs = bpy.context.preferences.filepaths.asset_libraries
# iterate over a snapshot of prefs to safely find index
lib_to_remove = None
for i, lib in enumerate(list(prefs)):
try:
if lib.path == lib_path_str:
lib_to_remove = lib
break
except Exception:
continue
if lib_to_remove is not None:
try:
prefs.remove(lib_to_remove)
print(f"Removed Bioxel Nodes library: {lib_path_str}")
except Exception as e:
print(f"Warning: failed to remove Bioxel node library: {e}")
def register():
pcoll = previews.new()
pcoll.layer_previews = ()
PREVIEW_COLLECTIONS["layers"] = pcoll
bpy.types.WindowManager.bioxel_progress_factor = bpy.props.FloatProperty(
default=1.0
)
bpy.types.WindowManager.bioxel_progress_text = bpy.props.StringProperty()
bpy.types.WindowManager.bioxel_layer_library = bpy.props.EnumProperty(
name="Bioxel Layer Gallery",
items=_bioxel_layer_items,
update=_update_layer_gallery,
)
bpy.types.WindowManager.bioxel_snapshot_z = bpy.props.FloatProperty(
name="Snapshot Z",
default=0.5,
min=0.0,
max=1.0,
subtype="FACTOR",
update=_update_snapshot_z,
)
bpy.types.WindowManager.bioxel_layer_list_index = bpy.props.IntProperty(
default=0)
auto_load.register()
bpy.types.WindowManager.bioxelnodes_progress_factor = bpy.props.FloatProperty(
default=1.0)
bpy.types.WindowManager.bioxelnodes_progress_text = bpy.props.StringProperty()
bpy.types.WindowManager.bioxelnodes_layer_list_UL = bpy.props.PointerProperty(
type=BIOXELNODES_LayerListUL)
menus.add()
add_asset_library_if_missing()
def unregister():
remove_asset_library_if_exists()
menus.remove()
del bpy.types.WindowManager.bioxelnodes_progress_factor
del bpy.types.WindowManager.bioxelnodes_progress_text
del bpy.types.WindowManager.bioxelnodes_layer_list_UL
auto_load.unregister()
del bpy.types.WindowManager.bioxel_progress_factor
del bpy.types.WindowManager.bioxel_progress_text
del bpy.types.WindowManager.bioxel_layer_library
del bpy.types.WindowManager.bioxel_snapshot_z
del bpy.types.WindowManager.bioxel_layer_list_index
for pcoll in PREVIEW_COLLECTIONS.values():
previews.remove(pcoll)
PREVIEW_COLLECTIONS.clear()

Binary file not shown.

View File

@@ -0,0 +1,12 @@
# This is an Asset Catalog Definition file for Blender.
#
# Empty lines and lines starting with `#` will be ignored.
# The first non-ignored line should be the version indicator.
# Other lines are of the format "UUID:catalog/path/for/assets:simple catalog name"
VERSION 1
c0699a8b-6fd6-4d10-bdde-5ea0fa36724d:O Bioxel:O Bioxel
5ea24e10-5a2b-44d1-b67b-19014c412793:O Bioxel/Layer:O Bioxel-Layer
17383d65-f1a0-4122-b73a-0baaad4b8ecf:O Bioxel/Structure:O Bioxel-Structure
d03fc5b0-465c-45ba-ba8e-524a1c52ae3d:O Bioxel/Utilities:O Bioxel-Utilities

View File

@@ -80,11 +80,13 @@ def get_ordered_classes_to_register(modules):
def get_register_deps_dict(modules):
my_classes = set(iter_my_classes(modules))
my_classes_by_idname = {cls.bl_idname: cls for cls in my_classes if hasattr(cls, "bl_idname")}
my_classes_by_idname = {
cls.bl_idname: cls for cls in my_classes if hasattr(cls, "bl_idname")}
deps_dict = {}
for cls in my_classes:
deps_dict[cls] = set(iter_my_register_deps(cls, my_classes, my_classes_by_idname))
deps_dict[cls] = set(iter_my_register_deps(
cls, my_classes, my_classes_by_idname))
return deps_dict
@@ -181,7 +183,8 @@ def toposort(deps_dict):
sorted_values.add(value)
else:
unsorted.append(value)
deps_dict = {value: deps_dict[value] - sorted_values for value in unsorted}
deps_dict = {value: deps_dict[value] -
sorted_values for value in unsorted}
sorted_list_sub.sort(key=lambda cls: getattr(cls, "bl_order", 0))
sorted_list.extend(sorted_list_sub)
return sorted_list

View File

@@ -78,8 +78,8 @@ class Layer():
mask_frame = mask[f, :, :, :]
if smooth > 0:
mask_frame = scipy.median_filter(mask_frame.astype(np.float32),
mode="nearest",
size=smooth)
mode="nearest",
size=smooth)
# mask_frame = scipy.median_filter(
# mask_frame.astype(np.float32), size=2)
mask_frames += (mask_frame,)
@@ -88,8 +88,8 @@ class Layer():
mask_frame = mask[:, :, :]
if smooth > 0:
mask_frame = scipy.median_filter(mask_frame.astype(np.float32),
mode="nearest",
size=smooth)
mode="nearest",
size=smooth)
# mask_frame = scipy.median_filter(
# mask_frame.astype(np.float32), size=2)
mask_frames += (mask_frame,)
@@ -150,3 +150,32 @@ class Layer():
mat_scale = transforms3d.zooms.zfdir2aff(factors[0])
self.affine = np.dot(self.affine, mat_scale)
def snapshot(self, shape: tuple, smooth: int = 0):
if len(shape) != 3:
raise Exception("Shape must be 3 dim")
data = self.data
snapshot = data[0, :, :, :, :]
factors = np.divide(self.shape, shape)
zoom_factors = [1 / f for f in factors]
order = 0 if snapshot.dtype == bool else 1
snapshot = ndi.zoom(snapshot,
zoom_factors+[1.0],
mode="nearest",
grid_mode=False,
order=order)
snapshot = snapshot.astype(np.float32)
if self.kind in ['scalar', 'vector', 'color']:
mn = float(np.nanmin(snapshot))
mx = float(np.nanmax(snapshot))
if mx - mn > 1e-8:
snapshot = (snapshot - mn) / (mx - mn)
else:
snapshot = np.clip(snapshot, 0.0, 1.0)
return snapshot

View File

@@ -39,7 +39,6 @@ from . import _ni_support
from . import _nd_image
def _invalid_origin(origin, lenw):
return (origin < -(lenw // 2)) or (origin > (lenw - 1) // 2)
@@ -73,7 +72,6 @@ def _complex_via_real_components(func, input, weights, output, cval, **kwargs):
return output
def correlate1d(input, weights, axis=-1, output=None, mode="reflect",
cval=0.0, origin=0):
"""Calculate a 1-D correlation along the given axis.
@@ -133,7 +131,6 @@ def correlate1d(input, weights, axis=-1, output=None, mode="reflect",
return output
def convolve1d(input, weights, axis=-1, output=None, mode="reflect",
cval=0.0, origin=0):
"""Calculate a 1-D convolution along the given axis.
@@ -205,7 +202,6 @@ def _gaussian_kernel1d(sigma, order, radius):
return q * phi_x
def gaussian_filter1d(input, sigma, axis=-1, order=0, output=None,
mode="reflect", cval=0.0, truncate=4.0, *, radius=None):
"""1-D Gaussian filter.
@@ -274,7 +270,6 @@ def gaussian_filter1d(input, sigma, axis=-1, order=0, output=None,
return correlate1d(input, weights, axis, output, mode, cval, 0)
def gaussian_filter(input, sigma, order=0, output=None,
mode="reflect", cval=0.0, truncate=4.0, *, radius=None,
axes=None):
@@ -381,7 +376,6 @@ def gaussian_filter(input, sigma, order=0, output=None,
return output
def prewitt(input, axis=-1, output=None, mode="reflect", cval=0.0):
"""Calculate a Prewitt filter.
@@ -443,7 +437,6 @@ def prewitt(input, axis=-1, output=None, mode="reflect", cval=0.0):
return output
def sobel(input, axis=-1, output=None, mode="reflect", cval=0.0):
"""Calculate a Sobel filter.
@@ -501,7 +494,6 @@ def sobel(input, axis=-1, output=None, mode="reflect", cval=0.0):
return output
def generic_laplace(input, derivative2, output=None, mode="reflect",
cval=0.0,
extra_arguments=(),
@@ -549,7 +541,6 @@ def generic_laplace(input, derivative2, output=None, mode="reflect",
return output
def laplace(input, output=None, mode="reflect", cval=0.0):
"""N-D Laplace filter based on approximate second derivatives.
@@ -584,7 +575,6 @@ def laplace(input, output=None, mode="reflect", cval=0.0):
return generic_laplace(input, derivative2, output, mode, cval)
def gaussian_laplace(input, sigma, output=None, mode="reflect",
cval=0.0, **kwargs):
"""Multidimensional Laplace filter using Gaussian second derivatives.
@@ -637,7 +627,6 @@ def gaussian_laplace(input, sigma, output=None, mode="reflect",
extra_keywords=kwargs)
def generic_gradient_magnitude(input, derivative, output=None,
mode="reflect", cval=0.0,
extra_arguments=(), extra_keywords=None):
@@ -690,7 +679,6 @@ def generic_gradient_magnitude(input, derivative, output=None,
return output
def gaussian_gradient_magnitude(input, sigma, output=None,
mode="reflect", cval=0.0, **kwargs):
"""Multidimensional gradient magnitude using Gaussian derivatives.
@@ -792,7 +780,6 @@ def _correlate_or_convolve(input, weights, output, mode, cval, origin,
return output
def correlate(input, weights, output=None, mode='reflect', cval=0.0,
origin=0):
"""
@@ -856,7 +843,6 @@ def correlate(input, weights, output=None, mode='reflect', cval=0.0,
origin, False)
def convolve(input, weights, output=None, mode='reflect', cval=0.0,
origin=0):
"""
@@ -967,7 +953,6 @@ def convolve(input, weights, output=None, mode='reflect', cval=0.0,
origin, True)
def uniform_filter1d(input, size, axis=-1, output=None,
mode="reflect", cval=0.0, origin=0):
"""Calculate a 1-D uniform filter along the given axis.
@@ -1018,7 +1003,6 @@ def uniform_filter1d(input, size, axis=-1, output=None,
return output
def uniform_filter(input, size=3, output=None, mode="reflect",
cval=0.0, origin=0, *, axes=None):
"""Multidimensional uniform filter.
@@ -1088,7 +1072,6 @@ def uniform_filter(input, size=3, output=None, mode="reflect",
return output
def minimum_filter1d(input, size, axis=-1, output=None,
mode="reflect", cval=0.0, origin=0):
"""Calculate a 1-D minimum filter along the given axis.
@@ -1145,7 +1128,6 @@ def minimum_filter1d(input, size, axis=-1, output=None,
return output
def maximum_filter1d(input, size, axis=-1, output=None,
mode="reflect", cval=0.0, origin=0):
"""Calculate a 1-D maximum filter along the given axis.
@@ -1302,7 +1284,6 @@ def _min_or_max_filter(input, size, footprint, structure, output, mode,
return output
def minimum_filter(input, size=None, footprint=None, output=None,
mode="reflect", cval=0.0, origin=0, *, axes=None):
"""Calculate a multidimensional minimum filter.
@@ -1350,7 +1331,6 @@ def minimum_filter(input, size=None, footprint=None, output=None,
cval, origin, 1, axes)
def maximum_filter(input, size=None, footprint=None, output=None,
mode="reflect", cval=0.0, origin=0, *, axes=None):
"""Calculate a multidimensional maximum filter.
@@ -1398,7 +1378,6 @@ def maximum_filter(input, size=None, footprint=None, output=None,
cval, origin, 0, axes)
def _rank_filter(input, rank, size=None, footprint=None, output=None,
mode="reflect", cval=0.0, origin=0, operation='rank',
axes=None):
@@ -1491,7 +1470,6 @@ def _rank_filter(input, rank, size=None, footprint=None, output=None,
return output
def rank_filter(input, rank, size=None, footprint=None, output=None,
mode="reflect", cval=0.0, origin=0, *, axes=None):
"""Calculate a multidimensional rank filter.
@@ -1535,7 +1513,6 @@ def rank_filter(input, rank, size=None, footprint=None, output=None,
origin, 'rank', axes=axes)
def median_filter(input, size=None, footprint=None, output=None,
mode="reflect", cval=0.0, origin=0, *, axes=None):
"""
@@ -1586,7 +1563,6 @@ def median_filter(input, size=None, footprint=None, output=None,
origin, 'median', axes=axes)
def percentile_filter(input, percentile, size=None, footprint=None,
output=None, mode="reflect", cval=0.0, origin=0, *,
axes=None):
@@ -1630,7 +1606,6 @@ def percentile_filter(input, percentile, size=None, footprint=None,
cval, origin, 'percentile', axes=axes)
def generic_filter1d(input, function, filter_size, axis=-1,
output=None, mode="reflect", cval=0.0, origin=0,
extra_arguments=(), extra_keywords=None):
@@ -1718,7 +1693,6 @@ def generic_filter1d(input, function, filter_size, axis=-1,
return output
def generic_filter(input, function, size=None, footprint=None,
output=None, mode="reflect", cval=0.0, origin=0,
extra_arguments=(), extra_keywords=None):
@@ -1846,4 +1820,4 @@ def generic_filter(input, function, size=None, footprint=None,
mode = _ni_support._extend_mode_to_code(mode)
_nd_image.generic_filter(input, function, footprint, output, mode,
cval, origins, extra_arguments, extra_keywords)
return output
return output

View File

@@ -11,7 +11,7 @@ def _prepad_for_spline_filter(input, mode, cval):
npad = 12
if mode == 'grid-constant':
padded = np.pad(input, npad, mode='constant',
constant_values=cval)
constant_values=cval)
elif mode == 'nearest':
padded = np.pad(input, npad, mode='edge')
else:
@@ -21,6 +21,7 @@ def _prepad_for_spline_filter(input, mode, cval):
padded = input
return padded, npad
def spline_filter1d(input, order=3, axis=-1, output=np.float64,
mode='mirror'):
"""

View File

@@ -83,7 +83,8 @@ def _get_output(output, input, shape=None, complex_output=False):
elif isinstance(output, (type, np.dtype)):
# Classes (like `np.float32`) and dtypes are interpreted as dtype
if complex_output and np.dtype(output).kind != 'c':
warnings.warn("promoting specified output dtype to complex", stacklevel=3)
warnings.warn(
"promoting specified output dtype to complex", stacklevel=3)
output = np.promote_types(output, np.complex64)
output = np.zeros(shape, dtype=output)
elif isinstance(output, str):

View File

@@ -7,4 +7,4 @@ def normalize_axis_index(axis, ndim):
if axis < 0:
axis = axis + ndim
return axis
return axis

View File

@@ -3,6 +3,7 @@ import numpy as np
from .. import scipy as ndi
from ._utils import convert_to_float
def _clip_warp_output(input_image, output_image, mode, cval, clip):
"""Clip output image to range of values of input image.

View File

@@ -39,7 +39,8 @@ _integer_types = (
np.uintp,
np.uintc,
)
_integer_ranges = {t: (np.iinfo(t).min, np.iinfo(t).max) for t in _integer_types}
_integer_ranges = {t: (np.iinfo(t).min, np.iinfo(t).max)
for t in _integer_types}
dtype_range = {
bool: (False, True),
np.bool_: (False, True),
@@ -175,7 +176,8 @@ def _scale(a, n, m, copy=True):
# downscale with precision loss
if copy:
b = np.empty(a.shape, _dtype_bits(kind, m))
np.floor_divide(a, 2 ** (n - m), out=b, dtype=a.dtype, casting='unsafe')
np.floor_divide(a, 2 ** (n - m), out=b,
dtype=a.dtype, casting='unsafe')
return b
else:
a //= 2 ** (n - m)
@@ -282,7 +284,8 @@ def _convert(image, dtype, force_copy=False, uniform=False):
return image
if not (dtype_in in _supported_types and dtype_out in _supported_types):
raise ValueError(f'Cannot convert from {dtypeobj_in} to ' f'{dtypeobj_out}.')
raise ValueError(
f'Cannot convert from {dtypeobj_in} to ' f'{dtypeobj_out}.')
if kind_in in 'ui':
imin_in = np.iinfo(dtype_in).min
@@ -318,7 +321,8 @@ def _convert(image, dtype, force_copy=False, uniform=False):
if not uniform:
if kind_out == 'u':
image_out = np.multiply(image, imax_out, dtype=computation_type)
image_out = np.multiply(
image, imax_out, dtype=computation_type)
else:
image_out = np.multiply(
image, (imax_out - imin_out) / 2, dtype=computation_type
@@ -327,7 +331,8 @@ def _convert(image, dtype, force_copy=False, uniform=False):
np.rint(image_out, out=image_out)
np.clip(image_out, imin_out, imax_out, out=image_out)
elif kind_out == 'u':
image_out = np.multiply(image, imax_out + 1, dtype=computation_type)
image_out = np.multiply(
image, imax_out + 1, dtype=computation_type)
np.clip(image_out, 0, imax_out, out=image_out)
else:
image_out = np.multiply(

View File

@@ -1,270 +0,0 @@
from ast import literal_eval
from pathlib import Path
import bpy
from ..constants import LATEST_NODE_LIB_PATH, NODE_LIB_DIRPATH, VERSIONS
from ..utils import get_cache_dir
def move_node_to_node(node, target_node, offset=(0, 0)):
node.location.x = target_node.location.x + offset[0]
node.location.y = target_node.location.y + offset[1]
def move_node_between_nodes(node, target_nodes, offset=(0, 0)):
xs = []
ys = []
for target_node in target_nodes:
xs.append(target_node.location.x)
ys.append(target_node.location.y)
node.location.x = sum(xs) / len(xs) + offset[0]
node.location.y = sum(ys) / len(ys) + offset[1]
def get_node_type(node):
node_type = type(node).__name__
if node_type == "GeometryNodeGroup":
node_type = node.node_tree.name
return node_type
def get_nodes_by_type(node_group, type_name: str):
return [node for node in node_group.nodes if get_node_type(node) == type_name]
def get_container_objs_from_selection():
container_objs = []
for obj in bpy.context.selected_objects:
if get_container_obj(obj):
container_objs.append(obj)
return list(set(container_objs))
def get_container_obj(current_obj):
if current_obj:
if current_obj.get('bioxel_container'):
return current_obj
elif current_obj.get('bioxel_layer'):
parent = current_obj.parent
return parent if parent.get('bioxel_container') else None
return None
def get_layer_prop_value(layer_obj: bpy.types.Object, prop_name: str):
node_group = layer_obj.modifiers[0].node_group
layer_node = get_nodes_by_type(node_group, "BioxelNodes__Layer")[0]
prop = layer_node.inputs.get(prop_name)
if prop is None:
return None
value = prop.default_value
if type(value).__name__ == "bpy_prop_array":
value = tuple(value)
return tuple([int(v) for v in value]) \
if prop in ["shape"] else value
elif type(value).__name__ == "str":
return str(value)
elif type(value).__name__ == "float":
value = float(value)
return round(value, 2) \
if prop in ["bioxel_size"] else value
elif type(value).__name__ == "int":
value = int(value)
return value
else:
return value
def get_layer_name(layer_obj):
return get_layer_prop_value(layer_obj, "name")
def get_layer_kind(layer_obj):
return get_layer_prop_value(layer_obj, "kind")
def get_layer_label(layer_obj):
name = get_layer_name(layer_obj)
# kind = get_layer_kind(layer_obj)
label = f"{name}"
if is_missing_layer(layer_obj):
return "**MISSING**" + label
elif is_temp_layer(layer_obj):
return "* " + label
else:
return label
def is_missing_layer(layer_obj):
cache_filepath = Path(bpy.path.abspath(
layer_obj.data.filepath)).resolve()
return not cache_filepath.is_file()
def is_temp_layer(layer_obj):
cache_filepath = Path(bpy.path.abspath(
layer_obj.data.filepath)).resolve()
cache_dirpath = Path(get_cache_dir())
return cache_dirpath in cache_filepath.parents
def set_layer_prop_value(layer_obj: bpy.types.Object, prop: str, value):
node_group = layer_obj.modifiers[0].node_group
layer_node = get_nodes_by_type(node_group, "BioxelNodes__Layer")[0]
layer_node.inputs[prop].default_value = value
def get_layer_obj(current_obj: bpy.types.Object):
if current_obj:
if current_obj.get('bioxel_layer') and current_obj.parent:
if current_obj.parent.get('bioxel_container'):
return current_obj
return None
def get_container_layer_objs(container_obj: bpy.types.Object):
layer_objs = []
for obj in bpy.data.objects:
if obj.parent == container_obj and get_layer_obj(obj):
layer_objs.append(obj)
return layer_objs
def get_all_layer_objs():
layer_objs = []
for obj in bpy.data.objects:
if get_layer_obj(obj):
layer_objs.append(obj)
return layer_objs
def add_driver(target, target_prop, var_sources, expression):
driver = target.driver_add(target_prop)
is_vector = isinstance(driver, list)
drivers = driver if is_vector else [driver]
for i, driver in enumerate(drivers):
for j, var_source in enumerate(var_sources):
source = var_source['source']
prop = var_source['prop']
var = driver.driver.variables.new()
var.name = f"var{j}"
var.targets[0].id_type = source.id_type
var.targets[0].id = source
var.targets[0].data_path = f'["{prop}"][{i}]'\
if is_vector else f'["{prop}"]'
driver.driver.expression = expression
def add_direct_driver(target, target_prop, source, source_prop):
drivers = [
{
"source": source,
"prop": source_prop
}
]
expression = "var0"
add_driver(target, target_prop, drivers, expression)
def read_file_prop(content: str):
props = {}
for line in content.split("\n"):
line = line.replace(" ", "")
p = line.split("=")[0]
if p != "":
v = line.split("=")[-1]
props[p] = v
return props
def write_file_prop(props: dict):
lines = []
for p, v in props.items():
lines.append(f"{p} = {v}")
return "\n".join(lines)
def set_file_prop(prop, value):
if bpy.data.texts.get("BioxelNodes") is None:
bpy.data.texts.new("BioxelNodes")
props = read_file_prop(bpy.data.texts["BioxelNodes"].as_string())
props[prop] = value
bpy.data.texts["BioxelNodes"].clear()
bpy.data.texts["BioxelNodes"].write(write_file_prop(props))
def get_file_prop(prop):
if bpy.data.texts.get("BioxelNodes") is None:
bpy.data.texts.new("BioxelNodes")
props = read_file_prop(bpy.data.texts["BioxelNodes"].as_string())
return props.get(prop)
def get_node_version():
node_version = get_file_prop("node_version")
return literal_eval(node_version) if node_version else None
def is_incompatible():
node_version = get_node_version()
if node_version is None:
for node_group in bpy.data.node_groups:
if node_group.name.startswith("BioxelNodes"):
return True
else:
addon_version = VERSIONS[0]["node_version"]
if node_version[0] != addon_version[0]\
or node_version[1] != addon_version[1]:
return True
return False
def get_node_lib_path(node_version):
version_str = "v"+".".join([str(i) for i in list(node_version)])
lib_filename = f"BioxelNodes_{version_str}.blend"
return Path(NODE_LIB_DIRPATH,
lib_filename).resolve()
def local_lib_not_updated():
addon_version = VERSIONS[0]["node_version"]
addon_lib_path = LATEST_NODE_LIB_PATH
use_local = False
for node_group in bpy.data.node_groups:
if node_group.name.startswith("BioxelNodes"):
lib = node_group.library
if lib:
lib_path = Path(bpy.path.abspath(lib.filepath)).resolve()
if lib_path != addon_lib_path:
use_local = True
break
not_update = get_node_version() != addon_version
return use_local and not_update
def get_output_node(node_group):
try:
output_node = get_nodes_by_type(node_group,
'NodeGroupOutput')[0]
except:
output_node = node_group.nodes.new("NodeGroupOutput")
return output_node

View File

@@ -1,186 +0,0 @@
import bpy
from ..bioxel.container import Container
from bpy_extras.io_utils import axis_conversion
from mathutils import Matrix, Vector
from .layer import Layer, layer_to_obj, obj_to_layer
from .common import (get_container_layer_objs,
get_layer_prop_value,
get_nodes_by_type, get_output_node,
move_node_to_node)
from .node import add_node_to_graph
from ..utils import get_use_link
NODE_TYPE = {
"label": "BioxelNodes_MaskByLabel",
"scalar": "BioxelNodes_MaskByThreshold"
}
def calc_bbox_verts(origin: tuple, size: tuple):
bbox_origin = Vector(
(origin[0], origin[1], origin[2]))
bbox_size = Vector(
(size[0], size[1], size[2]))
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 obj_to_container(container_obj: bpy.types.Object):
layer_objs = get_container_layer_objs(container_obj)
layers = [obj_to_layer(obj) for obj in layer_objs]
container = Container(name=container_obj.name,
layers=layers)
return container
def add_layers(layers: list[Layer],
container_obj: bpy.types.Object,
cache_dir: str):
node_group = container_obj.modifiers[0].node_group
output_node = get_output_node(node_group)
for i, layer in enumerate(layers):
layer_obj = layer_to_obj(layer, container_obj, cache_dir)
fetch_node = add_node_to_graph("FetchLayer",
node_group,
use_link=get_use_link())
fetch_node.label = get_layer_prop_value(layer_obj, "name")
fetch_node.inputs[0].default_value = layer_obj
if len(output_node.inputs[0].links) == 0:
node_group.links.new(fetch_node.outputs[0],
output_node.inputs[0])
move_node_to_node(fetch_node, output_node, (-900, 0))
else:
move_node_to_node(fetch_node, output_node, (0, -100 * (i+1)))
return container_obj
def container_to_obj(container: Container,
scene_scale: float,
step_size: float,
cache_dir: str):
# Wrapper a Container
# Make transformation
# (S)uperior -Z -> Y
# (A)osterior Y -> Z
mat_ras2blender = axis_conversion(from_forward='-Z',
from_up='Y',
to_forward='Y',
to_up='Z').to_4x4()
mat_scene_scale = Matrix.Scale(scene_scale,
4)
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((0, 0, 0), container.layers[0].shape)
for i, vert in enumerate(container_obj.data.vertices):
transform = Matrix(container.layers[0].affine)
vert.co = transform @ Vector(bbox_verts[i])
container_obj.matrix_world = mat_ras2blender @ mat_scene_scale
container_obj.name = container.name
container_obj.data.name = container.name
container_obj.show_in_front = True
container_obj.lock_location[0] = True
container_obj.lock_location[1] = True
container_obj.lock_location[2] = True
container_obj.lock_rotation[0] = True
container_obj.lock_rotation[1] = True
container_obj.lock_rotation[2] = True
container_obj.lock_scale[0] = True
container_obj.lock_scale[1] = True
container_obj.lock_scale[2] = True
container_obj['bioxel_container'] = True
container_obj["scene_scale"] = scene_scale
container_obj["step_size"] = step_size
modifier = container_obj.modifiers.new("GeometryNodes", 'NODES')
node_group = bpy.data.node_groups.new('GeometryNodes', 'GeometryNodeTree')
node_group.interface.new_socket(name="Output",
in_out="OUTPUT",
socket_type="NodeSocketGeometry")
modifier.node_group = node_group
node_group.nodes.new("NodeGroupOutput")
container_obj = add_layers(container.layers,
container_obj=container_obj,
cache_dir=cache_dir)
try:
layer_node = get_nodes_by_type(
node_group, "BioxelNodes_FetchLayer")[0]
center_node = add_node_to_graph("ReCenter",
node_group,
use_link=get_use_link())
output_node = get_output_node(node_group)
node_group.links.new(layer_node.outputs[0],
center_node.inputs[0])
node_group.links.new(center_node.outputs[0],
output_node.inputs[0])
move_node_to_node(center_node, layer_node, (300, 0))
bpy.ops.bioxelnodes.add_slicer('EXEC_DEFAULT')
except:
pass
return container_obj

View File

@@ -1,215 +0,0 @@
import random
import re
import bpy
import numpy as np
try:
import pyopenvdb as vdb
except:
import openvdb as vdb
from pathlib import Path
from uuid import uuid4
from ..bioxel.layer import Layer
from ..utils import get_use_link
from .node import add_node_to_graph
from .common import (get_layer_prop_value,
move_node_between_nodes)
def obj_to_layer(layer_obj: bpy.types.Object):
cache_filepath = Path(bpy.path.abspath(layer_obj.data.filepath)).resolve()
is_sequence = re.search(r'\.\d{4}\.',
cache_filepath.name) is not None
if is_sequence:
cache_path = cache_filepath.parent
data_frames = ()
for f in cache_path.iterdir():
if not f.is_file() or f.suffix != ".vdb":
continue
grids, base_metadata = vdb.readAll(str(f))
grid = grids[0]
metadata = grid.metadata
if grid["layer_kind"] in ['label', 'scalar']:
data_shape = grid["data_shape"]
else:
data_shape = tuple(list(grid["data_shape"]) + [3])
data_frame = np.ndarray(data_shape, np.float32)
grid.copyToArray(data_frame)
data_frames += (data_frame,)
data = np.stack(data_frames)
else:
grids, base_metadata = vdb.readAll(str(cache_filepath))
grid = grids[0]
metadata = grid.metadata
if grid["layer_kind"] in ['label', 'scalar']:
data_shape = grid["data_shape"]
else:
data_shape = tuple(list(grid["data_shape"]) + [3])
data = np.ndarray(data_shape, np.float32)
grid.copyToArray(data)
data = np.expand_dims(data, axis=0) # expend frame
name = get_layer_prop_value(layer_obj, "name") \
or metadata["layer_name"]
kind = get_layer_prop_value(layer_obj, "kind") \
or metadata["layer_kind"]
affine = metadata["layer_affine"]
dtype = get_layer_prop_value(layer_obj, "dtype") \
or metadata.get("data_dtype") or "float32"
offset = get_layer_prop_value(layer_obj, "offset") \
or metadata.get("data_offset") or 0
data = data - np.full_like(data, offset)
data = data.astype(dtype)
if kind in ["scalar", "label"]:
data = np.expand_dims(data, axis=-1) # expend channel
layer = Layer(data=data,
name=name,
kind=kind,
affine=affine)
return layer
def layer_to_obj(layer: Layer,
container_obj: bpy.types.Object,
cache_dir: str):
data = layer.data
# TXYZC > TXYZ
if layer.kind in ['label', 'scalar']:
data = np.amax(data, -1)
offset = 0
if layer.kind in ['scalar']:
data = data.astype(np.float32)
orig_min = float(np.min(data))
if orig_min < 0:
offset = -orig_min
data = data + np.full_like(data, offset)
metadata = {
"layer_name": layer.name,
"layer_kind": layer.kind,
"layer_affine": layer.affine.tolist(),
"data_shape": layer.shape,
"data_dtype": layer.data.dtype.str,
"data_offset": offset
}
layer_display_name = f"{container_obj.name}_{layer.name}"
if layer.frame_count > 1:
print(f"Saving the Cache of {layer.name}...")
vdb_name = str(uuid4())
sequence_path = Path(cache_dir, vdb_name)
sequence_path.mkdir(parents=True, exist_ok=True)
cache_filepaths = []
for f in range(layer.frame_count):
if layer.kind in ['label', 'scalar']:
grid = vdb.FloatGrid()
grid.copyFromArray(data[f, :, :, :].copy().astype(np.float32))
else:
# color
grid = vdb.Vec3SGrid()
grid.copyFromArray(
data[f, :, :, :, :].copy().astype(np.float32))
grid.transform = vdb.createLinearTransform(
layer.affine.transpose())
grid.metadata = metadata
grid.name = layer.kind
cache_filepath = Path(sequence_path,
f"{vdb_name}.{str(f+1).zfill(4)}.vdb")
vdb.write(str(cache_filepath), grids=[grid])
cache_filepaths.append(cache_filepath)
else:
if layer.kind in ['label', 'scalar']:
grid = vdb.FloatGrid()
grid.copyFromArray(data[0, :, :, :].copy().astype(np.float32))
else:
# color
grid = vdb.Vec3SGrid()
grid.copyFromArray(data[0, :, :, :, :].copy().astype(np.float32))
grid.transform = vdb.createLinearTransform(
layer.affine.transpose())
grid.metadata = metadata
grid.name = layer.kind
print(f"Saving the Cache of {layer.name}...")
cache_filepath = Path(cache_dir, f"{uuid4()}.vdb")
vdb.write(str(cache_filepath), grids=[grid])
cache_filepaths = [cache_filepath]
layer_data = bpy.data.volumes.new(layer_display_name)
layer_data.render.space = 'WORLD'
scene_scale = container_obj.get("scene_scale") or 0.01
step_size = container_obj.get("step_size") or 1
layer_data.render.step_size = scene_scale * step_size
layer_data.sequence_mode = 'REPEAT'
layer_data.filepath = str(cache_filepaths[0])
if layer.frame_count > 1:
layer_data.is_sequence = True
layer_data.frame_duration = layer.frame_count
else:
layer_data.is_sequence = False
layer_obj = bpy.data.objects.new(layer_display_name, layer_data)
layer_obj['bioxel_layer'] = True
print(f"Creating Node for {layer.name}...")
modifier = layer_obj.modifiers.new("GeometryNodes", 'NODES')
node_group = bpy.data.node_groups.new('GeometryNodes', 'GeometryNodeTree')
node_group.interface.new_socket(name="Cache",
in_out="INPUT",
socket_type="NodeSocketGeometry")
node_group.interface.new_socket(name="Layer",
in_out="OUTPUT",
socket_type="NodeSocketGeometry")
modifier.node_group = node_group
layer_node = add_node_to_graph("_Layer",
node_group,
use_link=get_use_link())
layer_node.inputs['name'].default_value = layer.name
layer_node.inputs['shape'].default_value = layer.shape
layer_node.inputs['kind'].default_value = layer.kind
for i in range(layer.affine.shape[1]):
for j in range(layer.affine.shape[0]):
affine_key = f"affine{i}{j}"
layer_node.inputs[affine_key].default_value = layer.affine[j, i]
layer_node.inputs['unique'].default_value = random.uniform(0, 1)
layer_node.inputs['bioxel_size'].default_value = layer.bioxel_size[0]
layer_node.inputs['dtype'].default_value = layer.dtype.str
layer_node.inputs['dtype_num'].default_value = layer.dtype.num
layer_node.inputs['frame_count'].default_value = layer.frame_count
layer_node.inputs['channel_count'].default_value = layer.channel_count
layer_node.inputs['offset'].default_value = max(0, -layer.min)
layer_node.inputs['min'].default_value = layer.min
layer_node.inputs['max'].default_value = layer.max
input_node = node_group.nodes.new("NodeGroupInput")
output_node = node_group.nodes.new("NodeGroupOutput")
node_group.links.new(input_node.outputs[0],
layer_node.inputs[0])
node_group.links.new(layer_node.outputs[0],
output_node.inputs[0])
move_node_between_nodes(
layer_node, [input_node, output_node])
layer_obj.parent = container_obj
return layer_obj

View File

@@ -1,71 +0,0 @@
from pathlib import Path
import bpy
from .common import get_file_prop, set_file_prop
from ..exceptions import NoFound
from ..constants import LATEST_NODE_LIB_PATH, VERSIONS
def get_node_tree(node_type: str, use_link=True):
# unannotate below for local debug in node lib file.
# node_group = bpy.data.node_groups[node_type]
# return node_group
# added node is always from latest node version
addon_version = VERSIONS[0]["node_version"]
addon_lib_path = LATEST_NODE_LIB_PATH
if get_file_prop("node_version") is None:
set_file_prop("node_version", addon_version)
local_lib = None
for node_group in bpy.data.node_groups:
if node_group.name.startswith("BioxelNodes"):
lib = node_group.library
if lib:
lib_path = Path(bpy.path.abspath(lib.filepath)).resolve()
if lib_path != addon_lib_path:
local_lib = lib.filepath
break
# local lib first
lib_path_str = local_lib or str(addon_lib_path)
with bpy.data.libraries.load(lib_path_str,
link=use_link,
relative=True) as (data_from, data_to):
data_to.node_groups = [n for n in data_from.node_groups
if n == node_type]
node_tree = data_to.node_groups[0]
if node_tree is None:
raise NoFound('No custom node found')
return node_tree
def assign_node_tree(node, node_tree):
node.node_tree = node_tree
node.width = 200.0
node.name = node_tree.name
return node
def add_node_to_graph(node_name: str, node_group, node_label=None, use_link=True):
node_type = f"BioxelNodes_{node_name}"
node_label = node_label or node_name
# Deselect all nodes first
for node in node_group.nodes:
if node.select:
node.select = False
node_tree = get_node_tree(node_type, use_link)
node = node_group.nodes.new("GeometryNodeGroup")
assign_node_tree(node, node_tree)
node.label = node_label
node.show_options = False
return node

View File

@@ -1,7 +1,7 @@
schema_version = "1.0.0"
id = "bioxelnodes"
version = "1.0.9"
version = "2.0.0"
name = "Bioxel Nodes"
tagline = "For scientific volumetric data visualization in Blender"
maintainer = "Ma Nan <icrdr2010@outlook.com>"
@@ -9,12 +9,30 @@ type = "add-on"
website = "https://omoolab.github.io/BioxelNodes/latest"
tags = ["Geometry Nodes", "Render", "Import-Export"]
blender_version_min = "4.2.0"
blender_version_min = "5.0.0"
license = ['SPDX:GPL-3.0-or-later']
copyright = ["2024 OmooLab"]
platforms = ["windows-x64"]
wheels = ["./wheels/h5py-3.11.0-cp311-cp311-win_amd64.whl", "./wheels/lxml-5.3.2-cp311-cp311-win_amd64.whl", "./wheels/mrcfile-1.5.1-py2.py3-none-any.whl", "./wheels/pyometiff-1.0.0-py3-none-any.whl", "./wheels/SimpleITK-2.3.1-cp311-cp311-win_amd64.whl", "./wheels/tifffile-2024.7.24-py3-none-any.whl", "./wheels/transforms3d-0.4.2-py3-none-any.whl"]
wheels = [
"./wheels/contourpy-1.3.3-cp311-cp311-win_amd64.whl",
"./wheels/cycler-0.12.1-py3-none-any.whl",
"./wheels/fonttools-4.60.1-cp311-cp311-win_amd64.whl",
"./wheels/h5py-3.11.0-cp311-cp311-win_amd64.whl",
"./wheels/kiwisolver-1.4.9-cp311-cp311-win_amd64.whl",
"./wheels/lxml-6.0.2-cp311-cp311-win_amd64.whl",
"./wheels/matplotlib-3.10.7-cp311-cp311-win_amd64.whl",
"./wheels/mrcfile-1.5.1-py2.py3-none-any.whl",
"./wheels/packaging-25.0-py3-none-any.whl",
"./wheels/pillow-11.2.1-cp311-cp311-win_amd64.whl",
"./wheels/pyometiff-1.0.0-py3-none-any.whl",
"./wheels/pyparsing-3.2.5-py3-none-any.whl",
"./wheels/python_dateutil-2.9.0.post0-py2.py3-none-any.whl",
"./wheels/SimpleITK-2.3.1-cp311-cp311-win_amd64.whl",
"./wheels/six-1.17.0-py2.py3-none-any.whl",
"./wheels/tifffile-2024.7.24-py3-none-any.whl",
"./wheels/transforms3d-0.4.2-py3-none-any.whl",
]
[permissions]
files = "Import/export volume data from/to disk"
files = "Import/export volume data from/to disk"

View File

@@ -1,257 +1,9 @@
from pathlib import Path
VERSIONS = [{"label": "Latest", "node_version": (1, 0, 7)},
{"label": "v0.3.x", "node_version": (0, 3, 3)},
{"label": "v0.2.x", "node_version": (0, 2, 9)}]
NODE_LIB_DIRPATH = Path(Path(__file__).parent,
"assets/Nodes").resolve()
"assets/O Bioxel").resolve()
LATEST_NODE_LIB_PATH = lib_path = Path(NODE_LIB_DIRPATH,
"BioxelNodes_latest.blend").resolve()
LATEST_NODE_LIB_PATH = Path(NODE_LIB_DIRPATH,
"BioxelNodes.blend").resolve()
COMPONENT_OUTPUT_NODES = [
"CutoutByThreshold",
"CutoutByRange",
"CutoutByHue",
"JoinComponent",
"SetProperties",
"SetColor",
"SetColorByColor",
"SetColorByColor",
"SetColorByRamp2",
"SetColorByRamp3",
"SetColorByRamp4",
"SetColorByRamp5",
"Cut",
]
MENU_ITEMS = [
{
'label': 'Component',
'icon': 'OUTLINER_DATA_VOLUME',
'items': [
{
'label': 'Cutout by Threshold',
'icon': 'EMPTY_SINGLE_ARROW',
'name': 'CutoutByThreshold',
'description': ''
},
{
'label': 'Cutout by Range',
'icon': 'IPO_CONSTANT',
'name': 'CutoutByRange',
'description': ''
},
{
'label': 'Cutout by Hue',
'icon': 'COLOR',
'name': 'CutoutByHue',
'description': ''
},
"separator",
{
'label': 'To Surface',
'icon': 'MESH_DATA',
'name': 'ToSurface',
'description': ''
},
{
'label': 'Join Component',
'icon': 'CONSTRAINT_BONE',
'name': 'JoinComponent',
'description': ''
},
{
'label': 'Slice',
'icon': 'TEXTURE',
'name': 'Slice',
'description': ''
}
]
},
{
'label': 'Property',
'icon': 'PROPERTIES',
'items': [
{
'label': 'Set Properties',
'icon': 'PROPERTIES',
'name': 'SetProperties',
'description': ''
},
"separator",
{
'label': 'Set Color',
'icon': 'IPO_SINE',
'name': 'SetColor',
'description': ''
},
{
'label': 'Set Color by Color',
'icon': 'IPO_QUINT',
'name': 'SetColorByColor',
'description': ''
},
{
'label': 'Set Color by Ramp 2',
'icon': 'IPO_QUAD',
'name': 'SetColorByRamp2',
'description': ''
},
{
'label': 'Set Color by Ramp 3',
'icon': 'IPO_CUBIC',
'name': 'SetColorByRamp3',
'description': ''
},
{
'label': 'Set Color by Ramp 4',
'icon': 'IPO_QUART',
'name': 'SetColorByRamp4',
'description': ''
},
{
'label': 'Set Color by Ramp 5',
'icon': 'IPO_QUINT',
'name': 'SetColorByRamp5',
'description': ''
}
]
},
{
'label': 'Surface',
'icon': 'MESH_DATA',
'items': [
{
'label': 'Membrane Shader',
'icon': 'NODE_MATERIAL',
'name': 'AssignMembraneShader',
'description': ''
},
{
'label': 'Solid Shader',
'icon': 'SHADING_SOLID',
'name': 'AssignSolidShader',
'description': ''
},
{
'label': 'Slime Shader',
'icon': 'OUTLINER_DATA_META',
'name': 'AssignSlimeShader',
'description': ''
},
]
},
{
'label': 'Transform',
'icon': 'EMPTY_AXIS',
'items': [
{
'label': 'Transform',
'icon': 'EMPTY_AXIS',
'name': 'Transform',
'description': ''
},
{
'label': 'Transform Parent',
'icon': 'ORIENTATION_PARENT',
'name': 'TransformParent',
'description': ''
},
{
'label': 'ReCenter',
'icon': 'PROP_CON',
'name': 'ReCenter',
'description': ''
},
{
'label': 'Copy Transform',
'icon': 'EMPTY_AXIS',
'name': 'CopyTransform',
'description': ''
},
{
'label': 'Extract Transform',
'icon': 'EMPTY_AXIS',
'name': 'ExtractTransform',
'description': ''
},
]
},
{
'label': 'Cut',
'icon': 'MOD_BEVEL',
'items': [
{
'label': 'Cut',
'icon': 'MOD_BEVEL',
'name': 'Cut',
'description': ''
},
"separator",
{
'label': 'Primitive Cutter',
'icon': 'MOD_LINEART',
'name': 'PrimitiveCutter',
'description': ''
},
{
'label': 'Object Cutter',
'icon': 'MESH_PLANE',
'name': 'ObjectCutter',
'description': ''
}
]
},
{
'label': 'Extra',
'icon': 'MODIFIER',
'items': [
{
'label': 'Fetch Mesh',
'icon': 'OUTLINER_OB_MESH',
'name': 'FetchMesh',
'description': ''
},
{
'label': 'Fetch Volume',
'icon': 'OUTLINER_OB_VOLUME',
'name': 'FetchVolume',
'description': ''
},
{
'label': 'Fetch Shape Wire',
'icon': 'FILE_VOLUME',
'name': 'FetchShapeWire',
'description': ''
},
{
'label': 'Fetch Bbox Wire',
'icon': 'MESH_CUBE',
'name': 'FetchBboxWire',
'description': ''
},
"separator",
{
'label': 'Inflate',
'icon': 'OUTLINER_OB_META',
'name': 'Inflate',
'description': ''
},
{
'label': 'Smooth',
'icon': 'MOD_SMOOTH',
'name': 'Smooth',
'description': ''
},
{
'label': 'Remove Small Island',
'icon': 'FORCE_LENNARDJONES',
'name': 'RemoveSmallIsland',
'description': ''
}
]
}
]
PREVIEW_COLLECTIONS = {}

View File

@@ -1,22 +1,6 @@
class CancelledByUser(Exception):
def __init__(self):
message = 'Cancelled by user'
super().__init__(message)
class NoContent(Exception):
def __init__(self, message):
super().__init__(message)
self.message = message
class NoFound(Exception):
def __init__(self, message):
super().__init__(message)
self.message = message
class Incompatible(Exception):
def __init__(self, message):
super().__init__(message)
self.message = message
# This file is kept for backward compatibility
# Custom exceptions have been replaced with built-in exceptions:
# CancelledByUser -> CancelledError
# NoContent -> ValueError
# NoFound -> LookupError
# Incompatible -> ValueError

277
src/bioxelnodes/layer.py Normal file
View File

@@ -0,0 +1,277 @@
import json
import time
from typing import Any, List, Dict
from pathlib import Path
import matplotlib.pyplot as plt
import bpy
import numpy as np
import openvdb as vdb
from .bioxel.layer import Layer
from .utils import ndarray_to_png
LAYERS_JSON = "bioxel_layers"
def cache_layer_data(layer: Layer, cache_path: str):
"""
Cache the given Layer's data as one or more VDB files.
- For multi-frame layers, writes per-frame VDB files named data.0001.vdb, data.0002.vdb, ...
- For single-frame layers, writes a single data.vdb file.
- Applies basic type handling:
- label/scalar layers collapse channel dimension via max.
- scalar layers are offset to avoid negative values.
- The VDB grids will have their transform set from layer.affine but no additional metadata is written.
Parameters:
- layer: Layer object containing ndarray data and metadata.
- cache_path: directory path where VDB files will be written (created if missing).
"""
data = layer.data
# 处理标量/标签类型(去除通道维度)
if layer.kind in ["label", "scalar"]:
data = np.amax(data, -1)
# 标量类型偏移处理(避免负值)
offset = 0
if layer.kind in ["scalar"]:
data = data.astype(np.float32)
orig_min = float(np.min(data))
if orig_min < 0:
offset = -orig_min
data = data + np.full_like(data, offset)
# 创建缓存目录
cache_path = Path(cache_path)
cache_path.mkdir(parents=True, exist_ok=True)
# 多帧序列处理
if layer.frame_count > 1:
for f in range(layer.frame_count):
# 根据图层类型创建VDB网格
if layer.kind in ["label", "scalar"]:
grid = vdb.FloatGrid()
grid.copyFromArray(data[f, :, :, :].copy().astype(np.float32))
else: # 颜色类型
grid = vdb.Vec3SGrid()
grid.copyFromArray(
data[f, :, :, :, :].copy().astype(np.float32))
# 仅设置transform,不存储metadata
grid.transform = vdb.createLinearTransform(
layer.affine.transpose())
grid.name = layer.kind
# 保存序列帧VDB
data_filepath = cache_path / f"data.{str(f+1).zfill(4)}.vdb"
vdb.write(str(data_filepath), grids=[grid])
else:
# 单帧处理
if layer.kind in ["label", "scalar"]:
grid = vdb.FloatGrid()
grid.copyFromArray(data[0, :, :, :].copy().astype(np.float32))
else: # 颜色类型
grid = vdb.Vec3SGrid()
grid.copyFromArray(data[0, :, :, :, :].copy().astype(np.float32))
# 仅设置transform,不存储metadata
grid.transform = vdb.createLinearTransform(layer.affine.transpose())
grid.name = layer.kind
# 保存单帧VDB
data_filepath = cache_path / "data.vdb"
vdb.write(str(data_filepath), grids=[grid])
def cache_layer_snapshot(layer: Layer, cache_path: str):
"""
Create and save a low-resolution snapshot (numpy .npy) of the layer and generate PNG slices.
- Saves a 3D numpy ndarray snapshot to <cache_path>/snapshot.npy.
- Then creates per-Z PNGs in the same directory via snapshot_to_pngs.
Parameters:
- layer: Layer to snapshot (expects Layer.snapshot method).
- cache_path: destination directory where snapshot.npy and PNGs will be created.
"""
cache_path = Path(cache_path)
cache_path.mkdir(parents=True, exist_ok=True)
snapshot_filepath = cache_path / "snapshot.npy"
snapshot = layer.snapshot((64, 64, 32))
np.save(str(snapshot_filepath), snapshot)
snapshot_to_pngs(
snapshot, str(cache_path), normalize=layer.kind in ["scalar", "vector"]
)
if layer.kind in ["scalar", "vector", "color"]:
plt.figure(figsize=(8, 8))
def plot_histogram(plt, data, color="black"):
flattened = data.flatten()
x_min = np.percentile(flattened, 1)
x_max = np.percentile(flattened, 99)
n, bins, _ = plt.hist(
flattened,
bins=50, # 自动根据数据密度调整bins数量(避免空柱)
range=(x_min, x_max), # 只显示核心区域,省略稀疏部分
alpha=0,
edgecolor="none", # 去除边框,使柱形更紧凑
)
bin_centers = 0.5 * (bins[1:] + bins[:-1]) # 计算每个bin的中点
plt.plot(bin_centers, n, color=color, linewidth=4) # 折线
plt.fill_between(bin_centers, n, color=color, alpha=0.1)
if layer.kind == "scalar":
plot_histogram(plt, layer.data[:, :, :, 0], color="black")
elif layer.kind in ["vector", "color"]:
plot_histogram(plt, layer.data[:, :, :, 0], color="tomato")
plot_histogram(plt, layer.data[:, :, :, 1], color="yellowgreen")
plot_histogram(plt, layer.data[:, :, :, 2], color="xkcd:azure")
plt.yscale("log")
plt.gca().get_yaxis().set_visible(False)
plt.xticks(fontsize=32, rotation=45) # 调整字体大小(数值越大字体越大)
ax = plt.gca()
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_visible(False) # 隐藏左边框
# 调整布局避免标签被截断
plt.tight_layout()
# 保存图片到指定路径
save_path = cache_path / "histogram.png"
plt.savefig(save_path, bbox_inches="tight", pad_inches=0.1) # 减少边缘留白)
# 关闭图像释放资源
plt.close()
def snapshot_to_pngs(snapshot, cache_path: str, normalize=False):
"""
Convert a 3D/4D snapshot ndarray into per-slice PNG files.
- snapshot is expected with axes order X, Y, Z, (C optional).
- Writes files snapshot_0.png ... snapshot_{Z-1}.png into cache_path.
- Uses ndarray_to_png to perform the per-slice conversion.
Parameters:
- snapshot: numpy ndarray (X, Y, Z[, C])
- cache_path: directory where generated PNGs are stored.
"""
cache_path = Path(cache_path)
cache_path.mkdir(parents=True, exist_ok=True)
if normalize:
mn = float(np.percentile(snapshot, 50))
mx = float(np.percentile(snapshot, 99))
if mx - mn > 1e-8:
snapshot = (snapshot - mn) / (mx - mn)
else:
snapshot = np.clip(snapshot, 0.0, 1.0)
shape = snapshot.shape
for zidx in range(shape[2]):
array = snapshot[:, :, zidx, :]
ndarray_to_png(array, str(cache_path / f"snapshot_{zidx}.png"))
def get_layer_caches() -> List[Dict[str, Any]]:
"""
Read the saved layer metadata list from Blender's internal text datablock.
- Looks for a text datablock named by LAYERS_JSON ("bioxel_layers").
- Returns the parsed list of layer dictionaries if present; otherwise returns an empty list.
Returns:
- list of layer metadata dictionaries (possibly empty)
"""
# 检查文本数据块是否存在
if LAYERS_JSON not in bpy.data.texts:
return []
# 加载并解析数据
layers_text = bpy.data.texts[LAYERS_JSON]
try:
layers_data = json.loads(layers_text.as_string())
# 确保返回格式为列表
return layers_data if isinstance(layers_data, list) else []
except:
return []
def set_layer_caches(layers_data: List[Dict[str, Any]]):
"""
Write the provided list of layer metadata dictionaries into the Blender text datablock.
- Ensures the LAYERS_JSON text datablock exists, then overwrites it with pretty JSON.
Parameters:
- layers_data: list of serializable dictionaries describing the layers
"""
# 获取或创建文本数据块
if LAYERS_JSON not in bpy.data.texts:
bpy.data.texts.new(LAYERS_JSON)
layers_text = bpy.data.texts[LAYERS_JSON]
# 写入数据
layers_text.clear()
layers_text.write(json.dumps(layers_data, indent=4))
def save_layers_to_json(layers: List[Layer], cache_dir: str) -> List[int]:
"""
Save multiple Layer objects into the internal layers text datablock.
For each layer:
- Generates a unique cache id.
- Writes VDB files and a low-resolution snapshot (.npy) plus PNG slices under cache_dir/<cache_id>/.
- Appends an entry describing the layer into the b i o x e l _ l a y e r s text datablock.
Returns:
- List of generated cache ids for the saved layers.
"""
existing_data = get_layer_caches()
added_ids = []
cache_dir_path = Path(cache_dir)
cache_dir_path.mkdir(parents=True, exist_ok=True)
for idx, layer in enumerate(layers):
cache_id = str(int(time.time())) + str(idx)
cache_path = cache_dir_path / str(cache_id)
cache_layer_data(layer, cache_path)
cache_layer_snapshot(layer, cache_path)
# build layer_info
cache_info = {
"id": cache_id,
"name": layer.name,
"kind": layer.kind,
"shape": layer.shape,
"affine": layer.affine.tolist(),
"bioxel_size": layer.bioxel_size[0],
"dtype": layer.dtype.str,
"dtype_num": layer.dtype.num,
"frame_count": layer.frame_count,
"channel_count": layer.channel_count,
"offset": max(0, -layer.min),
"min": layer.min,
"max": layer.max,
"path": bpy.path.abspath(str(cache_path)),
"snapshot_z": 0.5,
}
existing_data.append(cache_info)
added_ids.append(cache_id)
set_layer_caches(existing_data)
print(
f"Successfully added {len(added_ids)} layers to the internal text datablock")
return added_ids

View File

@@ -1,187 +1,40 @@
import bpy
from .operators.structure import ExtractMesh
from .constants import MENU_ITEMS, VERSIONS
from .node_menu import NodeMenu
from .bioxelutils.common import (get_container_obj,
get_container_layer_objs, get_layer_label,
get_layer_prop_value, get_node_version, is_incompatible)
from .operators.layer import (FetchLayer, RelocateLayer, RetimeLayer, RenameLayer,
RemoveSelectedLayers, SaveSelectedLayersCache,
ResampleLayer, SignScalar, CombineLabels,
FillByLabel, FillByThreshold, FillByRange)
from .operators.container import (AddLocator, AddSlicer, ContainerProps,
SaveContainerLayersCache, RemoveContainerMissingLayers,
SaveContainer, LoadContainer,
AddPieCutter, AddPlaneCutter,
AddCylinderCutter, AddCubeCutter, AddSphereCutter,
ExtractBboxWire, ExtractMesh, ExtractShapeWire,
ExtractNodeMesh, ExtractNodeBboxWire, ExtractNodeShapeWire)
from .operators.io import (ImportAsLabel, ImportAsScalar, ImportAsColor)
from .operators.misc import (AddEeveeEnv, CleanTemp, Help,
ReLinkNodeLib, RemoveAllMissingLayers,
RenderSettingPreset, SaveAllLayersCache,
SaveNodeLib, SliceViewer)
class IncompatibleMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_INCOMPATIBLE"
bl_label = "Bioxel Nodes"
def draw(self, context):
tip_text = "please downgrade addon version."
node_version = get_node_version()
if node_version:
version_str = "v"+".".join([str(i) for i in list(node_version)])
tip_text = f"please downgrade addon version to {version_str}."
layout = self.layout
layout.label(text="Incompatible node version detected.")
layout.separator()
layout.label(
text="If you still want to edit this file with bioxel nodes, ")
layout.label(text=tip_text)
layout.separator()
layout.label(text="If this file is archived, "
"please relink node library, ")
layout.label(text="check if it still works, "
"then save node library.")
layout.separator()
layout.menu(ReLinkNodeLibMenu.bl_idname)
layout.operator(SaveNodeLib.bl_idname)
layout.separator()
layout.menu(DangerZoneMenu.bl_idname)
layout.separator()
layout.menu(RenderSettingMenu.bl_idname)
layout.separator()
layout.operator(Help.bl_idname,
icon=Help.bl_icon)
class FetchLayerMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_ADD_LAYER"
bl_label = "Fetch Layer"
def draw(self, context):
container_obj = get_container_obj(bpy.context.active_object)
layer_objs = get_container_layer_objs(container_obj)
layout = self.layout
for layer_obj in layer_objs:
op = layout.operator(FetchLayer.bl_idname,
text=get_layer_label(layer_obj))
op.layer_obj_name = layer_obj.name
class ExtractFromContainerMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_PICK"
bl_label = "Extract from Container"
def draw(self, context):
layout = self.layout
layout.operator(ExtractMesh.bl_idname)
layout.operator(ExtractShapeWire.bl_idname)
layout.operator(ExtractBboxWire.bl_idname)
class AddCutterMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_CUTTERS"
bl_label = "Add a Cutter"
def draw(self, context):
layout = self.layout
layout.operator(AddPlaneCutter.bl_idname,
icon=AddPlaneCutter.bl_icon, text="Plane Cutter")
layout.operator(AddCylinderCutter.bl_idname,
icon=AddCylinderCutter.bl_icon, text="Cylinder Cutter")
layout.operator(AddCubeCutter.bl_idname,
icon=AddCubeCutter.bl_icon, text="Cube Cutter")
layout.operator(AddSphereCutter.bl_idname,
icon=AddSphereCutter.bl_icon, text="Sphere Cutter")
layout.operator(AddPieCutter.bl_idname,
icon=AddPieCutter.bl_icon, text="Pie Cutter")
from .operators.io import ImportAsLabel, ImportAsScalar, ImportAsColor
from .operators.misc import (
CleanTemp,
Help,
RenderSettingPreset,
TogglePhantom,
)
class ImportLayerMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_IMPORTLAYER"
bl_idname = "BIOXEL_MT_IMPORTLAYER"
bl_label = "Import Volumetric Data (Init)"
bl_icon = "FILE_NEW"
def draw(self, context):
layout = self.layout
layout.operator(ImportAsScalar.bl_idname,
text="as Scalar")
layout.operator(ImportAsLabel.bl_idname,
text="as Label")
layout.operator(ImportAsColor.bl_idname,
text="as Color")
class AddLayerMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_ADDLAYER"
bl_label = "Import Volumetric Data (Add)"
bl_icon = "FILE_NEW"
def draw(self, context):
layout = self.layout
layout.operator(ImportAsScalar.bl_idname,
text="as Scalar")
layout.operator(ImportAsLabel.bl_idname,
text="as Label")
layout.operator(ImportAsColor.bl_idname,
text="as Color")
class ModifyLayerMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_MODIFYLAYER"
bl_label = "Modify Layer"
bl_icon = "FILE_NEW"
def draw(self, context):
layout = self.layout
layout.operator(SignScalar.bl_idname,
icon=SignScalar.bl_icon)
layout.operator(FillByThreshold.bl_idname,
icon=FillByThreshold.bl_icon)
layout.operator(FillByRange.bl_idname,
icon=FillByRange.bl_icon)
layout.operator(FillByLabel.bl_idname,
icon=FillByLabel.bl_icon)
layout.operator(CombineLabels.bl_idname,
icon=CombineLabels.bl_icon)
class ReLinkNodeLibMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_RELINK"
bl_label = "Relink Node Library"
def draw(self, context):
layout = self.layout
for index, version in enumerate(VERSIONS):
op = layout.operator(ReLinkNodeLib.bl_idname,
text=version["label"])
op.index = index
layout.operator(ImportAsScalar.bl_idname, text="as Scalar")
layout.operator(ImportAsLabel.bl_idname, text="as Label")
layout.operator(ImportAsColor.bl_idname, text="as Color")
class RenderSettingMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_RENDER"
bl_idname = "BIOXEL_MT_RENDER"
bl_label = "Render Setting Presets"
def draw(self, context):
layout = self.layout
for k, v in RenderSettingPreset.PRESETS.items():
op = layout.operator(RenderSettingPreset.bl_idname,
text=v)
op = layout.operator(RenderSettingPreset.bl_idname, text=v)
op.preset = k
class DangerZoneMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_DANGER"
bl_idname = "BIOXEL_MT_DANGER"
bl_label = "Danger Zone"
def draw(self, context):
@@ -190,118 +43,44 @@ class DangerZoneMenu(bpy.types.Menu):
class BioxelNodesTopbarMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_TOPBAR"
bl_idname = "BIOXEL_MT_TOPBAR"
bl_label = "Bioxel Nodes"
def draw(self, context):
layout = self.layout
layout.menu(ImportLayerMenu.bl_idname,
icon=ImportLayerMenu.bl_icon)
layout.operator(LoadContainer.bl_idname)
layout.separator()
layout.operator(SaveAllLayersCache.bl_idname)
layout.operator(RemoveAllMissingLayers.bl_idname)
layout.separator()
layout.menu(ReLinkNodeLibMenu.bl_idname)
layout.operator(SaveNodeLib.bl_idname)
layout.separator()
layout.menu(DangerZoneMenu.bl_idname)
layout.separator()
layout.operator(AddEeveeEnv.bl_idname)
layout.menu(RenderSettingMenu.bl_idname)
layout.separator()
layout.operator(Help.bl_idname,
icon=Help.bl_icon)
def TOPBAR(self, context):
layout = self.layout
if is_incompatible():
layout.menu(IncompatibleMenu.bl_idname)
else:
layout.menu(BioxelNodesTopbarMenu.bl_idname)
class NodeHeadMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_NODE_HEAD"
bl_label = "Bioxel Nodes"
bl_icon = "FILE_VOLUME"
def draw(self, context):
layout = self.layout
layout.menu(AddLayerMenu.bl_idname,
icon=AddLayerMenu.bl_icon)
layout.operator(SaveContainer.bl_idname)
layout.separator()
layout.operator(ContainerProps.bl_idname)
layout.menu(ExtractFromContainerMenu.bl_idname)
layout.separator()
layout.operator(SaveContainerLayersCache.bl_idname,
icon=SaveContainerLayersCache.bl_icon)
layout.operator(RemoveContainerMissingLayers.bl_idname)
layout.separator()
layout.operator(AddLocator.bl_idname,
icon=AddLocator.bl_icon)
layout.operator(AddSlicer.bl_idname,
icon=AddSlicer.bl_icon)
layout.menu(AddCutterMenu.bl_idname)
layout.separator()
layout.menu(FetchLayerMenu.bl_idname)
layout.operator(Help.bl_idname, icon=Help.bl_icon)
class NodeContextMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_NODE_CONTEXT"
bl_idname = "BIOXEL_MT_NODE_CONTEXT"
bl_label = "Bioxel Nodes"
bl_icon = "FILE_VOLUME"
def draw(self, context):
layout = self.layout
layout.operator(ExtractNodeMesh.bl_idname)
layout.operator(ExtractNodeShapeWire.bl_idname)
layout.operator(ExtractNodeBboxWire.bl_idname)
layout.operator(ExtractMesh.bl_idname)
layout.separator()
layout.operator(SaveSelectedLayersCache.bl_idname,
icon=SaveSelectedLayersCache.bl_icon)
layout.operator(RemoveSelectedLayers.bl_idname)
layout.separator()
layout.operator(RenameLayer.bl_idname,
icon=RenameLayer.bl_icon)
layout.operator(RetimeLayer.bl_idname)
layout.operator(RelocateLayer.bl_idname)
class ObjectContextMenu(bpy.types.Menu):
bl_idname = "BIOXEL_MT_OBJECT_CONTEXT"
bl_label = "Bioxel Nodes"
bl_icon = "FILE_VOLUME"
layout.separator()
layout.operator(ResampleLayer.bl_idname,
icon=ResampleLayer.bl_icon)
layout.operator(SignScalar.bl_idname)
layout.operator(FillByThreshold.bl_idname)
layout.operator(FillByRange.bl_idname)
layout.operator(FillByLabel.bl_idname)
layout.operator(CombineLabels.bl_idname)
def draw(self, context):
layout = self.layout
layout.operator(TogglePhantom.bl_idname)
def NODE_CONTEXT(self, context):
if is_incompatible():
return
container_obj = context.object
is_geo_nodes = context.area.ui_type == "GeometryNodeTree"
is_container = get_container_obj(container_obj)
if not is_geo_nodes or not is_container:
if not is_geo_nodes or not container_obj:
return
layout = self.layout
@@ -309,127 +88,18 @@ def NODE_CONTEXT(self, context):
layout.menu(NodeContextMenu.bl_idname)
def NODE_HEAD(self, context):
if is_incompatible():
return
container_obj = context.object
is_geo_nodes = context.area.ui_type == "GeometryNodeTree"
is_container = get_container_obj(container_obj)
if not is_geo_nodes or not is_container:
return
# 定义添加到右键菜单的函数,放在首位
def OBJECT_CONTEXT(self, context):
layout = self.layout
layout.separator()
layout.menu(NodeHeadMenu.bl_idname)
def NODE_PROP(self, context):
if is_incompatible():
return
container_obj = context.object
is_geo_nodes = context.area.ui_type == "GeometryNodeTree"
is_container = get_container_obj(container_obj)
self.bl_label = "Group"
if not is_geo_nodes or not is_container:
return
if container_obj.modifiers[0].node_group != context.space_data.edit_tree:
return
self.bl_label = "Bioxel Nodes"
layer_list_UL = bpy.context.window_manager.bioxelnodes_layer_list_UL
layer_list = layer_list_UL.layer_list
layer_list.clear()
for layer_obj in get_container_layer_objs(container_obj):
layer_item = layer_list.add()
layer_item.label = get_layer_label(layer_obj)
layer_item.obj_name = layer_obj.name
layer_item.info_text = "\n".join([f"{prop}: {get_layer_prop_value(layer_obj, prop)}"
for prop in ["kind",
"bioxel_size",
"shape",
"frame_count",
"channel_count",
"min", "max"]])
layout = self.layout
layout.label(text="Layer List")
split = layout.row()
split.template_list(listtype_name="BIOXELNODES_UL_layer_list",
list_id="layer_list",
dataptr=layer_list_UL,
propname="layer_list",
active_dataptr=layer_list_UL,
active_propname="layer_list_active",
item_dyntip_propname="info_text",
rows=20)
# sidebar = split.column(align=True)
# sidebar.menu(AddLayerMenu.bl_idname,
# icon=AddLayerMenu.bl_icon, text="")
# sidebar.operator(SaveContainerLayersCache.bl_idname,
# icon=SaveContainerLayersCache.bl_icon, text="")
# sidebar.operator(RemoveContainerMissingLayers.bl_idname,
# icon=RemoveContainerMissingLayers.bl_icon, text="")
# sidebar.separator()
# sidebar.operator(SaveSelectedLayersCache.bl_idname,
# icon=SaveSelectedLayersCache.bl_icon, text="")
# sidebar.operator(RemoveSelectedLayers.bl_idname,
# icon=RemoveSelectedLayers.bl_icon, text="")
# sidebar.separator()
# sidebar.operator(RenameLayer.bl_idname,
# icon=RenameLayer.bl_icon, text="")
# sidebar.operator(ResampleLayer.bl_idname,
# icon=ResampleLayer.bl_icon, text="")
# sidebar.operator(RetimeLayer.bl_idname,
# icon=RetimeLayer.bl_icon, text="")
# sidebar.separator()
# sidebar.operator(SignScalar.bl_idname,
# icon=SignScalar.bl_icon, text="")
# sidebar.operator(FillByThreshold.bl_idname,
# icon=FillByThreshold.bl_icon, text="")
# sidebar.operator(FillByRange.bl_idname,
# icon=FillByRange.bl_icon, text="")
# sidebar.operator(FillByLabel.bl_idname,
# icon=FillByLabel.bl_icon, text="")
# sidebar.separator()
# layout.separator()
def VIEW3D_TOPBAR(self, context):
layout = self.layout
layout.operator(SliceViewer.bl_idname)
node_menu = NodeMenu(
menu_items=MENU_ITEMS
)
layout.menu(ObjectContextMenu.bl_idname)
def add():
node_menu.register()
# bpy.types.VIEW3D_HT_header.append(VIEW3D_TOPBAR)
bpy.types.TOPBAR_MT_editor_menus.append(TOPBAR)
bpy.types.NODE_PT_node_tree_properties.prepend(NODE_PROP)
bpy.types.NODE_MT_editor_menus.append(NODE_HEAD)
bpy.types.NODE_MT_context_menu.append(NODE_CONTEXT)
bpy.types.VIEW3D_MT_object_context_menu.append(OBJECT_CONTEXT)
# bpy.types.NODE_MT_context_menu.append(NODE_CONTEXT)
def remove():
# bpy.types.VIEW3D_HT_header.remove(VIEW3D_TOPBAR)
bpy.types.TOPBAR_MT_editor_menus.remove(TOPBAR)
bpy.types.NODE_PT_node_tree_properties.remove(NODE_PROP)
bpy.types.NODE_MT_editor_menus.remove(NODE_HEAD)
bpy.types.NODE_MT_context_menu.remove(NODE_CONTEXT)
node_menu.unregister()
bpy.types.VIEW3D_MT_object_context_menu.remove(OBJECT_CONTEXT)
# bpy.types.NODE_MT_context_menu.remove(NODE_CONTEXT)

149
src/bioxelnodes/node.py Normal file
View File

@@ -0,0 +1,149 @@
from pathlib import Path
import bpy
# LookupError replaced with built-in LookupError
from .constants import LATEST_NODE_LIB_PATH
def move_node_to_node(node, target_node, offset=(0, 0)):
node.location.x = target_node.location.x + offset[0]
node.location.y = target_node.location.y + offset[1]
def move_node_between_nodes(node, target_nodes, offset=(0, 0)):
xs = []
ys = []
for target_node in target_nodes:
xs.append(target_node.location.x)
ys.append(target_node.location.y)
node.location.x = sum(xs) / len(xs) + offset[0]
node.location.y = sum(ys) / len(ys) + offset[1]
def get_node_type(node):
node_type = type(node).__name__
if node_type == "GeometryNodeGroup":
node_type = node.node_tree.name
return node_type
def get_nodes_by_type(node_group, type_name: str):
return [node for node in node_group.nodes if get_node_type(node) == type_name]
def get_node_tree(node_type: str, use_link=True):
try:
return bpy.data.node_groups[node_type]
except KeyError:
lib_path_str = str(LATEST_NODE_LIB_PATH)
with bpy.data.libraries.load(lib_path_str, link=use_link, relative=True) as (
data_from,
data_to,
):
data_to.node_groups = [
n for n in data_from.node_groups if n == node_type]
node_tree = data_to.node_groups[0]
if node_tree is None:
raise LookupError("No custom node found")
return node_tree
def assign_node_tree(node, node_tree):
node.node_tree = node_tree
node.width = 200.0
node.name = node_tree.name
return node
def add_node_to_graph(node_name: str, node_group, node_label=None, use_link=True):
node_type = f"O {node_name}"
node_label = node_label or node_name
# Deselect all nodes first
for node in node_group.nodes:
if node.select:
node.select = False
node_tree = get_node_tree(node_type, use_link)
node = node_group.nodes.new("GeometryNodeGroup")
assign_node_tree(node, node_tree)
node.label = node_label
node.show_options = False
bpy.ops.node.view_selected()
return node
def get_output_node(node_group):
try:
output_node = get_nodes_by_type(node_group, "NodeGroupOutput")[0]
except:
output_node = node_group.nodes.new("NodeGroupOutput")
return output_node
def add_bioxel_node(name: str):
# bpy.ops.node.add_group_asset(
# asset_library_type="CUSTOM",
# asset_library_identifier = "O Bioxel",
# relative_asset_identifier = "NodeTreO Crop Layer"
# )
# bpy.ops.node.add_group_asset(
# asset_library_type="CUSTOM",
# asset_library_identifier="O Bioxel",
# relative_asset_identifier="Nodes.blend\\NodeTree\\O Render Structure"
# )
try:
node_tree = bpy.data.node_groups[name]
except KeyError:
bpy.ops.object.modifier_add_node_group(
asset_library_type="CUSTOM",
asset_library_identifier="O Bioxel",
relative_asset_identifier=f"Nodes.blend\\NodeTree\\{name}",
)
bpy.ops.object.modifier_remove(modifier=name)
try:
node_tree = bpy.data.node_groups[name]
except Exception as e:
raise e
bpy.ops.node.add_node(
"INVOKE_DEFAULT", type="GeometryNodeGroup", use_transform=True
)
node = bpy.context.active_node
node.node_tree = node_tree
node.show_options = False
return bpy.context.active_node
def get_layer_nodes(node_group):
"""Return all O Layer nodes in the given node_tree."""
return [
n
for n in node_group.nodes
if n.bl_idname == "GeometryNodeGroup"
and getattr(n, "node_tree", None)
and n.node_tree.name.startswith("O Layer")
]
def get_main_node_group(context):
space = getattr(context, "space_data", None) or getattr(
context, "space", None)
if not space:
return None
node_tree = getattr(space, "node_tree", None)
if not node_tree:
return None
# accept GeometryNodeTree by bl_idname or class name for compatibility
if getattr(node_tree, "bl_idname", "") == "GeometryNodeTree" or node_tree.__class__.__name__ == "GeometryNodeTree":
return node_tree
return None

View File

@@ -1,87 +0,0 @@
import bpy
from .operators.node import AddNode
class NodeMenu():
def __init__(
self,
menu_items,
) -> None:
self.menu_items = menu_items
self.class_prefix = f"BIOXELNODES_MT"
root_label = "Bioxel Nodes"
menu_classes = []
self._create_menu_class(
items=menu_items,
label=root_label,
menu_classes=menu_classes
)
self.menu_classes = menu_classes
idname = f"{self.class_prefix}_{root_label.replace(' ', '').upper()}"
icon = "FILE_VOLUME"
def drew_menu(self, context):
if (bpy.context.area.spaces[0].tree_type == 'GeometryNodeTree'):
layout = self.layout
layout.separator()
layout.menu(idname,
icon=icon)
self.drew_menu = drew_menu
def _create_menu_class(self, menu_classes, items, label='CustomNodes', icon=0, idname_namespace=None):
idname_namespace = idname_namespace or self.class_prefix
idname = f"{idname_namespace}_{label.replace(' ', '').upper()}"
# create submenu class if item is menu.
for item in items:
item_items = item.get('items') if item != 'separator' else None
if item_items:
menu_class = self._create_menu_class(
menu_classes=menu_classes,
items=item_items,
label=item.get('label') or 'CustomNodes',
icon=item.get('icon') or 0,
idname_namespace=idname
)
item['menu_class'] = menu_class
# create menu class
class Menu(bpy.types.Menu):
bl_idname = idname
bl_label = label
def draw(self, context):
layout = self.layout
for item in items:
if item == "separator":
layout.separator()
elif item.get('menu_class'):
layout.menu(
item.get('menu_class').bl_idname,
icon=item.get('icon') or 'NONE'
)
else:
op = layout.operator(
AddNode.bl_idname,
text=item.get('label'),
icon=item.get('icon') or 'NONE'
)
op.node_name = item['name']
op.node_label = item.get('label') or ""
op.node_description = item.get(
'node_description') or ""
menu_classes.append(Menu)
return Menu
def register(self):
for cls in self.menu_classes:
bpy.utils.register_class(cls)
bpy.types.NODE_MT_add.append(self.drew_menu)
def unregister(self):
bpy.types.NODE_MT_add.remove(self.drew_menu)
for cls in reversed(self.menu_classes):
bpy.utils.unregister_class(cls)

View File

@@ -1,649 +0,0 @@
import bpy
import bmesh
from ..constants import COMPONENT_OUTPUT_NODES
from ..utils import get_cache_dir, get_use_link, select_object
from ..bioxel.io import load_container, save_container
from ..bioxelutils.container import container_to_obj, obj_to_container
from ..bioxelutils.node import add_node_to_graph
from ..bioxelutils.common import (get_container_layer_objs, get_container_obj, get_node_type,
get_nodes_by_type, get_output_node, is_missing_layer,
move_node_between_nodes,
move_node_to_node,
get_all_layer_objs)
from .layer import RemoveLayers, SaveLayersCache
class SaveContainer(bpy.types.Operator):
bl_idname = "bioxelnodes.save_container"
bl_label = "Save Container (.bioxel) (BETA)"
bl_description = "Clean all caches saved in temp"
filepath: bpy.props.StringProperty(
subtype="FILE_PATH"
) # type: ignore
filename_ext = ".bioxel"
def execute(self, context):
container_obj = get_container_obj(context.object)
if container_obj is None:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
container = obj_to_container(container_obj)
save_path = f"{self.filepath.split('.')[0]}.bioxel"
save_container(container, save_path, overwrite=True)
self.report({"INFO"}, f"Successfully save to {save_path}")
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
class LoadContainer(bpy.types.Operator):
bl_idname = "bioxelnodes.load_container"
bl_label = "Load Container (.bioxel) (BETA)"
bl_description = "Clean all caches saved in temp"
filepath: bpy.props.StringProperty(
subtype="FILE_PATH"
) # type: ignore
filename_ext = ".bioxel"
def execute(self, context):
load_path = self.filepath
container = load_container(self.filepath)
is_first_import = len(get_all_layer_objs()) == 0
container_obj = container_to_obj(container,
scene_scale=0.01,
cache_dir=get_cache_dir())
select_object(container_obj)
if is_first_import:
bpy.ops.bioxelnodes.render_setting_preset('EXEC_DEFAULT',
preset="preview_c")
self.report({"INFO"}, f"Successfully load {load_path}")
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
class ExtractNodeObject():
bl_options = {'UNDO'}
def execute(self, context):
container_obj = get_container_obj(context.object)
if container_obj is None:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
# nodes = [node for node in context.selected_nodes
# if get_node_type(node).removeprefix("BioxelNodes_") in COMPONENT_OUTPUT_NODES]
if len(context.selected_nodes) == 0:
self.report({"WARNING"}, "No node selected.")
return {'FINISHED'}
selected_node = context.selected_nodes[0]
container_node_group = container_obj.modifiers[0].node_group
container_output_node = get_output_node(container_node_group)
if len(container_output_node.inputs[0].links) == 0:
pre_socket = None
else:
pre_socket = container_output_node.inputs[0].links[0].from_socket
container_node_group.links.new(selected_node.outputs[0],
container_output_node.inputs[0])
bpy.ops.mesh.primitive_cube_add(
size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
obj = bpy.context.active_object
obj.name = f"{container_obj.name}_{self.object_type}"
bpy.ops.node.new_geometry_nodes_modifier()
modifier = obj.modifiers[0]
node_group = modifier.node_group
output_node = get_nodes_by_type(node_group, 'NodeGroupOutput')[0]
fetch_mesh_node = add_node_to_graph(f"Fetch{self.object_type}",
node_group,
node_label=f"Fetch {self.object_type}",
use_link=get_use_link())
fetch_mesh_node.inputs[0].default_value = container_obj
node_group.links.new(fetch_mesh_node.outputs[0], output_node.inputs[0])
for modifier in obj.modifiers:
bpy.ops.object.modifier_apply(modifier=modifier.name)
obj.data.materials.clear()
if pre_socket:
container_node_group.links.new(pre_socket,
container_output_node.inputs[0])
select_object(obj)
self.report({"INFO"}, f"Successfully picked")
return {'FINISHED'}
class ExtractNodeMesh(bpy.types.Operator, ExtractNodeObject):
bl_idname = "bioxelnodes.extract_node_mesh"
bl_label = "Extract Mesh"
bl_description = "Extract Mesh"
bl_icon = "OUTLINER_OB_MESH"
object_type = "Mesh"
class ExtractNodeShapeWire(bpy.types.Operator, ExtractNodeObject):
bl_idname = "bioxelnodes.extract_node_shape_wire"
bl_label = "Extract Shape Wire"
bl_description = "Extract Shape Wire"
bl_icon = "FILE_VOLUME"
object_type = "ShapeWire"
class ExtractNodeBboxWire(bpy.types.Operator, ExtractNodeObject):
bl_idname = "bioxelnodes.extract_node_bbox_wire"
bl_label = "Extract Bbox Wire"
bl_description = "Extract Bbox Wire"
bl_icon = "MESH_CUBE"
object_type = "BboxWire"
class ExtractObject():
bl_options = {'UNDO'}
def execute(self, context):
container_obj = get_container_obj(context.object)
if container_obj is None:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
bpy.ops.mesh.primitive_cube_add(
size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
obj = bpy.context.active_object
obj.name = f"{container_obj.name}_{self.object_type}"
bpy.ops.node.new_geometry_nodes_modifier()
modifier = obj.modifiers[0]
node_group = modifier.node_group
output_node = get_output_node(node_group)
fetch_mesh_node = add_node_to_graph(f"Fetch{self.object_type}",
node_group,
node_label=f"Fetch {self.object_type}",
use_link=get_use_link())
fetch_mesh_node.inputs[0].default_value = container_obj
if self.object_type == "Mesh":
node_group.links.new(
fetch_mesh_node.outputs[0], output_node.inputs[0])
else:
curve_to_mesh_node = node_group.nodes.new(
"GeometryNodeCurveToMesh")
node_group.links.new(
fetch_mesh_node.outputs[0], curve_to_mesh_node.inputs[0])
node_group.links.new(
curve_to_mesh_node.outputs[0], output_node.inputs[0])
for modifier in obj.modifiers:
bpy.ops.object.modifier_apply(modifier=modifier.name)
obj.data.materials.clear()
select_object(obj)
self.report({"INFO"}, f"Successfully picked")
return {'FINISHED'}
class ExtractMesh(bpy.types.Operator, ExtractObject):
bl_idname = "bioxelnodes.extract_mesh"
bl_label = "Extract Mesh"
bl_description = "Extract Mesh"
bl_icon = "OUTLINER_OB_MESH"
object_type = "Mesh"
class ExtractShapeWire(bpy.types.Operator, ExtractObject):
bl_idname = "bioxelnodes.extract_shape_wire"
bl_label = "Extract Shape Wire"
bl_description = "Extract Shape Wire"
bl_icon = "FILE_VOLUME"
object_type = "ShapeWire"
class ExtractBboxWire(bpy.types.Operator, ExtractObject):
bl_idname = "bioxelnodes.extract_bbox_wire"
bl_label = "Extract Bbox Wire"
bl_description = "Extract Bbox Wire"
bl_icon = "MESH_CUBE"
object_type = "BboxWire"
class AddCutter():
bl_options = {'UNDO'}
def execute(self, context):
container_obj = get_container_obj(context.object)
if container_obj is None:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
# TODO: do not use operator to create obj
if self.cutter_type == "plane":
bpy.ops.mesh.primitive_plane_add(
size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
elif self.cutter_type == "cylinder":
bpy.ops.mesh.primitive_cylinder_add(
radius=1, depth=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
bpy.context.object.rotation_euler[0] = container_obj.rotation_euler[0]
elif self.cutter_type == "cube":
bpy.ops.mesh.primitive_cube_add(
size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
elif self.cutter_type == "sphere":
bpy.ops.mesh.primitive_ico_sphere_add(
radius=1, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
elif self.cutter_type == "pie":
bpy.ops.mesh.primitive_plane_add(
size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
pie_obj = bpy.context.active_object
orig_data = pie_obj.data
# Create mesh
pie_mesh = bpy.data.meshes.new('Pie')
pie_obj.data = pie_mesh
bpy.data.meshes.remove(orig_data)
# # Create object
# pie = bpy.data.objects.new('Pie', pie_mesh)
# # Link object to scene
# bpy.context.scene.collection.objects.link(pie)
# Get a BMesh representation
bm = bmesh.new() # create an empty BMesh
bm.from_mesh(pie_mesh) # fill it in from a Mesh
# Hot to create vertices
v_0 = bm.verts.new((0.0, -1.0, 0.0))
v_1 = bm.verts.new((-1.0, -1.0, 1.0))
v_2 = bm.verts.new((0.0, 1.0, 0.0))
v_3 = bm.verts.new((-1.0, 1.0, 1.0))
v_4 = bm.verts.new((1.0, -1.0, 1.0))
v_5 = bm.verts.new((1.0, 1.0, 1.0))
# Initialize the index values of this sequence.
bm.verts.index_update()
# How to create a face
# it's not necessary to create the edges before, I made it only to show how create
# edges too
bm.faces.new((v_0, v_1, v_3, v_2))
bm.faces.new((v_0, v_2, v_5, v_4))
# Finish up, write the bmesh back to the mesh
bm.to_mesh(pie_mesh)
# bpy.context.view_layer.objects.active = pie
cutter_obj = bpy.context.active_object
# vcos = [container_obj.matrix_world @
# v.co for v in container_obj.data.vertices]
# def find_center(l): return (max(l) + min(l)) / 2
# x, y, z = [[v[i] for v in vcos] for i in range(3)]
# center = [find_center(axis) for axis in [x, y, z]]
# cutter_obj.location = center
name = f"{self.cutter_type.capitalize()}_Cutter"
cutter_obj.name = name
cutter_obj.data.name = name
cutter_obj.visible_camera = False
cutter_obj.visible_diffuse = False
cutter_obj.visible_glossy = False
cutter_obj.visible_transmission = False
cutter_obj.visible_volume_scatter = False
cutter_obj.visible_shadow = False
cutter_obj.hide_render = True
cutter_obj.display_type = 'WIRE'
cutter_obj.lineart.usage = 'EXCLUDE'
select_object(container_obj)
modifier = container_obj.modifiers[0]
node_group = modifier.node_group
cut_nodes = get_nodes_by_type(node_group,
'BioxelNodes_Cut')
if len(cut_nodes) == 0:
cutter_node = add_node_to_graph("ObjectCutter",
node_group,
node_label=f"{self.cutter_type.capitalize()} Cutter",
use_link=get_use_link())
cutter_node.inputs[0].default_value = self.cutter_type.capitalize()
cutter_node.inputs[1].default_value = cutter_obj
cut_node = add_node_to_graph("Cut",
node_group,
use_link=get_use_link())
output_node = get_output_node(node_group)
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:
bpy.ops.bioxelnodes.add_node('EXEC_DEFAULT',
node_name="ObjectCutter",
node_label=name)
node = bpy.context.active_node
node.inputs[0].default_value = self.cutter_type.capitalize()
node.inputs[1].default_value = cutter_obj
return {'FINISHED'}
class AddPlaneCutter(bpy.types.Operator, AddCutter):
bl_idname = "bioxelnodes.add_plane_cutter"
bl_label = "Add a Plane Cutter"
bl_description = "Add a plane cutter to current container"
bl_icon = "MESH_PLANE"
cutter_type = "plane"
class AddCylinderCutter(bpy.types.Operator, AddCutter):
bl_idname = "bioxelnodes.add_cylinder_cutter"
bl_label = "Add a Cylinder Cutter"
bl_description = "Add a cylinder cutter to current container"
bl_icon = "MESH_CYLINDER"
cutter_type = "cylinder"
class AddCubeCutter(bpy.types.Operator, AddCutter):
bl_idname = "bioxelnodes.add_cube_cutter"
bl_label = "Add a Cube Cutter"
bl_description = "Add a cube cutter to current container"
bl_icon = "MESH_CUBE"
cutter_type = "cube"
class AddSphereCutter(bpy.types.Operator, AddCutter):
bl_idname = "bioxelnodes.add_sphere_cutter"
bl_label = "Add a Sphere Cutter"
bl_description = "Add a sphere cutter to current container"
bl_icon = "MESH_UVSPHERE"
cutter_type = "sphere"
class AddPieCutter(bpy.types.Operator, AddCutter):
bl_idname = "bioxelnodes.add_pie_cutter"
bl_label = "Add a Pie Cutter"
bl_description = "Add a pie cutter to current container"
bl_icon = "MESH_CONE"
cutter_type = "pie"
class AddSlicer(bpy.types.Operator):
bl_idname = "bioxelnodes.add_slicer"
bl_label = "Add a Slicer"
bl_description = "Add a slicer to current container"
bl_icon = "TEXTURE"
bl_options = {'UNDO'}
def execute(self, context):
container_obj = get_container_obj(context.object)
if container_obj is None:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
bpy.ops.mesh.primitive_plane_add(size=2,
enter_editmode=False,
align='WORLD',
location=(0, 0, 0),
scale=(1, 1, 1))
slicer_obj = bpy.context.active_object
slicer_obj.name = "Slicer"
slicer_obj.data.name = "Slicer"
slicer_obj.visible_camera = False
slicer_obj.visible_diffuse = False
slicer_obj.visible_glossy = False
slicer_obj.visible_transmission = False
slicer_obj.visible_volume_scatter = False
slicer_obj.visible_shadow = False
slicer_obj.hide_render = True
slicer_obj.display_type = 'WIRE'
slicer_obj.lineart.usage = 'EXCLUDE'
select_object(container_obj)
modifier = container_obj.modifiers[0]
node_group = modifier.node_group
slice_node = add_node_to_graph("Slice",
node_group,
use_link=get_use_link())
slice_node.inputs[1].default_value = slicer_obj
output_node = get_output_node(node_group)
if len(output_node.inputs[0].links) == 0:
node_group.links.new(slice_node.outputs[0],
output_node.inputs[0])
move_node_to_node(slice_node, output_node, (-300, 0))
else:
pre_output_node = output_node.inputs[0].links[0].from_node
node_group.links.new(pre_output_node.outputs[0],
slice_node.inputs[0])
node_group.links.new(slice_node.outputs[0],
output_node.inputs[0])
move_node_between_nodes(slice_node,
[pre_output_node, output_node])
return {'FINISHED'}
class AddLocator(bpy.types.Operator):
bl_idname = "bioxelnodes.add_locator"
bl_label = "Add a Locator"
bl_description = "Add a locator to current container"
bl_icon = "EMPTY_AXIS"
bl_options = {'UNDO'}
def execute(self, context):
container_obj = get_container_obj(context.object)
if container_obj is None:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
bpy.ops.object.empty_add(type='ARROWS',
align='WORLD',
location=(0, 0, 0),
scale=(1, 1, 1))
locator_obj = bpy.context.active_object
locator_obj.name = "Locator"
select_object(container_obj)
modifier = container_obj.modifiers[0]
node_group = modifier.node_group
try:
selected_node = context.selected_nodes[0]
except:
selected_node = None
parent_node = add_node_to_graph("TransformParent",
node_group,
node_label="Transform Parent",
use_link=get_use_link())
parent_node.inputs[1].default_value = locator_obj
if selected_node is None or len(selected_node.outputs) == 0:
output_node = get_output_node(node_group)
if len(output_node.inputs[0].links) == 0:
node_group.links.new(parent_node.outputs[0],
output_node.inputs[0])
move_node_to_node(parent_node, output_node, (-300, 0))
else:
pre_output_node = output_node.inputs[0].links[0].from_node
node_group.links.new(pre_output_node.outputs[0],
parent_node.inputs[0])
node_group.links.new(parent_node.outputs[0],
output_node.inputs[0])
move_node_between_nodes(parent_node,
[pre_output_node, output_node])
else:
try:
to_node = selected_node.outputs[0].links[0].to_node
except:
to_node = None
if to_node is None:
node_group.links.new(selected_node.outputs[0],
parent_node.inputs[0])
move_node_to_node(parent_node, selected_node, (300, 0))
else:
node_group.links.new(selected_node.outputs[0],
parent_node.inputs[0])
node_group.links.new(parent_node.outputs[0],
to_node.inputs[0])
move_node_between_nodes(parent_node,
[selected_node, to_node])
return {'FINISHED'}
class ContainerProps(bpy.types.Operator):
bl_idname = "bioxelnodes.container_props"
bl_label = "Change Container Properties"
bl_description = "Change current ontainer properties"
bl_icon = "FILE_TICK"
bl_options = {'UNDO'}
scene_scale: bpy.props.FloatProperty(name="Scene Scale",
soft_min=0.0001, soft_max=10.0,
min=1e-6, max=1e6,
default=0.01) # type: ignore
step_size: bpy.props.FloatProperty(name="Step Size",
soft_min=0.1, soft_max=100.0,
min=0.1, max=1e2,
default=1) # type: ignore
def execute(self, context):
container_obj = get_container_obj(context.object)
if container_obj is None:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
container_obj.scale[0] = self.scene_scale
container_obj.scale[1] = self.scene_scale
container_obj.scale[2] = self.scene_scale
container_obj["scene_scale"] = self.scene_scale
container_obj["step_size"] = self.step_size
for layer_obj in get_container_layer_objs(container_obj):
layer_obj.data.render.space = 'WORLD'
layer_obj.data.render.step_size = self.scene_scale * self.step_size
return {'FINISHED'}
def invoke(self, context, event):
container_obj = get_container_obj(context.object)
if container_obj is None:
return self.execute(context)
else:
self.scene_scale = container_obj.get("scene_scale") or 0.01
self.step_size = container_obj.get("step_size") or 1
context.window_manager.invoke_props_dialog(self)
return {'RUNNING_MODAL'}
def get_container_layers(context, layer_filter=None):
def _layer_filter(layer_obj, context):
return True
layer_filter = layer_filter or _layer_filter
container_obj = context.object
layer_objs = get_container_layer_objs(container_obj)
return [obj for obj in layer_objs if layer_filter(obj, context)]
class SaveContainerLayersCache(bpy.types.Operator, SaveLayersCache):
bl_idname = "bioxelnodes.save_container_layers_cache"
bl_label = "Save Container Layers' Cache"
bl_description = "Save all current container layers' cache to directory."
bl_icon = "FILE_TICK"
success_msg = "Successfully saved all container layers."
def get_layers(self, context):
def is_not_missing(layer_obj, context):
return not is_missing_layer(layer_obj)
return get_container_layers(context, is_not_missing)
class RemoveContainerMissingLayers(bpy.types.Operator, RemoveLayers):
bl_idname = "bioxelnodes.remove_container_missing_layers"
bl_label = "Remove Container Missing Layers"
bl_description = "Remove all current container missing layers"
bl_icon = "BRUSH_DATA"
success_msg = "Successfully removed all container missing layers."
def get_layers(self, context):
def is_missing(layer_obj, context):
return is_missing_layer(layer_obj)
return get_container_layers(context, is_missing)
def invoke(self, context, event):
context.window_manager.invoke_confirm(self,
event,
message=f"Are you sure to remove all **Missing** layers?")
return {'RUNNING_MODAL'}

File diff suppressed because it is too large Load Diff

View File

@@ -1,619 +1,320 @@
from pathlib import Path
import shutil
import bpy
import numpy as np
from ..node import add_bioxel_node, get_layer_nodes, get_main_node_group
from ..utils import refresh_bioxel_panels
from ..layer import get_layer_caches, set_layer_caches
from ..exceptions import NoContent
from ..bioxel.layer import Layer
from ..bioxelutils.node import add_node_to_graph
from ..bioxelutils.common import (get_container_obj, get_layer_kind, get_layer_label, get_layer_name,
get_layer_prop_value,
get_container_layer_objs,
get_node_type, is_missing_layer, move_node_to_node, set_layer_prop_value)
from ..bioxelutils.layer import layer_to_obj, obj_to_layer
from ..utils import get_cache_dir, copy_to_dir, get_use_link
class RenameLayer(bpy.types.Operator):
"""Rename a cached Bioxel layer"""
bl_idname = "bioxel.rename_layer"
bl_label = "Rename Layer"
bl_options = {"REGISTER", "UNDO"}
def get_label_layer_selection(self, context):
items = [("None", "None", "")]
container_obj = get_container_obj(bpy.context.active_object)
cache_id: bpy.props.StringProperty(options={"HIDDEN"}) # type: ignore
new_name: bpy.props.StringProperty(
name="New name", default="") # type: ignore
for layer_obj in get_container_layer_objs(container_obj):
kind = get_layer_prop_value(layer_obj, "kind")
name = get_layer_prop_value(layer_obj, "name")
if kind == "label":
items.append((layer_obj.name,
name,
""))
return items
class FetchLayer(bpy.types.Operator):
bl_idname = "bioxelnodes.fetch_layer"
bl_label = "Fetch Layer"
bl_description = "Fetch layer from current container"
bl_icon = "NODE"
bl_options = {'UNDO'}
layer_obj_name: bpy.props.StringProperty(
options={"HIDDEN"}) # type: ignore
@classmethod
def description(cls, context, properties):
layer_obj_name = properties.layer_obj_name
layer_obj = bpy.data.objects.get(layer_obj_name)
return "\n".join([f"{prop}: {get_layer_prop_value(layer_obj, prop)}"
for prop in ["kind",
"bioxel_size",
"shape",
"frame_count",
"channel_count",
"min", "max"]])
@property
def layer_obj(self):
return bpy.data.objects.get(self.layer_obj_name)
def invoke(self, context, event):
caches = get_layer_caches()
entry = next((c for c in caches if str(
c.get("id", "")) == self.cache_id), None)
if entry:
self.new_name = str(entry.get("name", ""))
return context.window_manager.invoke_props_dialog(self)
def execute(self, context):
if self.layer_obj == None:
self.report({"WARNING"}, "Get no layer.")
return {'FINISHED'}
if not self.new_name:
self.report({"ERROR"}, "Name cannot be empty")
return {"CANCELLED"}
if is_missing_layer(self.layer_obj):
self.report({"WARNING"}, "Selected layer is lost.")
return {'FINISHED'}
caches = get_layer_caches()
for c in caches:
if str(c.get("id", "")) == self.cache_id:
c["name"] = self.new_name
break
bpy.ops.bioxelnodes.add_node('EXEC_DEFAULT',
node_name="FetchLayer",
node_label="Fetch Layer")
node = bpy.context.active_node
try:
set_layer_caches(caches)
refresh_bioxel_panels(context)
self.report({"INFO"}, "Layer renamed")
return {"FINISHED"}
except Exception as e:
self.report({"ERROR"}, f"Failed to save changes: {e}")
return {"CANCELLED"}
node.inputs[0].default_value = self.layer_obj
node.label = get_layer_label(self.layer_obj)
class DeleteLayer(bpy.types.Operator):
"""Delete a saved Bioxel layer"""
bl_idname = "bioxel.delete_layer"
bl_label = "Delete Layer"
bl_options = {"REGISTER", "UNDO"}
cache_id: bpy.props.StringProperty() # type: ignore
def invoke(self, context, event):
return context.window_manager.invoke_confirm(self, event)
def execute(self, context):
if not self.cache_id:
self.report({"ERROR"}, "Missing cache id")
return {"CANCELLED"}
caches = get_layer_caches()
new_list = [c for c in caches if str(c.get("id", "")) != self.cache_id]
removed = next(
(c for c in caches if str(c.get("id", "")) == self.cache_id), None
)
try:
set_layer_caches(new_list)
refresh_bioxel_panels(context)
except Exception as e:
self.report({"ERROR"}, f"Failed to update layer list: {e}")
return {"CANCELLED"}
self.report({"INFO"}, "Layer removed")
return {"FINISHED"}
def get_selected_layers(context, layer_filter=None):
def _layer_filter(layer_obj, context):
return True
# 添加图层到节点图的操作器
class AddLayerNode(bpy.types.Operator):
"""将选中的图层添加到几何节点图"""
layer_filter = layer_filter or _layer_filter
select_objs = []
# node_group = context.space_data.edit_tree
for node in context.selected_nodes:
if get_node_type(node) == "BioxelNodes_FetchLayer":
layer_obj = node.inputs[0].default_value
if layer_obj is not None:
if layer_filter(layer_obj, context):
select_objs.append(layer_obj)
bl_idname = "bioxel.add_layer_node"
bl_label = "添加图层节点"
bl_options = {"REGISTER", "UNDO"}
return list(set(select_objs))
def get_selected_layer(context, layer_filter=None):
layer_objs = get_selected_layers(context, layer_filter)
return layer_objs[0] if len(layer_objs) > 0 else None
class OutputLayerOperator():
new_layer_name: bpy.props.StringProperty(name="New Name",
options={"SKIP_SAVE"}) # type: ignore
def operate(self, orig_layer: Layer, context):
"""do the operation"""
return orig_layer
def add_layer_node(self, context, layer):
orig_node = context.selected_nodes[0]
layer_obj = layer_to_obj(layer,
container_obj=context.object,
cache_dir=get_cache_dir())
node_group = context.space_data.edit_tree
fetch_node = add_node_to_graph("FetchLayer",
node_group,
use_link=get_use_link())
fetch_node.label = get_layer_prop_value(layer_obj, "name")
fetch_node.inputs[0].default_value = layer_obj
move_node_to_node(fetch_node, orig_node, (0, -100))
cache_id: bpy.props.StringProperty() # type: ignore # 图层ID
def execute(self, context):
layer_obj = get_selected_layer(context)
if layer_obj == None:
self.report({"WARNING"}, "Get no layer.")
return {'FINISHED'}
# 获取图层数据
caches = get_layer_caches()
if is_missing_layer(layer_obj):
self.report({"WARNING"}, "Selected layer is lost.")
return {'FINISHED'}
# 查找目标图层
entry = next(
(c for c in caches if str(c.get("id", "")) == str(self.cache_id)), None
)
orig_layer = obj_to_layer(layer_obj)
if not entry:
self.report({"ERROR"}, "Layer not found")
return {"CANCELLED"}
# 创建并设置节点属性
try:
new_layer = self.operate(orig_layer, context)
except NoContent as e:
self.report({"WARNING"}, e.message)
return {'FINISHED'}
layer_node = add_bioxel_node("O Layer")
except:
self.report({"ERROR"}, "Fail to add layer node")
return {"CANCELLED"}
self.add_layer_node(context, new_layer)
return {'FINISHED'}
layer_node.node_tree.make_local()
layer_node.inputs["Path"].default_value = entry["path"]
layer_node.inputs["Name"].default_value = entry["name"]
layer_node.inputs["Shape"].default_value = entry["shape"]
layer_node.inputs["Min"].default_value = entry["min"]
layer_node.inputs["Max"].default_value = entry["max"]
layer_node.inputs["ID"].default_value = entry["id"]
# 将特定属性隐藏到hidden面板
hidden_sockets = ["Path", "Shape", "Min", "Max", "ID", "Animation"]
class LayerOperator():
def operate(self, layer_obj: bpy.types.Object, context):
"""do the operation"""
...
def execute(self, context):
layer_obj = get_selected_layer(context)
if layer_obj == None:
self.report({"WARNING"}, "Get no layer.")
return {'FINISHED'}
if is_missing_layer(layer_obj):
self.report({"WARNING"}, "Selected layer is lost.")
return {'FINISHED'}
self.operate(layer_obj, context)
return {'FINISHED'}
class RetimeLayer(bpy.types.Operator, LayerOperator):
bl_idname = "bioxelnodes.retime_layer"
bl_label = "Retime Sequence"
bl_description = "Retime layer time sequence"
bl_icon = "TIME"
frame_duration: bpy.props.IntProperty(name="Frames") # type: ignore
frame_start: bpy.props.IntProperty(name="Start") # type: ignore
frame_offset: bpy.props.IntProperty(name="Offset") # type: ignore
sequence_mode: bpy.props.EnumProperty(name="Mode",
default="REPEAT",
items=[("CLIP", "Clip", ""),
("EXTEND", "Extend", ""),
("REPEAT", "Repeat", ""),
("PING_PONG", "Ping-Pong", "")]) # type: ignore
def operate(self, layer_obj, context):
layer_obj.data.frame_duration = self.frame_duration
layer_obj.data.frame_start = self.frame_start
layer_obj.data.frame_offset = self.frame_offset
layer_obj.data.sequence_mode = self.sequence_mode
def invoke(self, context, event):
layer_obj = get_selected_layer(context)
if layer_obj:
self.frame_duration = layer_obj.data.frame_duration
self.frame_start = layer_obj.data.frame_start
self.frame_offset = layer_obj.data.frame_offset
self.sequence_mode = layer_obj.data.sequence_mode
name = get_layer_label(layer_obj)
context.window_manager.invoke_props_dialog(self,
title=f"Retime {name}")
return {'RUNNING_MODAL'}
if entry["frame_count"] > 1:
layer_node.inputs["Frame Count"].default_value = entry["frame_count"]
layer_node.inputs["Animation"].default_value = True
else:
return self.execute(context)
hidden_sockets = hidden_sockets + \
["Frame Count", "Frame Offset", "Cycle"]
# 将特定属性隐藏到hidden面板
for socket_name in hidden_sockets:
if socket_name in layer_node.inputs:
layer_node.inputs[socket_name].hide = True
for socket_name in layer_node.outputs.keys():
if socket_name != entry["kind"].capitalize():
layer_node.outputs[socket_name].hide = True
self.report({"INFO"}, f"Successfully created node: {entry['name']}")
return {"FINISHED"}
class RelocateLayer(bpy.types.Operator):
bl_idname = "bioxelnodes.relocate_layer"
bl_label = "Relocate Layer Cache"
bl_description = "Relocate layer cache"
bl_icon = "FILE"
class RelocatePath(bpy.types.Operator):
"""Locate and update the missing layer folder, and update all related nodes"""
filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore
bl_idname = "bioxel.find_layer_folder"
bl_label = "Relocate"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
layer_obj = get_selected_layer(context)
if layer_obj == None:
self.report({"WARNING"}, "Get no layer.")
return {'FINISHED'}
cache_id: bpy.props.StringProperty(options={"HIDDEN"}) # type: ignore
self.operate(layer_obj, context)
return {'FINISHED'}
def operate(self, layer_obj, context):
layer_obj.data.filepath = self.filepath
def invoke(self, context, event):
layer_obj = get_selected_layer(context)
if layer_obj:
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
else:
return self.execute(context)
class RenameLayer(bpy.types.Operator, LayerOperator):
bl_idname = "bioxelnodes.rename_layer"
bl_label = "Rename Layer"
bl_description = "Rename layer"
bl_icon = "FILE_FONT"
new_name: bpy.props.StringProperty(name="New Name",
options={"SKIP_SAVE"}) # type: ignore
def operate(self, layer_obj, context):
name = f"{layer_obj.parent.name}_{self.new_name}"
layer_obj.name = name
layer_obj.data.name = name
set_layer_prop_value(layer_obj, "name", self.new_name)
node_group = context.space_data.edit_tree
for node in node_group.nodes:
if get_node_type(node) == "BioxelNodes_FetchLayer":
if node.inputs[0].default_value == layer_obj:
node.label = self.new_name
def invoke(self, context, event):
layer_obj = get_selected_layer(context)
if layer_obj:
self.new_name = get_layer_name(layer_obj)
name = get_layer_label(layer_obj)
context.window_manager.invoke_props_dialog(self,
title=f"Rename {name}")
return {'RUNNING_MODAL'}
else:
return self.execute(context)
class ResampleLayer(bpy.types.Operator, OutputLayerOperator):
bl_idname = "bioxelnodes.resample_layer"
bl_label = "Resample Value"
bl_description = "Resample value"
bl_icon = "ALIASED"
smooth: bpy.props.IntProperty(name="Smooth Size",
default=0,
soft_min=0, soft_max=5,
options={"SKIP_SAVE"}) # type: ignore
bioxel_size: bpy.props.FloatProperty(
name="Bioxel Size",
soft_min=0.1, soft_max=10.0,
default=1,
) # type: ignore
@staticmethod
def get_new_shape(orig_shape, orig_size, new_size):
return (int(orig_shape[0]*orig_size/new_size),
int(orig_shape[1]*orig_size/new_size),
int(orig_shape[2]*orig_size/new_size))
def operate(self, orig_layer: Layer, context):
new_layer = orig_layer.copy()
new_shape = self.get_new_shape(new_layer.shape,
new_layer.bioxel_size[0],
self.bioxel_size)
new_layer.resize(new_shape, self.smooth)
new_layer.name = self.new_layer_name \
or f"{orig_layer.name}_R-{self.bioxel_size:.2f}"
return new_layer
def draw(self, context):
layer_obj = get_selected_layer(context)
orig_shape = get_layer_prop_value(layer_obj, "shape")
orig_size = get_layer_prop_value(layer_obj, "bioxel_size")
new_shape = self.get_new_shape(orig_shape,
orig_size,
self.bioxel_size)
orig_shape = tuple(orig_shape)
bioxel_count = new_shape[0] * new_shape[1] * new_shape[2]
layer_shape_text = f"Shape from {str(orig_shape)} to {str(new_shape)}"
if bioxel_count > 100000000:
layer_shape_text += "**TOO LARGE!**"
layout = self.layout
layout.prop(self, "new_layer_name")
layout.prop(self, "bioxel_size")
layout.prop(self, "smooth")
layout.label(text=layer_shape_text)
def invoke(self, context, event):
layer_obj = get_selected_layer(context)
if layer_obj:
name = get_layer_label(layer_obj)
self.bioxel_size = get_layer_prop_value(layer_obj,
"bioxel_size")
context.window_manager.invoke_props_dialog(self,
title=f"Resample {name}")
return {'RUNNING_MODAL'}
else:
return self.execute(context)
class SignScalar(bpy.types.Operator, OutputLayerOperator):
bl_idname = "bioxelnodes.sign_scalar"
bl_label = "Sign Value"
bl_description = "Sign value"
bl_icon = "REMOVE"
def operate(self, orig_layer: Layer, context):
new_layer = orig_layer.copy()
new_layer.data = -orig_layer.data
new_layer.name = self.new_layer_name \
or f"{orig_layer.name}_Sign"
return new_layer
def invoke(self, context, event):
layer_obj = get_selected_layer(context)
if layer_obj:
name = get_layer_label(layer_obj)
context.window_manager.invoke_props_dialog(self,
title=f"Sign {name}")
return {'RUNNING_MODAL'}
else:
return self.execute(context)
class FillOperator(OutputLayerOperator):
fill_value: bpy.props.FloatProperty(
name="Fill Value",
soft_min=0, soft_max=1024.0,
default=0,
) # type: ignore
invert: bpy.props.BoolProperty(
name="Invert Aera",
use_relative: bpy.props.BoolProperty(
name="Relative Path",
description="Store the path relative to the current Blender file",
default=True,
) # type: ignore
def invoke(self, context, event):
layer_obj = get_selected_layer(context)
if layer_obj:
name = get_layer_label(layer_obj)
context.window_manager.invoke_props_dialog(self,
title=f"Fill {name}")
return {'RUNNING_MODAL'}
else:
return self.execute(context)
class FillByThreshold(bpy.types.Operator, FillOperator):
bl_idname = "bioxelnodes.fill_by_threshold"
bl_label = "Fill Value by Threshold"
bl_description = "Fill value by threshold"
bl_icon = "EMPTY_SINGLE_ARROW"
threshold: bpy.props.FloatProperty(
name="Threshold",
soft_min=0, soft_max=1024,
default=128,
directory: bpy.props.StringProperty(
name="Layer Folder",
description="Select the correct folder for this layer",
subtype="DIR_PATH",
) # type: ignore
def operate(self, orig_layer: Layer, context):
data = np.amax(orig_layer.data, -1)
mask = data <= self.threshold \
if self.invert else data > self.threshold
new_layer = orig_layer.copy()
new_layer.fill(self.fill_value, mask, 0)
new_layer.name = self.new_layer_name \
or f"{orig_layer.name}_F-{self.threshold:.2f}"
return new_layer
class FillByRange(bpy.types.Operator, FillOperator):
bl_idname = "bioxelnodes.fill_by_range"
bl_label = "Fill Value by Range"
bl_description = "Fill value by range"
bl_icon = "IPO_CONSTANT"
from_min: bpy.props.FloatProperty(
name="From Min",
soft_min=0, soft_max=1024,
default=128,
) # type: ignore
from_max: bpy.props.FloatProperty(
name="From Max",
soft_min=0, soft_max=1024,
default=256,
) # type: ignore
def operate(self, orig_layer: Layer, context):
data = np.amax(orig_layer.data, -1)
mask = (data <= self.from_min) | (data >= self.from_max) if self.invert else \
(data > self.from_min) & (data < self.from_max)
new_layer = orig_layer.copy()
new_layer.fill(self.fill_value, mask, 0)
new_layer.name = self.new_layer_name \
or f"{orig_layer.name}_F-{self.from_min:.2f}-{self.from_max:.2f}"
return new_layer
class FillByLabel(bpy.types.Operator, FillOperator):
bl_idname = "bioxelnodes.fill_by_label"
bl_label = "Fill Value by Label"
bl_description = "Fill value by label"
bl_icon = "MESH_CAPSULE"
smooth: bpy.props.IntProperty(name="Smooth Size",
default=0,
soft_min=0, soft_max=5,
options={"SKIP_SAVE"}) # type: ignore
label_obj_name: bpy.props.EnumProperty(name="Label Layer",
items=get_label_layer_selection) # type: ignore
def operate(self, orig_layer: Layer, context):
label_obj = bpy.data.objects.get(self.label_obj_name)
if label_obj is None:
raise NoContent("Cannot find any label layer.")
label_layer = obj_to_layer(label_obj)
label_layer.resize(orig_layer.shape, self.smooth)
mask = np.amax(label_layer.data, -1)
if self.invert:
mask = 1 - mask
new_layer = orig_layer.copy()
new_layer.fill(self.fill_value, mask, 0)
new_layer.name = self.new_layer_name \
or f"{orig_layer.name}_F-{label_layer.name}"
return new_layer
class CombineLabels(bpy.types.Operator, OutputLayerOperator):
bl_idname = "bioxelnodes.combine_labels"
bl_label = "Combine Labels"
bl_description = "Combine all selected labels"
bl_icon = "MOD_BUILD"
def execute(self, context):
def layer_filter(layer_obj, context):
return get_layer_kind(layer_obj) == "label"
label_objs = get_selected_layers(context, layer_filter)
if len(label_objs) < 2:
self.report({"WARNING"}, "Not enough layers.")
return {'FINISHED'}
base_obj = label_objs[0]
label_objs = label_objs[1:]
base_layer = obj_to_layer(base_obj)
new_layer = base_layer.copy()
label_names = [base_layer.name]
for label_obj in label_objs:
label_layer = obj_to_layer(label_obj)
label_layer.resize(base_layer.shape)
new_layer.data = np.maximum(
new_layer.data, label_layer.data)
label_names.append(label_layer.name)
new_layer.name = self.new_layer_name \
or f"C-{'-'.join(label_names)}"
self.add_layer_node(context, new_layer)
return {'FINISHED'}
class BatchLayerOperator():
success_msg = "Successfully done selected layers."
fail_msg = "fails."
def execute(self, context):
layer_objs = self.get_layers(context)
fails = []
for layer_obj in layer_objs:
try:
self.operate(layer_obj, context)
except:
fails.append(layer_obj)
if len(fails) == 0:
self.report({"INFO"}, self.success_msg)
else:
self.report(
{"WARNING"}, f"{','.join([layer_obj.name for layer_obj in fails])} {self.fail_msg}")
return {'FINISHED'}
class SaveLayersCache(BatchLayerOperator):
fail_msg = "fail to save."
cache_dir: bpy.props.StringProperty(
name="Cache Directory",
subtype='DIR_PATH',
default="//"
) # type: ignore
def operate(self, layer_obj, context):
output_dir = bpy.path.abspath(self.cache_dir)
source_dir = bpy.path.abspath(layer_obj.data.filepath)
source_path: Path = Path(source_dir).resolve()
is_sequence = layer_obj.data.is_sequence
name = layer_obj.name if is_sequence else f"{layer_obj.name}.vdb"
output_path: Path = Path(output_dir, name, source_path.name).resolve() \
if is_sequence else Path(output_dir, name).resolve()
if output_path != source_path:
copy_to_dir(source_path.parent if is_sequence else source_path,
output_path.parent.parent if is_sequence else output_path.parent,
new_name=name)
blend_path = Path(bpy.path.abspath("//")).resolve()
layer_obj.data.filepath = bpy.path.relpath(str(output_path),
start=str(blend_path))
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.invoke_props_dialog(self)
return {'RUNNING_MODAL'}
context.window_manager.fileselect_add(self)
return {"RUNNING_MODAL"}
def execute(self, context):
if not self.cache_id:
self.report({"ERROR"}, "Missing cache id")
return {"CANCELLED"}
if not self.directory:
self.report({"ERROR"}, "Please select a folder")
return {"CANCELLED"}
class RemoveLayers(BatchLayerOperator):
fail_msg = "fail to remove."
caches = get_layer_caches()
entry = next((c for c in caches if str(
c.get("id", "")) == self.cache_id), None)
if not entry:
self.report({"ERROR"}, "Layer not found")
return {"CANCELLED"}
def operate(self, layer_obj, context):
old_path = entry["path"]
new_path = (
bpy.path.relpath(self.directory)
if self.use_relative and bpy.data.filepath
else bpy.path.abspath(self.directory)
)
entry["path"] = new_path
set_layer_caches(caches)
refresh_bioxel_panels(context)
# 更新所有 node group 中 O Layer 节点的 Path
for node_group in bpy.data.node_groups:
for node in node_group.nodes:
if get_node_type(node) == "BioxelNodes_FetchLayer":
if node.inputs[0].default_value == layer_obj:
node_group.nodes.remove(node)
for node in get_layer_nodes(node_group):
node_path = getattr(node.inputs.get("Path"), "default_value")
if node_path and node_path == old_path:
node.inputs["Path"].default_value = new_path
cache_filepath = Path(bpy.path.abspath(
layer_obj.data.filepath)).resolve()
self.report({"INFO"}, f"Cache path updated: {new_path}")
return {"FINISHED"}
if cache_filepath.is_file():
if layer_obj.data.is_sequence:
for f in cache_filepath.parent.iterdir():
f.unlink(missing_ok=True)
else:
cache_filepath.unlink(missing_ok=True)
# also remove layer object
bpy.data.volumes.remove(layer_obj.data)
class SaveLayer(bpy.types.Operator):
"""Copy the entire layer folder (named by id) to a target folder and update its path in the cache"""
return {'FINISHED'}
bl_idname = "bioxel.cache_layer_to_folder"
bl_label = "Save Layer"
bl_options = {"REGISTER", "UNDO"}
cache_id: bpy.props.StringProperty(options={"HIDDEN"}) # type: ignore
use_relative: bpy.props.BoolProperty(
name="Relative Path",
description="Store the path relative to the current Blender file",
default=True,
) # type: ignore
directory: bpy.props.StringProperty(
name="Target Folder",
description="Select a folder to cache the layer files",
subtype="DIR_PATH",
) # type: ignore
def invoke(self, context, event):
context.window_manager.invoke_confirm(self,
event,
message=f"Are you sure to remove them?")
return {'RUNNING_MODAL'}
# Default to Blender file directory if possible
blend_dir = Path(
bpy.data.filepath).parent if bpy.data.filepath else Path.cwd()
context.window_manager.fileselect_add(self)
if not self.directory:
self.directory = str(blend_dir)
return {"RUNNING_MODAL"}
def execute(self, context):
if not self.cache_id:
self.report({"ERROR"}, "Missing cache id")
return {"CANCELLED"}
if not self.directory:
self.report({"ERROR"}, "Please select a target folder")
return {"CANCELLED"}
caches = get_layer_caches()
entry = next((c for c in caches if str(
c.get("id")) == self.cache_id), None)
cache_id = entry.get("id")
if not entry:
self.report({"ERROR"}, "Layer not found")
return {"CANCELLED"}
old_path = entry["path"]
src_path = bpy.path.abspath(old_path)
if not src_path or not Path(src_path).is_dir():
self.report({"ERROR"}, "Source folder not found")
return {"CANCELLED"}
src_dir = Path(src_path).resolve()
dst_root = Path(self.directory)
# copy as a subfolder named by id
dst_dir = (dst_root / src_dir.name).resolve()
try:
if src_dir != dst_dir:
if dst_dir.exists():
shutil.rmtree(dst_dir)
shutil.copytree(src_dir, dst_dir)
# Save as relative path if requested and possible
new_path = (
bpy.path.relpath(str(dst_dir))
if self.use_relative and bpy.data.filepath
else bpy.path.abspath(str(dst_dir))
)
entry["path"] = new_path
set_layer_caches(caches)
refresh_bioxel_panels(context)
# 遍历所有 node group
for node_group in bpy.data.node_groups:
for node in get_layer_nodes(node_group):
if cache_id == getattr(node.inputs.get("ID"), "default_value"):
node.inputs["Path"].default_value = new_path
self.report({"INFO"}, f"Layer cached to {dst_dir}")
return {"FINISHED"}
except Exception as e:
self.report({"ERROR"}, f"Failed to cache layer: {e}")
return {"CANCELLED"}
class SaveSelectedLayersCache(bpy.types.Operator, SaveLayersCache):
bl_idname = "bioxelnodes.save_selected_layers_cache"
bl_label = "Save Selected Layers Cache"
bl_description = "Save selected layers' Cache"
bl_icon = "FILE_TICK"
class SelectAndFocusNode(bpy.types.Operator):
"""Select and focus the specified node in the current node tree"""
success_msg = "Successfully saved all selected layers."
bl_idname = "bioxel.select_and_focus_node"
bl_label = "Select and Focus Node"
node_name: bpy.props.StringProperty() # type: ignore
def get_layers(self, context):
def is_not_missing(layer_obj, context):
return not is_missing_layer(layer_obj)
return get_selected_layers(context, is_not_missing)
class RemoveSelectedLayers(bpy.types.Operator, RemoveLayers):
bl_idname = "bioxelnodes.remove_selected_layers"
bl_label = "Remove Selected Layers"
bl_description = "Remove selected layers"
bl_icon = "TRASH"
success_msg = "Successfully removed all selected layers."
def get_layers(self, context):
return get_selected_layers(context)
def execute(self, context):
node_group = get_main_node_group(context)
if not node_group:
self.report({"ERROR"}, "No node group found.")
return {"CANCELLED"}
node = node_group.nodes.get(self.node_name)
if not node:
self.report({"ERROR"}, f"Node '{self.node_name}' not found.")
return {"CANCELLED"}
# Deselect all, select and focus this node
for n in node_group.nodes:
n.select = False
node.select = True
node_group.nodes.active = node
bpy.ops.node.view_selected()
return {"FINISHED"}

View File

@@ -1,110 +1,21 @@
import json
import uuid
import webbrowser
import bpy
from pathlib import Path
import shutil
from ..bioxelutils.common import get_all_layer_objs, get_node_lib_path, get_node_version, is_missing_layer, set_file_prop
from .layer import RemoveLayers, SaveLayersCache
from ..constants import LATEST_NODE_LIB_PATH, VERSIONS
from ..utils import get_cache_dir
class ReLinkNodeLib(bpy.types.Operator):
bl_idname = "bioxelnodes.relink_node_lib"
bl_label = "Relink Node Library"
bl_description = "Relink all nodes to addon library source"
bl_options = {'UNDO'}
index: bpy.props.IntProperty() # type: ignore
def execute(self, context):
node_version = VERSIONS[self.index]['node_version']
lib_path = get_node_lib_path(node_version) \
if self.index != 0 else LATEST_NODE_LIB_PATH
node_libs = []
for node_group in bpy.data.node_groups:
if node_group.name.startswith("BioxelNodes"):
node_lib = node_group.library
if node_lib:
node_libs.append(node_lib)
node_libs = list(set(node_libs))
for node_lib in node_libs:
node_lib.filepath = str(lib_path)
# FIXME: may cause crash
node_lib.reload()
set_file_prop("node_version", node_version)
self.report({"INFO"}, f"Successfully relinked.")
return {'FINISHED'}
class SaveNodeLib(bpy.types.Operator):
bl_idname = "bioxelnodes.save_node_lib"
bl_label = "Save Node Library"
bl_description = "Save node library file to local"
bl_options = {'UNDO'}
lib_dir: bpy.props.StringProperty(
name="Library Directory",
subtype='DIR_PATH',
default="//"
) # type: ignore
def execute(self, context):
node_version = get_node_version()
if node_version is None:
node_version = VERSIONS[0]["node_version"]
else:
if node_version not in [v["node_version"] for v in VERSIONS]:
node_version = VERSIONS[0]["node_version"]
if node_version == VERSIONS[0]["node_version"]:
lib_path = LATEST_NODE_LIB_PATH
else:
lib_path = get_node_lib_path(node_version)
version_str = "v"+".".join([str(i) for i in list(node_version)])
lib_dir = bpy.path.abspath(self.lib_dir)
local_lib_path: Path = Path(lib_dir,
f"BioxelNodes_{version_str}.blend").resolve()
node_lib_path: Path = lib_path
blend_path = Path(bpy.path.abspath("//")).resolve()
if local_lib_path != node_lib_path:
shutil.copy(node_lib_path, local_lib_path)
libs = []
for node_group in bpy.data.node_groups:
if node_group.name.startswith("BioxelNodes"):
if node_group.library:
libs.append(node_group.library)
libs = list(set(libs))
for lib in libs:
lib.filepath = bpy.path.relpath(str(local_lib_path),
start=str(blend_path))
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.invoke_props_dialog(self)
return {'RUNNING_MODAL'}
@classmethod
def poll(cls, context):
return bpy.data.is_saved
from ..constants import LATEST_NODE_LIB_PATH
from ..utils import (
get_all_layer_objs,
get_cache_dir,
get_node_version,
is_missing_layer,
)
class CleanTemp(bpy.types.Operator):
bl_idname = "bioxelnodes.clear_temp"
bl_idname = "bioxel.clear_temp"
bl_label = "Clean Temp"
bl_description = "Clean all cache in temp (include other project cache)"
@@ -113,24 +24,26 @@ class CleanTemp(bpy.types.Operator):
try:
shutil.rmtree(cache_dir)
self.report({"INFO"}, f"Successfully cleaned temp.")
return {'FINISHED'}
return {"FINISHED"}
except:
self.report({"WARNING"},
"Fail to clean temp, you may do it manually.")
return {'CANCELLED'}
self.report(
{"WARNING"}, "Fail to clean temp, you may do it manually.")
return {"CANCELLED"}
def invoke(self, context, event):
context.window_manager.invoke_confirm(self,
event,
message="All temp files will be removed, include other project cache, do you still want to clean?")
return {'RUNNING_MODAL'}
context.window_manager.invoke_confirm(
self,
event,
message="All temp files will be removed, include other project cache, do you still want to clean?",
)
return {"RUNNING_MODAL"}
class RenderSettingPreset(bpy.types.Operator):
bl_idname = "bioxelnodes.render_setting_preset"
bl_idname = "bioxel.render_setting_preset"
bl_label = "Render Setting Presets"
bl_description = "Render setting presets for bioxel"
bl_options = {'UNDO'}
bl_options = {"UNDO"}
PRESETS = {
"performance": "Performance",
@@ -138,166 +51,201 @@ class RenderSettingPreset(bpy.types.Operator):
"quality": "Quality",
}
preset: bpy.props.EnumProperty(name="Preset",
default="balance",
items=[(k, v, "")
for k, v in PRESETS.items()]) # type: ignore
preset: bpy.props.EnumProperty(
name="Preset", default="balance", items=[(k, v, "") for k, v in PRESETS.items()]
) # type: ignore
def execute(self, context):
if self.preset == "performance":
# EEVEE
# bpy.context.scene.eevee.use_taa_reprojection = False
bpy.context.scene.eevee.use_shadow_jitter_viewport = True
bpy.context.scene.eevee.volumetric_tile_size = '2'
bpy.context.scene.eevee.use_taa_reprojection = False
# bpy.context.scene.eevee.use_shadow_jitter_viewport = True
bpy.context.scene.eevee.volumetric_tile_size = "4"
bpy.context.scene.eevee.volumetric_shadow_samples = 32
bpy.context.scene.eevee.volumetric_samples = 64
bpy.context.scene.eevee.volumetric_ray_depth = 1
bpy.context.scene.eevee.use_volumetric_shadows = True
# Cycles
bpy.context.scene.cycles.volume_bounces = 0
bpy.context.scene.cycles.volume_bounces = 4
bpy.context.scene.cycles.transparent_max_bounces = 4
bpy.context.scene.cycles.volume_preview_step_rate = 4
bpy.context.scene.cycles.volume_step_rate = 4
bpy.context.scene.cycles.volume_biased = True
bpy.context.scene.cycles.volume_preview_step_rate = 100
bpy.context.scene.cycles.volume_step_rate = 100
elif self.preset == "balance":
# EEVEE
# bpy.context.scene.eevee.use_taa_reprojection = False
bpy.context.scene.eevee.use_shadow_jitter_viewport = True
bpy.context.scene.eevee.volumetric_tile_size = '2'
bpy.context.scene.eevee.use_taa_reprojection = False
# bpy.context.scene.eevee.use_shadow_jitter_viewport = True
bpy.context.scene.eevee.volumetric_tile_size = "2"
bpy.context.scene.eevee.volumetric_shadow_samples = 64
bpy.context.scene.eevee.volumetric_samples = 128
bpy.context.scene.eevee.volumetric_ray_depth = 8
bpy.context.scene.eevee.use_volumetric_shadows = True
# Cycles
bpy.context.scene.cycles.volume_bounces = 4
bpy.context.scene.cycles.transparent_max_bounces = 8
bpy.context.scene.cycles.volume_preview_step_rate = 1
bpy.context.scene.cycles.volume_step_rate = 1
bpy.context.scene.cycles.volume_bounces = 8
bpy.context.scene.cycles.transparent_max_bounces = 16
bpy.context.scene.cycles.volume_biased = True
bpy.context.scene.cycles.volume_preview_step_rate = 10
bpy.context.scene.cycles.volume_step_rate = 10
elif self.preset == "quality":
# EEVEE
# bpy.context.scene.eevee.use_taa_reprojection = False
bpy.context.scene.eevee.use_shadow_jitter_viewport = True
bpy.context.scene.eevee.volumetric_tile_size = '2'
bpy.context.scene.eevee.use_taa_reprojection = False
# bpy.context.scene.eevee.use_shadow_jitter_viewport = True
bpy.context.scene.eevee.volumetric_tile_size = "1"
bpy.context.scene.eevee.volumetric_shadow_samples = 128
bpy.context.scene.eevee.volumetric_samples = 256
bpy.context.scene.eevee.volumetric_ray_depth = 16
bpy.context.scene.eevee.use_volumetric_shadows = True
# Cycles
bpy.context.scene.cycles.volume_bounces = 8
bpy.context.scene.cycles.transparent_max_bounces = 16
bpy.context.scene.cycles.volume_preview_step_rate = 0.5
bpy.context.scene.cycles.volume_step_rate = 0.5
bpy.context.scene.cycles.volume_bounces = 16
bpy.context.scene.cycles.transparent_max_bounces = 32
bpy.context.scene.cycles.volume_biased = False
return {'FINISHED'}
class SliceViewer(bpy.types.Operator):
bl_idname = "bioxelnodes.slice_viewer"
bl_label = "Slice Viewer"
bl_description = "A preview scene setting for viewing slicers"
bl_icon = "FILE_VOLUME"
def execute(self, context):
# bpy.context.scene.eevee.use_taa_reprojection = False
bpy.context.scene.eevee.volumetric_tile_size = '2'
bpy.context.scene.eevee.volumetric_shadow_samples = 128
bpy.context.scene.eevee.volumetric_samples = 128
bpy.context.scene.eevee.volumetric_ray_depth = 1
bpy.context.scene.eevee.use_shadows = False
bpy.context.scene.eevee.use_volumetric_shadows = False
view_3d = None
if context.area.type == 'VIEW_3D':
view_3d = context.area
else:
for area in context.screen.areas:
if area.type == 'VIEW_3D':
view_3d = area
break
if view_3d:
view_3d.spaces[0].shading.type = 'MATERIAL'
view_3d.spaces[0].shading.studio_light = 'studio.exr'
view_3d.spaces[0].shading.studiolight_intensity = 1.5
view_3d.spaces[0].shading.use_scene_lights = False
view_3d.spaces[0].shading.use_scene_world = False
return {'FINISHED'}
def get_all_layers(layer_filter=None):
def _layer_filter(layer_obj):
return True
layer_filter = layer_filter or _layer_filter
layer_objs = get_all_layer_objs()
return [obj for obj in layer_objs if layer_filter(obj)]
class SaveAllLayersCache(bpy.types.Operator, SaveLayersCache):
bl_idname = "bioxelnodes.save_all_layers_cache"
bl_label = "Save All Layers Cache"
bl_description = "Save all cache of this file"
bl_icon = "FILE_TICK"
success_msg = "Successfully saved all layers."
def get_layers(self, context):
def is_not_missing(layer_obj):
return not is_missing_layer(layer_obj)
return get_all_layers(is_not_missing)
class RemoveAllMissingLayers(bpy.types.Operator, RemoveLayers):
bl_idname = "bioxelnodes.remove_all_missing_layers"
bl_label = "Remove All Missing Layers"
bl_description = "Remove all current container missing layers"
bl_icon = "BRUSH_DATA"
success_msg = "Successfully removed all missing layers."
def get_layers(self, context):
def is_missing(layer_obj):
return is_missing_layer(layer_obj)
return get_all_layers(is_missing)
def invoke(self, context, event):
context.window_manager.invoke_confirm(self,
event,
message=f"Are you sure to remove all **Missing** layers?")
return {'RUNNING_MODAL'}
return {"FINISHED"}
class Help(bpy.types.Operator):
bl_idname = "bioxelnodes.help"
bl_idname = "bioxel.help"
bl_label = "Need Help?"
bl_description = "Online Manual for Beginner"
bl_icon = "HELP"
def execute(self, context):
webbrowser.open(
'https://omoolab.github.io/BioxelNodes/latest/', new=2)
webbrowser.open("https://omoolab.github.io/BioxelNodes/latest/", new=2)
return {'FINISHED'}
return {"FINISHED"}
class AddEeveeEnv(bpy.types.Operator):
bl_idname = "bioxelnodes.add_eevee_env"
bl_label = "Add EEVEE Env Light"
bl_description = "To make volume shadow less dark"
# 定义虚无化切换操作
class TogglePhantom(bpy.types.Operator):
"""Toggle object phantom state (ray invisible, no shadows, semi-transparent wireframe)"""
bl_idname = "bioxel.toggle_phantom"
bl_label = "Toggle Phantom"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
bpy.ops.wm.append(
directory=f"{str(LATEST_NODE_LIB_PATH)}/Object",
filename="EeveeEnv"
obj = context.active_object
if not obj:
self.report({"WARNING"}, "No object selected")
return {"CANCELLED"}
# 获取3D视图空间
view_space = None
for area in bpy.context.screen.areas:
if area.type == "VIEW_3D":
for space in area.spaces:
if space.type == "VIEW_3D":
view_space = space
break
if view_space:
break
# 确保存储状态的文本数据块存在
text_name = "phantom_orig_states"
if text_name not in bpy.data.texts:
bpy.data.texts.new(text_name)
# 从文本数据块加载状态
states_text = bpy.data.texts[text_name]
try:
states = json.loads(states_text.as_string())
except:
states = {}
def get_obj_uuid(obj):
if "bioxel_uuid" not in obj:
obj["bioxel_uuid"] = uuid.uuid4().hex
return obj["bioxel_uuid"]
# 使用物体的唯一标识符来追踪
obj_uuid = get_obj_uuid(obj)
# 检查物体是否已经处于虚无化状态
is_phantom = (
obj.display_type == "SOLID"
and obj.show_wire
and obj.show_in_front
and round(obj.color[3], 2) == 0.10
and not obj.visible_camera
and not obj.visible_diffuse
and not obj.visible_glossy
and not obj.visible_transmission
and not obj.visible_volume_scatter
and not obj.visible_shadow
)
bpy.context.scene.eevee.use_shadows = True
bpy.context.scene.eevee.use_volumetric_shadows = True
if is_phantom:
# 恢复原始状态
if obj_uuid in states:
state = states[obj_uuid]
obj.display_type = state["display_type"]
obj.show_wire = state["show_wire"]
obj.show_in_front = state["show_in_front"]
obj.color = state["color"]
obj.visible_camera = state["visible_camera"]
obj.visible_diffuse = state["visible_diffuse"]
obj.visible_glossy = state["visible_glossy"]
obj.visible_transmission = state["visible_transmission"]
obj.visible_volume_scatter = state["visible_volume_scatter"]
obj.visible_shadow = state["visible_shadow"]
return {'FINISHED'}
# 恢复视图着色设置(如果之前有保存)
if view_space and "viewport_color_type" in state:
view_space.shading.color_type = state["viewport_color_type"]
del states[obj_uuid]
self.report({"INFO"}, f"Object restored")
else:
# 保存原始状态
state_data = {
"display_type": obj.display_type,
"show_wire": obj.show_wire,
"show_in_front": obj.show_in_front,
"color": list(obj.color), # 保存RGBA颜色值
"visible_camera": obj.visible_camera,
"visible_diffuse": obj.visible_diffuse,
"visible_glossy": obj.visible_glossy,
"visible_transmission": obj.visible_transmission,
"visible_volume_scatter": obj.visible_volume_scatter,
"visible_shadow": obj.visible_shadow,
}
# 检查并处理视图着色设置
if view_space:
# 保存当前视图着色设置
state_data["viewport_color_type"] = view_space.shading.color_type
# 检查当前是否为object, random或attribute
valid_types = {"OBJECT", "RANDOM", "ATTRIBUTE"}
if view_space.shading.color_type not in valid_types:
# 改为object模式
view_space.shading.color_type = "OBJECT"
states[obj_uuid] = state_data
# 设置虚无化状态
obj.display_type = "SOLID" # 以实体方式显示
obj.show_wire = True # 显示线框
obj.show_in_front = True # 显示在其他物体前面
# 保持RGB不变,仅修改alpha透明度为0.1
obj.color[3] = 0.1
# 关闭所有射线可见性和投影
obj.visible_camera = False
obj.visible_diffuse = False
obj.visible_glossy = False
obj.visible_transmission = False
obj.visible_volume_scatter = False
obj.visible_shadow = False
self.report({"INFO"}, f"Object phantomed")
# 保存状态到文本数据块
states_text.clear()
states_text.write(json.dumps(states, indent=2))
return {"FINISHED"}

View File

@@ -1,12 +1,12 @@
import bpy
from ..bioxelutils.common import is_incompatible, local_lib_not_updated
from ..bioxelutils.node import assign_node_tree, get_node_tree
from ..utils import is_incompatible, local_lib_not_updated
from ..node import assign_node_tree, get_node_tree
from ..utils import get_use_link
class AddNode(bpy.types.Operator):
bl_idname = "bioxelnodes.add_node"
bl_idname = "bioxel.add_node"
bl_label = "Add Node"
bl_options = {"REGISTER", "UNDO"}
@@ -24,7 +24,7 @@ class AddNode(bpy.types.Operator):
@property
def node_type(self):
return f"BioxelNodes_{self.node_name}"
return f"O {self.node_name}"
def execute(self, context):
space = context.space_data

View File

@@ -0,0 +1,107 @@
import bpy
from ..utils import get_use_link
from ..node import add_node_to_graph, get_output_node, get_main_node_group
class ExtractMesh(bpy.types.Operator):
bl_idname = "bioxel.extract_mesh"
bl_label = "Extract Mesh"
bl_description = "Extract Mesh"
bl_icon = "OUTLINER_OB_MESH"
bl_options = {"UNDO"}
@classmethod
def poll(cls, context):
# Check if there is a selected node in the node group
node_group = get_main_node_group(context)
selected_nodes = [n for n in node_group.nodes if n.select]
if len(selected_nodes) == 0:
return False
else:
source_node = selected_nodes[0]
return "Structure" in source_node.outputs
def execute(self, context):
container_obj = context.object
if container_obj is None:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {"FINISHED"}
node_group = container_obj.modifiers[0].node_group
selected_nodes = [n for n in node_group.nodes if n.select]
source_node = selected_nodes[0]
# Check if the selected node has a "Structure" output
if "Structure" not in source_node.outputs:
self.report({"ERROR"}, "Selected node has no 'Structure' output.")
return {"CANCELLED"}
# 记录原始输出连接(用于后续复原)
output_node = get_output_node(node_group)
original_links = list(output_node.inputs[0].links)
# 添加Component to Mesh节点
to_mesh_node = add_node_to_graph(
"Structure to Mesh", node_group, use_link=get_use_link()
)
# 连接节点:源节点 -> Component to Mesh -> 输出节点
node_group.links.new(
source_node.outputs["Structure"], to_mesh_node.inputs[0])
node_group.links.new(to_mesh_node.outputs[0], output_node.inputs[0])
new_obj = None
try:
deps = context.evaluated_depsgraph_get()
eval_obj = container_obj.evaluated_get(deps)
# obtain evaluated mesh (from modifiers including Geometry Nodes)
mesh_eval = eval_obj.to_mesh()
if mesh_eval is None:
raise RuntimeError("Failed to produce evaluated mesh")
mesh_name = f"{container_obj.name}_Mesh"
mesh_copy = mesh_eval.copy()
mesh_copy.name = mesh_name
# clear the temporary evaluated mesh
try:
eval_obj.to_mesh_clear()
except Exception:
pass
# create the new object with same transform as source
new_obj = bpy.data.objects.new(mesh_name, mesh_copy)
# link to same collection as the container (fallback to current collection)
col = (
container_obj.users_collection[0]
if getattr(container_obj, "users_collection", None)
else context.collection
)
if col is None:
col = context.collection
col.objects.link(new_obj)
# copy world transform exactly
new_obj.matrix_world = container_obj.matrix_world.copy()
except Exception as e:
self.report({"ERROR"}, f"Failed to extract mesh: {e}")
# 恢复原始连接
for link in original_links:
node_group.links.new(link.from_socket, output_node.inputs[0])
# 删除临时添加的Component to Mesh节点
node_group.nodes.remove(to_mesh_node)
if new_obj:
# deselect everything, select and activate new_obj
for o in list(context.selected_objects):
o.select_set(False)
new_obj.select_set(True)
context.view_layer.objects.active = new_obj
self.report({"INFO"}, f"Mesh extracted: {new_obj.name}")
return {"FINISHED"}
else:
return {"CANCELLED"}

180
src/bioxelnodes/panels.py Normal file
View File

@@ -0,0 +1,180 @@
from pathlib import Path
import bpy
from .node import get_layer_nodes, get_main_node_group
from .utils import load_icon
from .layer import get_layer_caches
from .operators.io import ImportAsColor, ImportAsLabel, ImportAsScalar, ImportData
from .operators.misc import Help, RenderSettingPreset
from .operators.layer import (
AddLayerNode,
DeleteLayer,
RelocatePath,
RenameLayer,
SaveLayer,
)
class ImportMenu(bpy.types.Menu):
bl_label = "Bioxel Import"
bl_idname = "BIOXEL_MT_import"
def draw(self, context):
layout = self.layout
layout.operator(ImportAsScalar.bl_idname,
text="Import As Scalar").filepath = ""
layout.operator(ImportAsLabel.bl_idname,
text="Import As Label").filepath = ""
layout.operator(ImportAsColor.bl_idname,
text="Import As Color").filepath = ""
class RenderSettingMenu(bpy.types.Menu):
bl_label = "Render Setting Presets"
bl_idname = "BIOXEL_MT_render_setting"
def draw(self, context):
layout = self.layout
for k, v in RenderSettingPreset.PRESETS.items():
op = layout.operator(RenderSettingPreset.bl_idname, text=v)
op.preset = k
class BioxelPanelBase:
"""
Abstract base panel for all Bioxel UI panels.
- Ensures the panel appears only when the active area is the Node Editor
and the Node Editor is editing a Geometry Nodes tree.
- Places the panel under the "Bioxel" sidebar tab.
- Provides an optional bl_order for panel ordering inside the category.
"""
bl_space_type = "NODE_EDITOR"
bl_region_type = "UI"
bl_context = "geometry_node"
bl_category = "胞素(Bioxel)"
# try to make Bioxel panels order early (may be respected by Blender)
@classmethod
def poll(cls, context):
node_group = get_main_node_group(context)
return node_group is not None
class HeaderPanel(bpy.types.Panel, BioxelPanelBase):
bl_label = "胞素(Bioxel)"
bl_idname = "BIOXEL_PT_header_panel"
bl_order = 0
def draw(self, context):
layout = self.layout
layout.operator(
Help.bl_idname, text="Help", icon=Help.bl_icon # 复用原操作的图标定义
)
layout.menu(
RenderSettingMenu.bl_idname, text="Render Preset", icon="RENDER_STILL"
)
class LayerNodePanel(BioxelPanelBase, bpy.types.Panel):
bl_label = "Layer Nodes"
bl_idname = "BIOXEL_PT_o_layer_nodes"
bl_order = 1
def draw(self, context):
layout = self.layout
node_group = get_main_node_group(context)
layout.template_list(
"BIOXEL_UL_layer_nodes",
"",
node_group,
"nodes",
context.window_manager,
"bioxel_layer_list_index",
rows=3,
)
class LibraryPanel(BioxelPanelBase, bpy.types.Panel):
bl_label = "Layer Library"
bl_idname = "BIOXEL_PT_library_panel"
bl_order = 2
def draw(self, context):
layout = self.layout
wm = context.window_manager
caches = get_layer_caches()
row = layout.row()
row.scale_y = 2.0
row.operator(ImportData.bl_idname, icon="IMPORT")
# row.menu(ImportMenu.bl_idname, icon="IMPORT", text="Import")
layout.separator()
layout.template_icon_view(
bpy.context.window_manager,
"bioxel_layer_library",
show_labels=False,
scale=10.0,
scale_popup=6.0,
)
selected_id = getattr(wm, "bioxel_layer_library", None)
selected_id = None if selected_id == "nothing_found" else selected_id
entry = next((c for c in caches if c.get("id") == selected_id), None)
if entry:
cache_id = entry["id"]
path = entry["path"]
name = entry.get("name", "?")
kind = entry.get("kind", "?")
path_exists = Path(bpy.path.abspath(path)).exists()
layout.prop(wm, "bioxel_snapshot_z", text="Z Slice", slider=True)
layout.separator()
# operations and meta_box as before
ops_row = layout.row(align=True)
if path_exists:
ops_row.operator(
AddLayerNode.bl_idname, text="Add", icon="PLUS"
).cache_id = selected_id
ops_row.operator(
SaveLayer.bl_idname, text="Save", icon="FILE_TICK"
).cache_id = selected_id
ops_row.operator(
RenameLayer.bl_idname, text="Name", icon="FONT_DATA"
).cache_id = selected_id
ops_row.operator(
DeleteLayer.bl_idname, text="", icon="TRASH"
).cache_id = selected_id
else:
ops_row.operator(
RelocatePath.bl_idname, text="Relocate", icon="FILE_FOLDER"
).cache_id = selected_id
ops_row.operator(
DeleteLayer.bl_idname, text="", icon="TRASH"
).cache_id = selected_id
meta_box = layout.box()
if hasattr(meta_box, "alert"):
meta_box.alert = True
meta_box.label(text=f"Cache is missing...")
meta_box.label(text=f"Please relocate the cache path!")
return
meta_box = layout.box()
meta_box.label(text=f"Name: {name}")
meta_box.label(text=f"Path: {path}")
meta_box.label(text=f"Kind: {kind}")
meta_box.label(
text=f"Dims: [{entry.get('frame_count',1)},{tuple(entry.get('shape',''))},{entry.get('channel_count',1)}]"
)
if kind in ["scalar", "color", "vector"]:
path = Path(bpy.path.abspath(entry["path"]))
histogram = load_icon(
path / "histogram.png", f"{cache_id}_histogram")
meta_box.template_icon(histogram, scale=10.0)
else:
layout.label(text="No layer selected", icon="QUESTION")

View File

@@ -8,22 +8,10 @@ class BioxelNodesPreferences(bpy.types.AddonPreferences):
cache_dir: bpy.props.StringProperty(
name="Set Cache Directory",
subtype='DIR_PATH',
default=str(Path(Path.home(), '.bioxelnodes'))
default=str(Path(Path.home(), '.bioxel'))
) # type: ignore
change_render_setting: bpy.props.BoolProperty(
name="Change Render Setting on First Import",
default=True,
) # type: ignore
node_import_method: bpy.props.EnumProperty(name="Node Import Method",
default="LINK",
items=[("LINK", "Link", ""),
("APPEND", "Append (Reuse Data)", "")]) # type: ignore
def draw(self, context):
layout = self.layout
layout.label(text="Configuration")
layout.prop(self, 'cache_dir')
layout.prop(self, "change_render_setting")
layout.prop(self, "node_import_method")

View File

@@ -1,44 +1,134 @@
from pathlib import Path
import bpy
from .bioxelutils.common import get_node_type
from .operators.layer import SelectAndFocusNode
from .utils import load_icon
from .layer import get_layer_caches, set_layer_caches
class BIOXELNODES_UL_layer_list(bpy.types.UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
layout.label(text=item.label, translate=False, icon_value=icon)
def get_snapshot_icon(cache, z: float):
cache_id = str(cache["id"])
shape = (64, 64, 32)
zidx = int(z * (shape[2] - 1))
path = Path(bpy.path.abspath(cache["path"]))
return load_icon(path / f"snapshot_{zidx}.png", f"{cache_id}_{zidx}")
class BIOXELNODES_Series(bpy.types.PropertyGroup):
id: bpy.props.StringProperty() # type: ignore
label: bpy.props.StringProperty() # type: ignore
def _bioxel_layer_items(self, context):
"""
EnumProperty items callback: 从 get_layer_caches 构造枚举项。
使用 PREVIEWS 加载每个 layer 的 thumbnail_png(如果存在)并返回 icon_id。
"""
items = []
caches = get_layer_caches()
for idx, cache in enumerate(caches):
cache_id = str(cache["id"])
name = cache.get("name", f"Layer ?")
description = cache.get("kind", "?")
z = cache.get("snapshot_z", 0.5)
icon = get_snapshot_icon(cache, z)
# 在这个context下,只能加载无法生成。
items.append((cache_id, name, description, icon, idx))
if not items:
items = [("nothing_found", "No Layers",
"No layers found", "QUESTION", 0)]
return items
def select_layer(self, context):
layer_list_UL = bpy.context.window_manager.bioxelnodes_layer_list_UL
layer_list = layer_list_UL.layer_list
layer_list_active = layer_list_UL.layer_list_active
if len(layer_list) > 0 and layer_list_active != -1 and layer_list_active < len(layer_list):
layer_obj = bpy.data.objects[layer_list[layer_list_active].obj_name]
node_group = context.space_data.edit_tree
for node in node_group.nodes:
node.select = False
if get_node_type(node) == "BioxelNodes_FetchLayer":
if node.inputs[0].default_value == layer_obj:
node.select = True
layer_list_UL.layer_list_active = -1
def _update_layer_gallery(self, context):
wm = context.window_manager
selected_id = getattr(wm, "bioxel_layer_library", None)
cache = next(
(c for c in get_layer_caches() if str(c.get("id", "")) == str(selected_id)),
None,
)
z = cache.get("snapshot_z", 0.5) if cache else 0.5
setattr(wm, "bioxel_snapshot_z", z)
class BIOXELNODES_Layer(bpy.types.PropertyGroup):
obj_name: bpy.props.StringProperty() # type: ignore
label: bpy.props.StringProperty() # type: ignore
info_text: bpy.props.StringProperty() # type: ignore
def _update_snapshot_z(self, context):
"""
update callback for WindowManager.bioxel_thumb_z
- write a slice PNG from the saved .npy
- update PREVIEWS entry for the selected layer
- force template_icon_view to refresh by briefly cycling the EnumProperty value
- force UI redraw
"""
wm = context.window_manager
selected_id = getattr(wm, "bioxel_layer_library", None)
if not selected_id or selected_id == "nothing_found":
return
try:
selected_id = int(selected_id)
except Exception:
return
caches = get_layer_caches()
z = getattr(wm, "bioxel_snapshot_z", 0.5)
for cache in caches:
if int(cache.get("id", -1)) == selected_id:
cache["snapshot_z"] = z
break
set_layer_caches(caches)
class BIOXELNODES_LayerListUL(bpy.types.PropertyGroup):
layer_list: bpy.props.CollectionProperty(
type=BIOXELNODES_Layer) # type: ignore
layer_list_active: bpy.props.IntProperty(default=-1,
update=select_layer,
options=set()) # type: ignore
class BIOXEL_Series(bpy.types.PropertyGroup):
id: bpy.props.StringProperty() # type: ignore
label: bpy.props.StringProperty() # type: ignore
class BIOXEL_UL_layer_nodes(bpy.types.UIList):
"""UIList for O Layer nodes only"""
bl_idname = "BIOXEL_UL_layer_nodes"
def filter_items(self, context, data, propname):
nodes = getattr(data, propname)
flt_flags = []
flt_neworder = []
for node in nodes:
# 只保留 O Layer 节点
if (
hasattr(node, "bl_idname")
and node.bl_idname.startswith("GeometryNodeGroup")
and getattr(getattr(node, "node_tree", None), "name", "").startswith(
"O Layer"
)
):
flt_flags.append(self.bitflag_filter_item)
else:
flt_flags.append(0)
return flt_flags, flt_neworder
def draw_item(
self, context, layout, data, item, icon, active_data, active_propname, index
):
node = item
# 这里只画 O Layer 节点,其他已被 filter_items 过滤
split = layout.split(factor=0.3, align=True)
col1 = split.row(align=True)
col2 = split.row(align=True)
name_socket = node.inputs["Name"]
resample_socket = node.inputs["Resample"]
scale_socket = node.inputs["Bioxel Scale"]
name = str(node.inputs["Name"].default_value)
# row.prop(name_socket, "default_value", text="")
col1.prop(resample_socket, "default_value",
icon="UV_DATA", text="", toggle=1)
col1.prop(scale_socket, "default_value", text="")
col2.label(text=name)
op = col2.operator(
SelectAndFocusNode.bl_idname, text="", icon="RESTRICT_SELECT_OFF", emboss=True
)
op.node_name = node.name

View File

@@ -1,6 +1,12 @@
from pathlib import Path
from ast import literal_eval
import shutil
import bpy
import numpy as np
from .node import get_nodes_by_type
from .constants import LATEST_NODE_LIB_PATH, NODE_LIB_DIRPATH, PREVIEW_COLLECTIONS
def copy_to_dir(source_path, dir_path, new_name=None, exist_ok=True):
@@ -44,16 +50,16 @@ def select_object(target_obj):
def progress_bar(self, context):
row = self.layout.row()
row.progress(
factor=context.window_manager.bioxelnodes_progress_factor,
factor=context.window_manager.bioxel_progress_factor,
type="BAR",
text=context.window_manager.bioxelnodes_progress_text
text=context.window_manager.bioxel_progress_text
)
row.scale_x = 2
def progress_update(context, factor, text=""):
context.window_manager.bioxelnodes_progress_factor = factor
context.window_manager.bioxelnodes_progress_text = text
bpy.context.window_manager.bioxel_progress_factor = factor
bpy.context.window_manager.bioxel_progress_text = text
def get_preferences():
@@ -62,11 +68,335 @@ def get_preferences():
def get_cache_dir():
preferences = get_preferences()
cache_path = Path(preferences.cache_dir, 'VDBs')
cache_path = Path(preferences.cache_dir)
cache_path.mkdir(parents=True, exist_ok=True)
return str(cache_path)
return cache_path
def get_use_link():
preferences = get_preferences()
return preferences.node_import_method == "LINK"
def get_container_objs_from_selection():
container_objs = []
for obj in bpy.context.selected_objects:
if get_container_obj(obj):
container_objs.append(obj)
return list(set(container_objs))
def get_container_obj(current_obj):
if current_obj:
if current_obj.get('bioxel_container'):
return current_obj
elif current_obj.get('bioxel_layer'):
parent = current_obj.parent
return parent if parent.get('bioxel_container') else None
return None
def get_layer_prop_value(layer_obj: bpy.types.Object, prop_name: str):
node_group = layer_obj.modifiers[0].node_group
layer_node = get_nodes_by_type(node_group, "BioxelNodes__Layer")[0]
prop = layer_node.inputs.get(prop_name)
if prop is None:
return None
value = prop.default_value
if type(value).__name__ == "bpy_prop_array":
value = tuple(value)
return tuple([int(v) for v in value]) \
if prop in ["shape"] else value
elif type(value).__name__ == "str":
return str(value)
elif type(value).__name__ == "float":
value = float(value)
return round(value, 2) \
if prop in ["bioxel_size"] else value
elif type(value).__name__ == "int":
value = int(value)
return value
else:
return value
def get_layer_name(layer_obj):
return get_layer_prop_value(layer_obj, "name")
def get_layer_kind(layer_obj):
return get_layer_prop_value(layer_obj, "kind")
def get_layer_label(layer_obj):
name = get_layer_name(layer_obj)
# kind = get_layer_kind(layer_obj)
label = f"{name}"
if is_missing_layer(layer_obj):
return "**MISSING**" + label
elif is_temp_layer(layer_obj):
return "* " + label
else:
return label
def is_missing_layer(layer_obj):
cache_filepath = Path(bpy.path.abspath(
layer_obj.data.filepath)).resolve()
return not cache_filepath.is_file()
def is_temp_layer(layer_obj):
cache_filepath = Path(bpy.path.abspath(
layer_obj.data.filepath)).resolve()
cache_dirpath = Path(get_cache_dir())
return cache_dirpath in cache_filepath.parents
def set_layer_prop_value(layer_obj: bpy.types.Object, prop: str, value):
node_group = layer_obj.modifiers[0].node_group
layer_node = get_nodes_by_type(node_group, "BioxelNodes__Layer")[0]
layer_node.inputs[prop].default_value = value
def get_layer_obj(current_obj: bpy.types.Object):
if current_obj:
if current_obj.get('bioxel_layer') and current_obj.parent:
if current_obj.parent.get('bioxel_container'):
return current_obj
return None
def get_container_layer_objs(container_obj: bpy.types.Object):
layer_objs = []
for obj in bpy.data.objects:
if obj.parent == container_obj and get_layer_obj(obj):
layer_objs.append(obj)
return layer_objs
def get_all_layer_objs():
layer_objs = []
for obj in bpy.data.objects:
if get_layer_obj(obj):
layer_objs.append(obj)
return layer_objs
def add_driver(target, target_prop, var_sources, expression):
driver = target.driver_add(target_prop)
is_vector = isinstance(driver, list)
drivers = driver if is_vector else [driver]
for i, driver in enumerate(drivers):
for j, var_source in enumerate(var_sources):
source = var_source['source']
prop = var_source['prop']
var = driver.driver.variables.new()
var.name = f"var{j}"
var.targets[0].id_type = source.id_type
var.targets[0].id = source
var.targets[0].data_path = f'["{prop}"][{i}]'\
if is_vector else f'["{prop}"]'
driver.driver.expression = expression
def add_direct_driver(target, target_prop, source, source_prop):
drivers = [
{
"source": source,
"prop": source_prop
}
]
expression = "var0"
add_driver(target, target_prop, drivers, expression)
def read_file_prop(content: str):
props = {}
for line in content.split("\n"):
line = line.replace(" ", "")
p = line.split("=")[0]
if p != "":
v = line.split("=")[-1]
props[p] = v
return props
def write_file_prop(props: dict):
lines = []
for p, v in props.items():
lines.append(f"{p} = {v}")
return "\n".join(lines)
def set_file_prop(prop, value):
if bpy.data.texts.get("BioxelNodes") is None:
bpy.data.texts.new("BioxelNodes")
props = read_file_prop(bpy.data.texts["BioxelNodes"].as_string())
props[prop] = value
bpy.data.texts["BioxelNodes"].clear()
bpy.data.texts["BioxelNodes"].write(write_file_prop(props))
def get_file_prop(prop):
if bpy.data.texts.get("BioxelNodes") is None:
bpy.data.texts.new("BioxelNodes")
props = read_file_prop(bpy.data.texts["BioxelNodes"].as_string())
return props.get(prop)
def get_node_version():
node_version = get_file_prop("node_version")
return literal_eval(node_version) if node_version else None
def is_incompatible():
return False
def get_node_lib_path(node_version):
version_str = "v"+".".join([str(i) for i in list(node_version)])
lib_filename = f"BioxelNodes_{version_str}.blend"
return Path(NODE_LIB_DIRPATH,
lib_filename).resolve()
def local_lib_not_updated():
return False
def ndarray_to_png(array: np.ndarray, save_path: str):
"""
Create a PNG from a 2D or 3-channel slice array using Blender API.
slice_arr expected shape: (W, H) or (W, H, 1) or (W,H,3). Values assumed numeric.
The function:
- Converts single-channel arrays to RGB by repeating channel.
- Flips the image vertically to match Blender's origin.
- Writes the image file to save_path using a temporary Blender image datablock.
Parameters:
- array: numpy ndarray representing the slice (W,H,C) or (W,H,1) or (W,H)
- save_path: destination PNG filepath
"""
save_path = Path(save_path)
h, w = array.shape[0], array.shape[1]
if array.shape[2] == 1:
img = np.concatenate([array, array, array], axis=2)
else:
img = array[..., :3]
# Blender expects pixels as sequence of floats in RGBA, length w*h*4
# NOTE: Blender's image origin is bottom-left; we'll flip vertically for visual consistency.
img = np.flipud(img)
pixels = np.empty((h * w * 4,), dtype=np.float32)
# fill
pixels[0::4] = img[..., 0].ravel()
pixels[1::4] = img[..., 1].ravel()
pixels[2::4] = img[..., 2].ravel()
pixels[3::4] = 1.0
# create a temporary image datablock, save then remove
name = "tmp"
image = bpy.data.images.new(
name, width=w, height=h, alpha=True, float_buffer=False)
image.pixels = pixels.tolist()
# ensure format and filepath then save
image.filepath_raw = str(save_path)
image.file_format = "PNG"
image.save()
# remove from data to avoid clutter
try:
bpy.data.images.remove(image)
except Exception:
pass
def split_text_to_lines(text: str, max_chars: int = 60) -> list:
"""
Split `text` into multiple lines not exceeding `max_chars` where possible.
Breaks on whitespace; long words are chunked.
Returns list of line strings.
"""
if not text:
return []
words = text.split()
lines = []
cur = ""
for w in words:
if cur:
candidate = cur + " " + w
else:
candidate = w
if len(candidate) <= max_chars:
cur = candidate
else:
if cur:
lines.append(cur)
# if single word longer than max_chars, chunk it
if len(w) > max_chars:
for i in range(0, len(w), max_chars):
lines.append(w[i: i + max_chars])
cur = ""
else:
cur = w
if cur:
lines.append(cur)
return lines
def wrapped_label(layout, text: str, max_chars: int = 42):
"""
Helper to add multiple layout.label calls for a long text.
"""
for line in split_text_to_lines(text, max_chars=max_chars):
layout.label(text=line)
def refresh_bioxel_panels(context):
"""
Force Bioxel UI to refresh.
- Tags relevant areas for redraw.
- Nudges the bioxel_layer_gallery EnumProperty to force items() re-evaluation
when possible (helps template_icon_view pick up updated icons/paths).
Use this from operators after they change b i o x e l _ l a y e r s or previews.
"""
# tag redraw for all windows/areas
for window in context.window_manager.windows:
for area in window.screen.areas:
# target node editor and other UI areas that may show previews
if area.type in {"NODE_EDITOR", "VIEW_3D", "IMAGE_EDITOR", "OUTLINER"}:
area.tag_redraw()
def load_icon(filepath: Path, key: str):
pcoll = PREVIEW_COLLECTIONS["layers"]
try:
icon = pcoll[key].icon_id
except KeyError:
if filepath.exists():
pcoll.load(key, str(filepath), "IMAGE")
try:
icon = pcoll[key].icon_id
except KeyError:
icon = "TEXTURE"
return icon

View File

691
uv.lock generated

File diff suppressed because it is too large Load Diff