From a558d1ae3089ee217e0048f6a41d853b7ba55053 Mon Sep 17 00:00:00 2001 From: MaNan Date: Wed, 21 Jan 2026 12:42:12 +0800 Subject: [PATCH 01/14] 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. --- .gitignore | 12 +- AGENTS.md | 222 +++++ build.py | 4 +- pack.py | 92 ++ pyproject.toml | 2 + src/bioxelnodes/__init__.py | 103 ++- .../assets/Nodes/BioxelNodes_latest.blend | 3 - .../assets/Nodes/BioxelNodes_v0.2.9.blend | 3 - .../assets/Nodes/BioxelNodes_v0.3.3.blend | 3 - .../assets/O Bioxel/BioxelNodes.blend | 3 + .../assets/O Bioxel/blender_assets.cats.txt | 12 + src/bioxelnodes/auto_load.py | 9 +- src/bioxelnodes/bioxel/layer.py | 37 +- src/bioxelnodes/bioxel/scipy/_filters.py | 28 +- .../bioxel/scipy/_interpolation.py | 3 +- src/bioxelnodes/bioxel/scipy/_ni_support.py | 3 +- src/bioxelnodes/bioxel/scipy/_utils.py | 2 +- src/bioxelnodes/bioxel/skimage/_warps.py | 1 + src/bioxelnodes/bioxel/skimage/dtype.py | 15 +- src/bioxelnodes/bioxelutils/__init__.py | 0 src/bioxelnodes/bioxelutils/common.py | 270 ------ src/bioxelnodes/bioxelutils/container.py | 186 ---- src/bioxelnodes/bioxelutils/layer.py | 215 ----- src/bioxelnodes/bioxelutils/node.py | 71 -- src/bioxelnodes/blender_manifest.toml | 26 +- src/bioxelnodes/constants.py | 256 +----- src/bioxelnodes/exceptions.py | 28 +- src/bioxelnodes/layer.py | 277 ++++++ src/bioxelnodes/menus.py | 394 +-------- src/bioxelnodes/node.py | 149 ++++ src/bioxelnodes/node_menu.py | 87 -- src/bioxelnodes/operators/container.py | 649 -------------- src/bioxelnodes/operators/io.py | 738 ++++++++-------- src/bioxelnodes/operators/layer.py | 799 ++++++------------ src/bioxelnodes/operators/misc.py | 400 ++++----- src/bioxelnodes/operators/node.py | 8 +- src/bioxelnodes/operators/structure.py | 107 +++ src/bioxelnodes/panels.py | 180 ++++ src/bioxelnodes/preferences.py | 14 +- src/bioxelnodes/props.py | 150 +++- src/bioxelnodes/utils.py | 342 +++++++- tests/__init__.py | 0 uv.lock | 695 +++++++++------ 43 files changed, 2915 insertions(+), 3683 deletions(-) create mode 100644 AGENTS.md create mode 100644 pack.py delete mode 100644 src/bioxelnodes/assets/Nodes/BioxelNodes_latest.blend delete mode 100644 src/bioxelnodes/assets/Nodes/BioxelNodes_v0.2.9.blend delete mode 100644 src/bioxelnodes/assets/Nodes/BioxelNodes_v0.3.3.blend create mode 100644 src/bioxelnodes/assets/O Bioxel/BioxelNodes.blend create mode 100644 src/bioxelnodes/assets/O Bioxel/blender_assets.cats.txt delete mode 100644 src/bioxelnodes/bioxelutils/__init__.py delete mode 100644 src/bioxelnodes/bioxelutils/common.py delete mode 100644 src/bioxelnodes/bioxelutils/container.py delete mode 100644 src/bioxelnodes/bioxelutils/layer.py delete mode 100644 src/bioxelnodes/bioxelutils/node.py create mode 100644 src/bioxelnodes/layer.py create mode 100644 src/bioxelnodes/node.py delete mode 100644 src/bioxelnodes/node_menu.py delete mode 100644 src/bioxelnodes/operators/container.py create mode 100644 src/bioxelnodes/operators/structure.py create mode 100644 src/bioxelnodes/panels.py delete mode 100644 tests/__init__.py diff --git a/.gitignore b/.gitignore index d5ad8a9..30b51f1 100644 --- a/.gitignore +++ b/.gitignore @@ -125,14 +125,14 @@ dmypy.json pythonlib* -*.blend1 - -blendcache_* - *.ipynb .secrets - .vdb -!scipy_ndimage/*/** \ No newline at end of file +!scipy_ndimage/*/** + +# Blender +*.blend1 +blendcache_* +*.cats.txt~ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d409372 --- /dev/null +++ b/AGENTS.md @@ -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 # 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 \ No newline at end of file diff --git a/build.py b/build.py index d7bc1cb..f0d83c4 100644 --- a/build.py +++ b/build.py @@ -17,7 +17,9 @@ class Platform: "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", diff --git a/pack.py b/pack.py new file mode 100644 index 0000000..5e72506 --- /dev/null +++ b/pack.py @@ -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 [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 [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() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index fefa9cc..05dec56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/bioxelnodes/__init__.py b/src/bioxelnodes/__init__.py index 1d34f90..9547761 100644 --- a/src/bioxelnodes/__init__.py +++ b/src/bioxelnodes/__init__.py @@ -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() diff --git a/src/bioxelnodes/assets/Nodes/BioxelNodes_latest.blend b/src/bioxelnodes/assets/Nodes/BioxelNodes_latest.blend deleted file mode 100644 index 57528b7..0000000 --- a/src/bioxelnodes/assets/Nodes/BioxelNodes_latest.blend +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a76a54a0ec3a2a279e6cc06288975c23ca0cb999dafd0d3fd8842ec3e310fc41 -size 9057934 diff --git a/src/bioxelnodes/assets/Nodes/BioxelNodes_v0.2.9.blend b/src/bioxelnodes/assets/Nodes/BioxelNodes_v0.2.9.blend deleted file mode 100644 index 016c4cf..0000000 --- a/src/bioxelnodes/assets/Nodes/BioxelNodes_v0.2.9.blend +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b219a6f005718d8223766215a598ebde9b646c3960fb298d72ffc864791f3c3a -size 6691383 diff --git a/src/bioxelnodes/assets/Nodes/BioxelNodes_v0.3.3.blend b/src/bioxelnodes/assets/Nodes/BioxelNodes_v0.3.3.blend deleted file mode 100644 index 595e09b..0000000 --- a/src/bioxelnodes/assets/Nodes/BioxelNodes_v0.3.3.blend +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:87831f56b9d23b8ee3eccfea02c27e8ff305723a079e2c115a7b059fb5e1cb24 -size 6803344 diff --git a/src/bioxelnodes/assets/O Bioxel/BioxelNodes.blend b/src/bioxelnodes/assets/O Bioxel/BioxelNodes.blend new file mode 100644 index 0000000..5db983f --- /dev/null +++ b/src/bioxelnodes/assets/O Bioxel/BioxelNodes.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c0cdfc0e9a37d0e559691fd8893697e1236ddbf3d31b6165ea72e7dcffada2f +size 14083935 diff --git a/src/bioxelnodes/assets/O Bioxel/blender_assets.cats.txt b/src/bioxelnodes/assets/O Bioxel/blender_assets.cats.txt new file mode 100644 index 0000000..80c7c80 --- /dev/null +++ b/src/bioxelnodes/assets/O Bioxel/blender_assets.cats.txt @@ -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 diff --git a/src/bioxelnodes/auto_load.py b/src/bioxelnodes/auto_load.py index 93501f4..d0dedda 100644 --- a/src/bioxelnodes/auto_load.py +++ b/src/bioxelnodes/auto_load.py @@ -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 diff --git a/src/bioxelnodes/bioxel/layer.py b/src/bioxelnodes/bioxel/layer.py index 4837b51..d9449df 100644 --- a/src/bioxelnodes/bioxel/layer.py +++ b/src/bioxelnodes/bioxel/layer.py @@ -78,8 +78,8 @@ def fill(self, value: float, mask: np.ndarray, smooth: int = 0): 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 @@ def fill(self, value: float, mask: np.ndarray, smooth: int = 0): 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 @@ def resize(self, shape: tuple, smooth: int = 0, progress_callback=None): 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 diff --git a/src/bioxelnodes/bioxel/scipy/_filters.py b/src/bioxelnodes/bioxel/scipy/_filters.py index 3bbd8ef..1e54ff4 100644 --- a/src/bioxelnodes/bioxel/scipy/_filters.py +++ b/src/bioxelnodes/bioxel/scipy/_filters.py @@ -39,7 +39,6 @@ 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 derivative2(input, axis, output, mode, cval): 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 derivative2(input, axis, output, mode, cval, sigma, **kwargs): 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 \ No newline at end of file + return output diff --git a/src/bioxelnodes/bioxel/scipy/_interpolation.py b/src/bioxelnodes/bioxel/scipy/_interpolation.py index 0384130..0479344 100644 --- a/src/bioxelnodes/bioxel/scipy/_interpolation.py +++ b/src/bioxelnodes/bioxel/scipy/_interpolation.py @@ -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'): """ diff --git a/src/bioxelnodes/bioxel/scipy/_ni_support.py b/src/bioxelnodes/bioxel/scipy/_ni_support.py index ae8875f..c312772 100644 --- a/src/bioxelnodes/bioxel/scipy/_ni_support.py +++ b/src/bioxelnodes/bioxel/scipy/_ni_support.py @@ -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): diff --git a/src/bioxelnodes/bioxel/scipy/_utils.py b/src/bioxelnodes/bioxel/scipy/_utils.py index c397ef4..5a4b9be 100644 --- a/src/bioxelnodes/bioxel/scipy/_utils.py +++ b/src/bioxelnodes/bioxel/scipy/_utils.py @@ -7,4 +7,4 @@ def normalize_axis_index(axis, ndim): if axis < 0: axis = axis + ndim - return axis \ No newline at end of file + return axis diff --git a/src/bioxelnodes/bioxel/skimage/_warps.py b/src/bioxelnodes/bioxel/skimage/_warps.py index 992e38b..f8f4a4f 100644 --- a/src/bioxelnodes/bioxel/skimage/_warps.py +++ b/src/bioxelnodes/bioxel/skimage/_warps.py @@ -3,6 +3,7 @@ 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. diff --git a/src/bioxelnodes/bioxel/skimage/dtype.py b/src/bioxelnodes/bioxel/skimage/dtype.py index 0b69b7b..07bc3b4 100644 --- a/src/bioxelnodes/bioxel/skimage/dtype.py +++ b/src/bioxelnodes/bioxel/skimage/dtype.py @@ -39,7 +39,8 @@ 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( diff --git a/src/bioxelnodes/bioxelutils/__init__.py b/src/bioxelnodes/bioxelutils/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/bioxelnodes/bioxelutils/common.py b/src/bioxelnodes/bioxelutils/common.py deleted file mode 100644 index b2bb850..0000000 --- a/src/bioxelnodes/bioxelutils/common.py +++ /dev/null @@ -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 diff --git a/src/bioxelnodes/bioxelutils/container.py b/src/bioxelnodes/bioxelutils/container.py deleted file mode 100644 index 59c0b1a..0000000 --- a/src/bioxelnodes/bioxelutils/container.py +++ /dev/null @@ -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 diff --git a/src/bioxelnodes/bioxelutils/layer.py b/src/bioxelnodes/bioxelutils/layer.py deleted file mode 100644 index f7a0525..0000000 --- a/src/bioxelnodes/bioxelutils/layer.py +++ /dev/null @@ -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 diff --git a/src/bioxelnodes/bioxelutils/node.py b/src/bioxelnodes/bioxelutils/node.py deleted file mode 100644 index 57707db..0000000 --- a/src/bioxelnodes/bioxelutils/node.py +++ /dev/null @@ -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 diff --git a/src/bioxelnodes/blender_manifest.toml b/src/bioxelnodes/blender_manifest.toml index 09239a8..d982f0d 100644 --- a/src/bioxelnodes/blender_manifest.toml +++ b/src/bioxelnodes/blender_manifest.toml @@ -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 " @@ -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" \ No newline at end of file +files = "Import/export volume data from/to disk" diff --git a/src/bioxelnodes/constants.py b/src/bioxelnodes/constants.py index ed6a0dd..322b703 100644 --- a/src/bioxelnodes/constants.py +++ b/src/bioxelnodes/constants.py @@ -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() - -LATEST_NODE_LIB_PATH = lib_path = Path(NODE_LIB_DIRPATH, - "BioxelNodes_latest.blend").resolve() - -COMPONENT_OUTPUT_NODES = [ - "CutoutByThreshold", - "CutoutByRange", - "CutoutByHue", - "JoinComponent", - "SetProperties", - "SetColor", - "SetColorByColor", - "SetColorByColor", - "SetColorByRamp2", - "SetColorByRamp3", - "SetColorByRamp4", - "SetColorByRamp5", - "Cut", -] + "assets/O Bioxel").resolve() -MENU_ITEMS = [ - { - 'label': 'Component', - 'icon': 'OUTLINER_DATA_VOLUME', - 'items': [ - { - 'label': 'Cutout by Threshold', - 'icon': 'EMPTY_SINGLE_ARROW', - 'name': 'CutoutByThreshold', - 'description': '' - }, - { - 'label': 'Cutout by Range', - 'icon': 'IPO_CONSTANT', - 'name': 'CutoutByRange', - 'description': '' - }, - { - 'label': 'Cutout by 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': '' - }, +LATEST_NODE_LIB_PATH = Path(NODE_LIB_DIRPATH, + "BioxelNodes.blend").resolve() - ] - }, - { - '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 = {} diff --git a/src/bioxelnodes/exceptions.py b/src/bioxelnodes/exceptions.py index d2de041..c0b8cb2 100644 --- a/src/bioxelnodes/exceptions.py +++ b/src/bioxelnodes/exceptions.py @@ -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 diff --git a/src/bioxelnodes/layer.py b/src/bioxelnodes/layer.py new file mode 100644 index 0000000..c6fb53d --- /dev/null +++ b/src/bioxelnodes/layer.py @@ -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 /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//. + - 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 diff --git a/src/bioxelnodes/menus.py b/src/bioxelnodes/menus.py index 7934fd5..18e1afa 100644 --- a/src/bioxelnodes/menus.py +++ b/src/bioxelnodes/menus.py @@ -1,187 +1,40 @@ import bpy - -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.structure import ExtractMesh + +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 @@ def draw(self, context): 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.operator(Help.bl_idname, icon=Help.bl_icon) - 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" +class NodeContextMenu(bpy.types.Menu): + bl_idname = "BIOXEL_MT_NODE_CONTEXT" 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(ExtractMesh.bl_idname) -class NodeContextMenu(bpy.types.Menu): - bl_idname = "BIOXELNODES_MT_NODE_CONTEXT" +class ObjectContextMenu(bpy.types.Menu): + bl_idname = "BIOXEL_MT_OBJECT_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.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) - - 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) + 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) diff --git a/src/bioxelnodes/node.py b/src/bioxelnodes/node.py new file mode 100644 index 0000000..566bee6 --- /dev/null +++ b/src/bioxelnodes/node.py @@ -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 diff --git a/src/bioxelnodes/node_menu.py b/src/bioxelnodes/node_menu.py deleted file mode 100644 index f64cd5b..0000000 --- a/src/bioxelnodes/node_menu.py +++ /dev/null @@ -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) diff --git a/src/bioxelnodes/operators/container.py b/src/bioxelnodes/operators/container.py deleted file mode 100644 index d4dad97..0000000 --- a/src/bioxelnodes/operators/container.py +++ /dev/null @@ -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'} diff --git a/src/bioxelnodes/operators/io.py b/src/bioxelnodes/operators/io.py index 0fce9cf..96e1b5f 100644 --- a/src/bioxelnodes/operators/io.py +++ b/src/bioxelnodes/operators/io.py @@ -1,145 +1,168 @@ import math -import bpy import shutil import threading -import numpy as np from pathlib import Path +import bpy +import numpy as np +import SimpleITK as sitk +import transforms3d -from ..exceptions import CancelledByUser -from ..props import BIOXELNODES_Series -from ..bioxelutils.common import (get_all_layer_objs, get_container_obj, - get_layer_obj, is_incompatible) -from ..bioxelutils.container import (Container, - add_layers, - container_to_obj) -from ..bioxel.layer import Layer -from ..bioxel.parse import (DICOM_EXTS, SUPPORT_EXTS, - get_ext, parse_volumetric_data) +# KeyboardInterrupt replaced with built-in KeyboardInterrupt +from ..props import BIOXEL_Series +from ..utils import get_layer_obj, wrapped_label -from ..utils import (get_cache_dir, progress_update, progress_bar, - select_object) +from ..bioxel.layer import Layer +from ..bioxel.parse import DICOM_EXTS, SUPPORT_EXTS, get_ext, parse_volumetric_data -# 3rd-party -import SimpleITK as sitk -import transforms3d +from ..utils import get_cache_dir, progress_update, progress_bar +from ..layer import get_layer_caches, save_layers_to_json def get_layer_shape(bioxel_size: float, orig_shape: tuple, orig_spacing: tuple): - shape = (int(orig_shape[0] / bioxel_size * orig_spacing[0]), - int(orig_shape[1] / bioxel_size * orig_spacing[1]), - int(orig_shape[2] / bioxel_size * orig_spacing[2])) + shape = ( + int(orig_shape[0] / bioxel_size * orig_spacing[0]), + int(orig_shape[1] / bioxel_size * orig_spacing[1]), + int(orig_shape[2] / bioxel_size * orig_spacing[2]), + ) - return (shape[0] if shape[0] > 0 else 1, - shape[1] if shape[1] > 0 else 1, - shape[2] if shape[2] > 0 else 1) + return ( + shape[0] if shape[0] > 0 else 1, + shape[1] if shape[1] > 0 else 1, + shape[2] if shape[2] > 0 else 1, + ) def get_layer_size(shape: tuple, bioxel_size: float, scale: float = 1.0): - size = (float(shape[0] * bioxel_size * scale), - float(shape[1] * bioxel_size * scale), - float(shape[2] * bioxel_size * scale)) + size = ( + float(shape[0] * bioxel_size * scale), + float(shape[1] * bioxel_size * scale), + float(shape[2] * bioxel_size * scale), + ) return size """ -ImportVolumetricData - -> ParseVolumetricData -> ImportVolumetricDataDialog -FH_ImportVolumetricData - - start import parse data execute import +ImportData -> ParseVolumetricData -> ImportDataDialog + start import parse data execute import """ -class ImportVolumetricData(): - bl_options = {'UNDO'} - +class ImportDataBase: + bl_options = {"UNDO"} filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore + read_as = None - read_as = "SCALAR" + def draw(self, context): + """ + Display helpful information in the file browser's properties region + when the file selector is open for this operator. + """ + layout = self.layout + exts_list = sorted(SUPPORT_EXTS) + layout.label(text="Supported formats:") + chunk_size = 6 # adjust per-line count to taste + for i in range(0, len(exts_list), chunk_size): + layout.label(text=" ".join(exts_list[i : i + chunk_size])) + + layout.separator() + layout.label(text="Tips:") + wrapped_label( + layout, "- Use 'Import Data' button in Bioxel panel to import data" + ) + wrapped_label( + layout, "- Use 'Import as ...' to choose data type (Scalar/Label/Color)" + ) + wrapped_label( + layout, "- If the file contains multiple series, select series after open" + ) def execute(self, context): data_path = Path(self.filepath).resolve() ext = get_ext(data_path) if ext not in SUPPORT_EXTS: self.report({"WARNING"}, "Not supported format.") - return {'CANCELLED'} + return {"CANCELLED"} + + if self.read_as is None: + bpy.ops.bioxel.parse_volumetric_data( + "INVOKE_DEFAULT", filepath=self.filepath, skip_read_as=False + ) + else: - bpy.ops.bioxelnodes.parse_volumetric_data('INVOKE_DEFAULT', - filepath=self.filepath, - skip_read_as=True, - read_as=self.read_as) + bpy.ops.bioxel.parse_volumetric_data( + "INVOKE_DEFAULT", + filepath=self.filepath, + skip_read_as=True, + read_as=self.read_as, + ) - return {'FINISHED'} + return {"FINISHED"} def invoke(self, context, event): context.window_manager.fileselect_add(self) - return {'RUNNING_MODAL'} + return {"RUNNING_MODAL"} -class ImportAsScalar(bpy.types.Operator, ImportVolumetricData): - bl_idname = "bioxelnodes.import_as_scalar" +class ImportData(bpy.types.Operator, ImportDataBase): + bl_idname = "bioxel.import_data" + bl_label = "Import Data" + bl_description = "Import Volumetric Data to Layer" + bl_icon = "EVENT_S" + + +class ImportAsScalar(bpy.types.Operator, ImportDataBase): + bl_idname = "bioxel.import_as_scalar" bl_label = "Import as Scalar" bl_description = "Import Volumetric Data to Container as Scalar" bl_icon = "EVENT_S" read_as = "SCALAR" -class ImportAsLabel(bpy.types.Operator, ImportVolumetricData): - bl_idname = "bioxelnodes.import_as_label" +class ImportAsLabel(bpy.types.Operator, ImportDataBase): + bl_idname = "bioxel.import_as_label" bl_label = "Import as Label" bl_description = "Import Volumetric Data to Container as Label" bl_icon = "EVENT_L" read_as = "LABEL" -class ImportAsColor(bpy.types.Operator, ImportVolumetricData): - bl_idname = "bioxelnodes.import_as_color" +class ImportAsColor(bpy.types.Operator, ImportDataBase): + bl_idname = "bioxel.import_as_color" bl_label = "Import as Color" bl_description = "Import Volumetric Data to Container as Color" bl_icon = "EVENT_C" read_as = "COLOR" -class BIOXELNODES_FH_ImportVolumetricData(bpy.types.FileHandler): - bl_idname = "BIOXELNODES_FH_ImportVolumetricData" +class BIOXELNODES_FH_ImportData(bpy.types.FileHandler): + bl_idname = "BIOXELNODES_FH_ImportData" bl_label = "File handler for dicom import" - bl_import_operator = "bioxelnodes.parse_volumetric_data" + bl_import_operator = "bioxel.parse_volumetric_data" bl_file_extensions = ";".join(SUPPORT_EXTS) @classmethod def poll_drop(cls, context): - if not context.area: - return False - - if context.area.type == 'VIEW_3D': - return True - elif context.area.type == 'NODE_EDITOR': - container_obj = get_container_obj(context.object) - return container_obj is not None - else: - return False + return ( + bpy.context.area.type == "NODE_EDITOR" + and bpy.context.space_data.node_tree.bl_idname == "GeometryNodeTree" + ) def get_series_ids(self, context): items = [] for index, series_id in enumerate(self.series_ids): - items.append(( - series_id.id, - series_id.label, - "", - index - )) + items.append((series_id.id, series_id.label, "", index)) return items class ParseVolumetricData(bpy.types.Operator): - bl_idname = "bioxelnodes.parse_volumetric_data" + bl_idname = "bioxel.parse_volumetric_data" bl_label = "Import Volumetric Data (BioxelNodes)" bl_description = "Import Volumetric Data as Layer" - bl_options = {'UNDO'} + bl_options = {"UNDO"} meta = None label_count = 0 @@ -147,62 +170,53 @@ class ParseVolumetricData(bpy.types.Operator): thread = None _timer = None - progress: bpy.props.FloatProperty(name="Progress", - options={"SKIP_SAVE"}, - default=1) # type: ignore + progress: bpy.props.FloatProperty( + name="Progress", options={"SKIP_SAVE"}, default=1 + ) # type: ignore filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore - skip_read_as: bpy.props.BoolProperty(name="Skip Read As", - default=False, - options={"SKIP_SAVE"}) # type: ignore + skip_read_as: bpy.props.BoolProperty( + name="Skip Read As", default=False, options={"SKIP_SAVE"} + ) # type: ignore - skip_series_select: bpy.props.BoolProperty(name="Skip Sries Select", - default=True, - options={"SKIP_SAVE"}) # type: ignore + skip_series_select: bpy.props.BoolProperty( + name="Skip Sries Select", default=True, options={"SKIP_SAVE"} + ) # type: ignore - read_as: bpy.props.EnumProperty(name="Read as", - default="SCALAR", - items=[("SCALAR", "Scalar", ""), - ("LABEL", "Label", ""), - ("COLOR", "Color", "")]) # type: ignore + read_as: bpy.props.EnumProperty( + name="Read as", + default="SCALAR", + items=[ + ("SCALAR", "Scalar", ""), + ("LABEL", "Label", ""), + ("COLOR", "Color", ""), + ], + ) # type: ignore - series_id: bpy.props.EnumProperty(name="Select Series", - items=get_series_ids) # type: ignore + series_id: bpy.props.EnumProperty( + name="Select Series", items=get_series_ids + ) # type: ignore - series_ids: bpy.props.CollectionProperty( - type=BIOXELNODES_Series) # type: ignore + series_ids: bpy.props.CollectionProperty(type=BIOXEL_Series) # type: ignore def execute(self, context): - if is_incompatible(): - self.report({"ERROR"}, - "Current addon verison is not compatible to this file. If you insist on editing this file please keep the same addon version") - return {'CANCELLED'} - - if not self.filepath: - self.report({"WARNING"}, "No file selected.") - return {'CANCELLED'} - - data_path = Path(self.filepath).resolve() - ext = get_ext(data_path) - if ext not in SUPPORT_EXTS: - self.report({"WARNING"}, "Not supported format.") - return {'CANCELLED'} - print("Collecting Meta Data...") def parse_volumetric_data_func(self, context, cancel): def progress_callback(factor, text): if cancel(): - raise CancelledByUser + raise KeyboardInterrupt("Cancelled by user") progress_update(context, factor, text) try: series_id = self.series_id if self.series_id != "empty" else "" - data, meta = parse_volumetric_data(data_file=self.filepath, - series_id=series_id, - progress_callback=progress_callback) - except CancelledByUser: + data, meta = parse_volumetric_data( + data_file=self.filepath, + series_id=series_id, + progress_callback=progress_callback, + ) + except KeyboardInterrupt: return except Exception as e: self.has_error = e @@ -220,38 +234,41 @@ def progress_callback(factor, text): self.has_error = None # Create the thread - self.thread = threading.Thread(target=parse_volumetric_data_func, - args=(self, context, lambda: self.is_cancelled)) + self.thread = threading.Thread( + target=parse_volumetric_data_func, + args=(self, context, lambda: self.is_cancelled), + ) # Start the thread self.thread.start() # Add a timmer for modal - self._timer = context.window_manager.event_timer_add(time_step=0.1, - window=context.window) + self._timer = context.window_manager.event_timer_add( + time_step=0.1, window=context.window + ) # Append progress bar to status bar bpy.types.STATUSBAR_HT_header.append(progress_bar) # Start modal handler context.window_manager.modal_handler_add(self) - return {'RUNNING_MODAL'} + return {"RUNNING_MODAL"} def modal(self, context, event): # Check if user press 'ESC' - if event.type == 'ESC': + if event.type == "ESC": self.is_cancelled = True progress_update(context, 0.0, "Canceling...") - return {'PASS_THROUGH'} + return {"PASS_THROUGH"} # Check if is the timer time - if event.type != 'TIMER': - return {'PASS_THROUGH'} + if event.type != "TIMER": + return {"PASS_THROUGH"} # Force update status bar bpy.context.workspace.status_text_set_internal(None) # Check if thread is still running if self.thread.is_alive(): - return {'PASS_THROUGH'} + return {"PASS_THROUGH"} # Release the thread self.thread.join() @@ -264,7 +281,7 @@ def modal(self, context, event): # Check if thread is cancelled by user if self.is_cancelled: self.report({"WARNING"}, "Canncelled by user.") - return {'CANCELLED'} + return {"CANCELLED"} # Check if thread is cancelled by user if self.has_error: @@ -273,7 +290,7 @@ def modal(self, context, event): # Check if has return if self.meta is None: self.report({"ERROR"}, "Some thing went wrong.") - return {'CANCELLED'} + return {"CANCELLED"} # If not canncelled... for key, value in self.meta.items(): @@ -282,14 +299,14 @@ def modal(self, context, event): if self.read_as == "LABEL": if self.label_count > 100 or self.dtype.kind not in ["i", "u"]: self.report({"ERROR"}, "Invaild label data.") - return {'CANCELLED'} + return {"CANCELLED"} if self.label_count == 0: self.report({"ERROR"}, "Get no label.") - return {'CANCELLED'} + return {"CANCELLED"} - orig_shape = self.meta['xyz_shape'] - orig_spacing = self.meta['spacing'] + orig_shape = self.meta["xyz_shape"] + orig_spacing = self.meta["spacing"] min_log10 = math.floor(math.log10(min(*orig_spacing))) max_log10 = math.floor(math.log10(max(*orig_spacing))) @@ -297,82 +314,75 @@ def modal(self, context, event): # max_space = max(*orig_spacing) if orig_spacing[2] == 1 and min_log10 < -1: - orig_spacing = (orig_spacing[0] * math.pow(10, -min_log10-2), - orig_spacing[1] * math.pow(10, -min_log10-2), - 1) + orig_spacing = ( + orig_spacing[0] * math.pow(10, -min_log10 - 2), + orig_spacing[1] * math.pow(10, -min_log10 - 2), + 1, + ) elif min_log10 > 0: - orig_spacing = (orig_spacing[0] * math.pow(10, -min_log10-1), - orig_spacing[1] * math.pow(10, -min_log10-1), - orig_spacing[2] * math.pow(10, -min_log10-1)) + orig_spacing = ( + orig_spacing[0] * math.pow(10, -min_log10 - 1), + orig_spacing[1] * math.pow(10, -min_log10 - 1), + orig_spacing[2] * math.pow(10, -min_log10 - 1), + ) elif max_log10 < 0: - orig_spacing = (orig_spacing[0] * math.pow(10, -max_log10-1), - orig_spacing[1] * math.pow(10, -max_log10-1), - orig_spacing[2] * math.pow(10, -max_log10-1)) + orig_spacing = ( + orig_spacing[0] * math.pow(10, -max_log10 - 1), + orig_spacing[1] * math.pow(10, -max_log10 - 1), + orig_spacing[2] * math.pow(10, -max_log10 - 1), + ) bioxel_size = max(min(*orig_spacing), 1.0) - layer_shape = get_layer_shape(bioxel_size, - orig_shape, - orig_spacing) - layer_size = get_layer_size(layer_shape, - bioxel_size, - 0.01) + layer_shape = get_layer_shape(bioxel_size, orig_shape, orig_spacing) + layer_size = get_layer_size(layer_shape, bioxel_size, 0.01) min_log10 = math.floor(math.log10(min(*layer_size))) max_log10 = math.floor(math.log10(max(*layer_size))) if min_log10 > 0: - scene_scale = math.pow(10, -min_log10-2) + scene_scale = math.pow(10, -min_log10 - 2) elif max_log10 < 0: - scene_scale = math.pow(10, -max_log10-2) + scene_scale = math.pow(10, -max_log10 - 2) else: scene_scale = 0.01 - if context.area.type == "NODE_EDITOR": - container_obj = context.object - container_name = container_obj.name - container_obj_name = container_name - else: - container_name = self.meta['name'] - container_obj_name = "" - series_id = self.series_id if self.series_id != "empty" else "" - bpy.ops.bioxelnodes.import_volumetric_data_dialog( - 'INVOKE_DEFAULT', + bpy.ops.bioxel.import_volumetric_data_dialog( + "INVOKE_DEFAULT", filepath=self.filepath, - container_name=container_name, - layer_name=self.meta['description'], + layer_name=self.meta["description"], orig_shape=orig_shape, orig_spacing=orig_spacing, bioxel_size=bioxel_size, series_id=series_id, - frame_count=self.meta['frame_count'], - channel_count=self.meta['channel_count'], - container_obj_name=container_obj_name, + frame_count=self.meta["frame_count"], + channel_count=self.meta["channel_count"], read_as=self.read_as, label_count=self.label_count, - scene_scale=scene_scale + scene_scale=scene_scale, ) self.report({"INFO"}, "Successfully Readed.") - return {'FINISHED'} + return {"FINISHED"} def invoke(self, context, event): # why not report in execute? # If this operator is executing, a new execute will when pre-one done. - if context.window_manager.bioxelnodes_progress_factor < 1: + if context.window_manager.bioxel_progress_factor < 1: print("A process is executing, please wait for it to finish.") - return {'CANCELLED'} + return {"CANCELLED"} if not self.filepath: - return self.execute(context) + self.report({"WARNING"}, "No file selected.") + return {"CANCELLED"} data_path = Path(self.filepath).resolve() ext = get_ext(data_path) + if ext not in SUPPORT_EXTS: + self.report({"WARNING"}, "Not supported format.") + return {"CANCELLED"} - if context.area.type == "NODE_EDITOR": - title = f"Add to **{context.object.name}**" - else: - title = "Init a Container" + title = f"Add to **{context.object.name}**" # Series Selection if ext in DICOM_EXTS: @@ -386,7 +396,8 @@ def invoke(self, context, event): for series_id in series_ids: series_files = reader.GetGDCMSeriesFileNames( - str(data_dirpath), series_id) + str(data_dirpath), series_id + ) single = sitk.ImageFileReader() single.SetFileName(series_files[0]) single.LoadPrivateTagsOn() @@ -395,10 +406,12 @@ def invoke(self, context, event): def get_meta(key): try: stirng = single.GetMetaData(key).removesuffix(" ") - stirng.encode('utf-8') - if stirng in ["No study description", - "No series description", - ""]: + stirng.encode("utf-8") + if stirng in [ + "No study description", + "No series description", + "", + ]: return "Unknown" else: return stirng @@ -421,8 +434,10 @@ def get_meta(key): if series_id == "": series_id = "empty" - label = "{:<20} {:>1}".format(f"{study_description}>{series_description}({series_modality})", - f"({size_x}x{size_y})x{count}") + label = "{:<20} {:>1}".format( + f"{study_description}>{series_description}({series_modality})", + f"({size_x}x{size_y})x{count}", + ) series_items[series_id] = label @@ -433,32 +448,27 @@ def get_meta(key): if len(series_items.keys()) > 1: self.skip_series_select = False - context.window_manager.invoke_props_dialog(self, - width=400, - title=title) - return {'RUNNING_MODAL'} + context.window_manager.invoke_props_dialog(self, width=400, title=title) + return {"RUNNING_MODAL"} elif len(series_items.keys()) == 1: self.series_id = list(series_items.keys())[0] else: self.report({"ERROR"}, "Get no vaild series.") - return {'CANCELLED'} + return {"CANCELLED"} if self.skip_read_as: return self.execute(context) else: - context.window_manager.invoke_props_dialog(self, - width=400, - title=title) - return {'RUNNING_MODAL'} + context.window_manager.invoke_props_dialog(self, width=400, title=title) + return {"RUNNING_MODAL"} def draw(self, context): layout = self.layout if not self.skip_read_as: - layout.label( - text='What kind is the data read as?') + layout.label(text="What kind is the data read as?") layout.prop(self, "read_as") if not self.skip_series_select: - layout.label(text='Which series to import?') + layout.label(text="Which series to import?") layout.prop(self, "series_id") @@ -477,66 +487,63 @@ def get_frame_sources(self, context): return items -class ImportVolumetricDataDialog(bpy.types.Operator): - bl_idname = "bioxelnodes.import_volumetric_data_dialog" +class ImportDataDialog(bpy.types.Operator): + bl_idname = "bioxel.import_volumetric_data_dialog" bl_label = "Import Volumetric Data" bl_description = "Import Volumetric Data as Layer" - bl_options = {'UNDO'} + bl_options = {"UNDO"} layers = None thread = None _timer = None filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore - - container_name: bpy.props.StringProperty( - name="Container Name") # type: ignore - - layer_name: bpy.props.StringProperty(name="Layer Name") # type: ignore - - series_id: bpy.props.StringProperty() # type: ignore - - container_obj_name: bpy.props.StringProperty() # type: ignore - + layer_name: bpy.props.StringProperty(name="Layer Name") # type: ignore + series_id: bpy.props.StringProperty() # type: ignore frame_count: bpy.props.IntProperty() # type: ignore - channel_count: bpy.props.IntProperty() # type: ignore - label_count: bpy.props.IntProperty() # type: ignore - - smooth: bpy.props.IntProperty(name="Smooth Size (Larger takes longer time)", - default=0) # type: ignore - - read_as: bpy.props.EnumProperty(name="Read as", - default="SCALAR", - items=[("SCALAR", "Scalar", ""), - ("LABEL", "Label", ""), - ("COLOR", "Color", "")]) # type: ignore - - bioxel_size: bpy.props.FloatProperty(name="Bioxel Size (Larger size means small resolution)", - soft_min=0.1, soft_max=10.0, - min=0.1, max=1e2, - default=1) # type: ignore - - orig_spacing: bpy.props.FloatVectorProperty(name="Original Spacing", - default=(1, 1, 1)) # type: ignore - - orig_shape: bpy.props.IntVectorProperty(name="Original Shape", - default=(100, 100, 100)) # type: ignore - - scene_scale: bpy.props.FloatProperty(name="Scene Scale (Bioxel Unit pre Blender Unit)", - soft_min=0.0001, soft_max=10.0, - min=1e-6, max=1e6, - default=0.01) # type: ignore - - remap: bpy.props.BoolProperty(name="Remap to 0~1", - default=False) # type: ignore - - split_channel: bpy.props.BoolProperty(name="Split Channels", - default=False) # type: ignore - - frame_source: bpy.props.EnumProperty(name="Frame From", - items=get_frame_sources) # type: ignore + smooth: bpy.props.IntProperty( + name="Smooth Size (Larger takes longer time)", default=0 + ) # type: ignore + read_as: bpy.props.EnumProperty( + name="Read as", + default="SCALAR", + items=[ + ("SCALAR", "Scalar", ""), + ("LABEL", "Label", ""), + ("COLOR", "Color", ""), + ], + ) # type: ignore + bioxel_size: bpy.props.FloatProperty( + name="Bioxel Size (Larger size means small resolution)", + soft_min=0.1, + soft_max=10.0, + min=0.1, + max=1e2, + default=1, + ) # type: ignore + orig_spacing: bpy.props.FloatVectorProperty( + name="Original Spacing", default=(1, 1, 1) + ) # type: ignore + orig_shape: bpy.props.IntVectorProperty( + name="Original Shape", default=(100, 100, 100) + ) # type: ignore + scene_scale: bpy.props.FloatProperty( + name="Scene Scale (Bioxel Unit pre Blender Unit)", + soft_min=0.0001, + soft_max=10.0, + min=1e-6, + max=1e6, + default=0.01, + ) # type: ignore + remap: bpy.props.BoolProperty(name="Remap to 0~1", default=False) # type: ignore + split_channel: bpy.props.BoolProperty( + name="Split Channels", default=False + ) # type: ignore + frame_source: bpy.props.EnumProperty( + name="Frame From", items=get_frame_sources + ) # type: ignore def execute(self, context): def import_volumetric_data_func(self, context, cancel): @@ -544,14 +551,32 @@ def import_volumetric_data_func(self, context, cancel): def progress_callback(factor, text): if cancel(): - raise CancelledByUser - progress_update(context, factor*0.2, text) + raise KeyboardInterrupt("Cancelled by user") + progress_update(context, factor * 0.2, text) + + def progress_callback_factory(layer_name, progress, progress_step): + def progress_callback(frame, total): + if cancel(): + raise KeyboardInterrupt("Cancelled by user") + sub_progress_step = progress_step / total + sub_progress = progress + frame * sub_progress_step + progress_update( + context, + sub_progress, + f"Processing {layer_name} Frame {frame+1}...", + ) + print(f"Processing {layer_name} Frame {frame+1}...") + + return progress_callback try: - data, meta = parse_volumetric_data(data_file=self.filepath, - series_id=self.series_id, - progress_callback=progress_callback) - except CancelledByUser: + data, meta = parse_volumetric_data( + data_file=self.filepath, + series_id=self.series_id, + progress_callback=progress_callback, + ) + + except KeyboardInterrupt: return except Exception as e: self.has_error = e @@ -560,12 +585,12 @@ def progress_callback(factor, text): if cancel(): return - shape = get_layer_shape(self.bioxel_size, - self.orig_shape, - self.orig_spacing) + shape = get_layer_shape( + self.bioxel_size, self.orig_shape, self.orig_spacing + ) mat_scale = transforms3d.zooms.zfdir2aff(self.bioxel_size) - affine = np.dot(meta['affine'], mat_scale) + affine = np.dot(meta["affine"], mat_scale) kind = self.read_as.lower() if cancel(): @@ -593,51 +618,39 @@ def progress_callback(factor, text): # channel as frame data = data.transpose(4, 1, 2, 3, 0) - def progress_callback_factory(layer_name, progress, progress_step): - def progress_callback(frame, total): - if cancel(): - raise CancelledByUser - sub_progress_step = progress_step/total - sub_progress = progress + frame * sub_progress_step - progress_update(context, sub_progress, - f"Processing {layer_name} Frame {frame+1}...") - print(f"Processing {layer_name} Frame {frame+1}...") - return progress_callback - layers = [] if kind == "label": name = self.layer_name or "Label" data = data.astype(int) label_count = int(np.max(data)) - progress_step = 0.7/label_count + progress_step = 0.7 / label_count for i in range(label_count): if cancel(): return name_i = f"{name}_{i+1}" - progress = 0.2+i*progress_step - progress_update(context, progress, - f"Processing {name_i}...") - - progress_callback = progress_callback_factory(name_i, - progress, - progress_step) - label_data = data == np.full_like(data, i+1) + progress = 0.2 + i * progress_step + progress_update(context, progress, f"Processing {name_i}...") + + progress_callback = progress_callback_factory( + name_i, progress, progress_step + ) + label_data = data == np.full_like(data, i + 1) # label_data = label_data.astype(np.float32) try: - layer = Layer(data=label_data, - name=name_i, - kind=kind) + layer = Layer(data=label_data, name=name_i, kind=kind) - layer.resize(shape=shape, - smooth=self.smooth, - progress_callback=progress_callback) + layer.resize( + shape=shape, + smooth=self.smooth, + progress_callback=progress_callback, + ) layer.affine = affine layers.append(layer) - except CancelledByUser: + except KeyboardInterrupt: return except Exception as e: self.has_error = e @@ -645,9 +658,8 @@ def progress_callback(frame, total): if kind == "color": if np.issubdtype(np.uint8, data.dtype): - data = np.multiply(data, 1.0 / 256, - dtype=np.float32) - elif data.dtype.kind in ['u', 'i']: + data = np.multiply(data, 1.0 / 256, dtype=np.float32) + elif data.dtype.kind in ["u", "i"]: # Convert the normalized array to float dtype data = data.astype(np.float32) @@ -681,24 +693,18 @@ def progress_callback(frame, total): if cancel(): return - progress_update(context, 0.2, - f"Processing {name}...") - progress_callback = progress_callback_factory(name, - 0.2, - 0.7) + progress_update(context, 0.2, f"Processing {name}...") + progress_callback = progress_callback_factory(name, 0.2, 0.7) try: - layer = Layer(data=data, - name=name, - kind=kind) + layer = Layer(data=data, name=name, kind=kind) - layer.resize(shape=shape, - progress_callback=progress_callback) + layer.resize(shape=shape, progress_callback=progress_callback) layer.affine = affine layers.append(layer) - except CancelledByUser: + except KeyboardInterrupt: return except Exception as e: self.has_error = e @@ -722,31 +728,31 @@ def progress_callback(frame, total): data = np.zeros_like(data, dtype=np.float32) if self.split_channel: - progress_step = 0.7/self.channel_count + progress_step = 0.7 / self.channel_count for i in range(self.channel_count): if cancel(): return name_i = f"{name}_{i+1}" - progress = 0.2 + i*progress_step - progress_update(context, progress, - f"Processing {name_i}...") - progress_callback = progress_callback_factory(name_i, - progress, - progress_step) + progress = 0.2 + i * progress_step + progress_update(context, progress, f"Processing {name_i}...") + progress_callback = progress_callback_factory( + name_i, progress, progress_step + ) try: - layer = Layer(data=data[:, :, :, :, i:i+1], - name=name_i, - kind=kind) + layer = Layer( + data=data[:, :, :, :, i : i + 1], name=name_i, kind=kind + ) - layer.resize(shape=shape, - progress_callback=progress_callback) + layer.resize( + shape=shape, progress_callback=progress_callback + ) layer.affine = affine layers.append(layer) - except CancelledByUser: + except KeyboardInterrupt: return except Exception as e: self.has_error = e @@ -755,24 +761,18 @@ def progress_callback(frame, total): if cancel(): return - progress_update(context, 0.2, - f"Processing {name}...") - progress_callback = progress_callback_factory(name, - 0.2, - 0.7) + progress_update(context, 0.2, f"Processing {name}...") + progress_callback = progress_callback_factory(name, 0.2, 0.7) try: - layer = Layer(data=data, - name=name, - kind=kind) + layer = Layer(data=data, name=name, kind=kind) - layer.resize(shape=shape, - progress_callback=progress_callback) + layer.resize(shape=shape, progress_callback=progress_callback) layer.affine = affine layers.append(layer) - except CancelledByUser: + except KeyboardInterrupt: return except Exception as e: self.has_error = e @@ -787,29 +787,32 @@ def progress_callback(frame, total): self.is_cancelled = False self.has_error = None - self.thread = threading.Thread(target=import_volumetric_data_func, - args=(self, context, lambda: self.is_cancelled)) + self.thread = threading.Thread( + target=import_volumetric_data_func, + args=(self, context, lambda: self.is_cancelled), + ) self.thread.start() - self._timer = context.window_manager.event_timer_add(time_step=0.1, - window=context.window) + self._timer = context.window_manager.event_timer_add( + time_step=0.1, window=context.window + ) bpy.types.STATUSBAR_HT_header.append(progress_bar) context.window_manager.modal_handler_add(self) - return {'RUNNING_MODAL'} + return {"RUNNING_MODAL"} def modal(self, context, event): - if event.type == 'ESC': + if event.type == "ESC": self.is_cancelled = True progress_update(context, 0.0, "Canceling...") - return {'PASS_THROUGH'} + return {"PASS_THROUGH"} - if event.type != 'TIMER': - return {'PASS_THROUGH'} + if event.type != "TIMER": + return {"PASS_THROUGH"} bpy.context.workspace.status_text_set_internal(None) if self.thread.is_alive(): - return {'PASS_THROUGH'} + return {"PASS_THROUGH"} self.thread.join() context.window_manager.event_timer_remove(self._timer) @@ -818,7 +821,7 @@ def modal(self, context, event): if self.is_cancelled: self.report({"WARNING"}, "Canncelled by user.") - return {'CANCELLED'} + return {"CANCELLED"} # Check if thread is cancelled by user if self.has_error: @@ -827,69 +830,30 @@ def modal(self, context, event): # Check if has return if self.layers is None: self.report({"ERROR"}, "Some thing went wrong.") - return {'CANCELLED'} - - is_first_import = len(get_all_layer_objs()) == 0 - - if self.container_obj_name: - container_obj = bpy.data.objects.get(self.container_obj_name) - if container_obj is None: - raise Exception("Could not find target container") + return {"CANCELLED"} - container_obj = add_layers(self.layers, - container_obj=container_obj, - cache_dir=get_cache_dir()) - else: - name = self.container_name or "Container" - container = Container(name=name, - layers=self.layers) - - step_size = container.layers[0].bioxel_size[0]*10 - container_obj = container_to_obj(container, - scene_scale=self.scene_scale, - step_size=step_size, - cache_dir=get_cache_dir()) + is_first_import = len(get_layer_caches()) == 0 + ids = save_layers_to_json(self.layers, cache_dir=get_cache_dir() / "layers") - select_object(container_obj) + setattr(context.window_manager, "bioxel_layer_library", ids[-1]) # Change render setting for better result if is_first_import: - try: - bpy.ops.bioxelnodes.render_setting_preset('EXEC_DEFAULT', - preset="balance") - bpy.ops.bioxelnodes.add_eevee_env('EXEC_DEFAULT') - bpy.context.scene.eevee.use_taa_reprojection = False - bpy.context.scene.render.engine = 'BLENDER_EEVEE_NEXT' - - 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 = 'RENDERED' - except: - self.report({"WARNING"}, "Fail to change render setting.") + bpy.ops.bioxel.render_setting_preset("EXEC_DEFAULT", preset="balance") self.report({"INFO"}, "Successfully Imported") - return {'FINISHED'} + return {"FINISHED"} def invoke(self, context, event): layer_kind = self.read_as.capitalize() - title = f"Add to **{self.container_obj_name}**, As {layer_kind}" \ - if self.container_obj_name != "" else f"Init a Container, As {layer_kind}" - context.window_manager.invoke_props_dialog(self, - width=500, - title=title) - return {'RUNNING_MODAL'} + title = f"Add to **{context.object.name}**, As {layer_kind}" + context.window_manager.invoke_props_dialog(self, width=500, title=title) + return {"RUNNING_MODAL"} def draw(self, context): - layer_shape = get_layer_shape(self.bioxel_size, - self.orig_shape, - self.orig_spacing) + layer_shape = get_layer_shape( + self.bioxel_size, self.orig_shape, self.orig_spacing + ) orig_shape = tuple(self.orig_shape) @@ -925,24 +889,13 @@ def draw(self, context): channel_count = 3 bioxel_count = layer_shape[0] * layer_shape[1] * layer_shape[2] - orig_shape_text = f"[{self.frame_count}, ({orig_shape[0]},{orig_shape[1]},{orig_shape[2]}), {self.channel_count}]" - layer_shape_text = f"{layer_count} x [{frame_count}, ({layer_shape[0]},{layer_shape[1]},{layer_shape[2]}), {channel_count}]" + orig_shape_text = f"[{self.frame_count}, {orig_shape[0]},{orig_shape[1]},{orig_shape[2]}, {self.channel_count}]" + layer_shape_text = f"{layer_count} x [{frame_count}, {layer_shape[0]},{layer_shape[1]},{layer_shape[2]}, {channel_count}]" if bioxel_count > 100000000: layer_shape_text += "**TOO LARGE!**" - layer_size = get_layer_size(layer_shape, - self.bioxel_size, - self.scene_scale) - layer_size_text = f"Size will be: ({layer_size[0]:.2f}, {layer_size[1]:.2f}, {layer_size[2]:.2f}) m" - layout = self.layout - if self.container_obj_name == "": - panel = layout.box() - panel.prop(self, "container_name") - panel.prop(self, "scene_scale") - panel.label(text=layer_size_text) - panel = layout.box() panel.prop(self, "layer_name") panel.prop(self, "bioxel_size") @@ -951,26 +904,21 @@ def draw(self, context): panel.prop(self, "frame_source") if self.read_as == "SCALAR": - panel.prop(self, "split_channel", - text=f"Split Channel as Multi Layer") + panel.prop(self, "split_channel", text=f"Split Channel as Multi Layer") elif self.read_as == "LABEL": panel.prop(self, "smooth") - panel.label( - text="Dimension Order: [Frame, (X-axis,Y-axis,Z-axis), Channel]") - panel.label( - text=f"Shape from {orig_shape_text} to {layer_shape_text}") + panel.label(text=f"Shape from {orig_shape_text} to {layer_shape_text}") + panel.label(text="Dimension Order: [Frame, X-axis, Y-axis, Z-axis, Channel]") class ExportVolumetricData(bpy.types.Operator): - bl_idname = "bioxelnodes.export_volumetric_data" + bl_idname = "bioxel.export_volumetric_data" bl_label = "Export Layer as VDB" bl_description = "Export Layer as VDB" - bl_options = {'UNDO'} + bl_options = {"UNDO"} - filepath: bpy.props.StringProperty( - subtype="FILE_PATH" - ) # type: ignore + filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore filename_ext = ".vdb" @@ -981,7 +929,7 @@ def poll(cls, context): def invoke(self, context, event): context.window_manager.fileselect_add(self) - return {'RUNNING_MODAL'} + return {"RUNNING_MODAL"} def execute(self, context): layer_obj = get_layer_obj(bpy.context.active_object) @@ -998,4 +946,4 @@ def execute(self, context): self.report({"INFO"}, f"Successfully exported to {output_path}") - return {'FINISHED'} + return {"FINISHED"} diff --git a/src/bioxelnodes/operators/layer.py b/src/bioxelnodes/operators/layer.py index 5cbf71b..6825012 100644 --- a/src/bioxelnodes/operators/layer.py +++ b/src/bioxelnodes/operators/layer.py @@ -1,619 +1,320 @@ from pathlib import Path -import bpy - -import numpy as np - - -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 - - -def get_label_layer_selection(self, context): - items = [("None", "None", "")] - container_obj = get_container_obj(bpy.context.active_object) - - 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 execute(self, context): - if self.layer_obj == None: - self.report({"WARNING"}, "Get no layer.") - return {'FINISHED'} - - if is_missing_layer(self.layer_obj): - self.report({"WARNING"}, "Selected layer is lost.") - return {'FINISHED'} - - bpy.ops.bioxelnodes.add_node('EXEC_DEFAULT', - node_name="FetchLayer", - node_label="Fetch Layer") - node = bpy.context.active_node - - node.inputs[0].default_value = self.layer_obj - node.label = get_layer_label(self.layer_obj) +import shutil - return {"FINISHED"} - - -def get_selected_layers(context, layer_filter=None): - def _layer_filter(layer_obj, context): - return True - - layer_filter = layer_filter or _layer_filter - select_objs = [] - # node_group = context.space_data.edit_tree - for node in context.selected_nodes: - if get_node_type(node) == "BioxelNodes_FetchLayer": - layer_obj = node.inputs[0].default_value - if layer_obj is not None: - if layer_filter(layer_obj, context): - select_objs.append(layer_obj) - - return list(set(select_objs)) +import bpy +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 -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 RenameLayer(bpy.types.Operator): + """Rename a cached Bioxel layer""" -class OutputLayerOperator(): - new_layer_name: bpy.props.StringProperty(name="New Name", - options={"SKIP_SAVE"}) # type: ignore + bl_idname = "bioxel.rename_layer" + bl_label = "Rename Layer" + bl_options = {"REGISTER", "UNDO"} - def operate(self, orig_layer: Layer, context): - """do the operation""" - return orig_layer + cache_id: bpy.props.StringProperty(options={"HIDDEN"}) # type: ignore + new_name: bpy.props.StringProperty( + name="New name", default="") # type: ignore - 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)) + 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): - layer_obj = get_selected_layer(context) - if 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(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 - orig_layer = obj_to_layer(layer_obj) try: - new_layer = self.operate(orig_layer, context) - except NoContent as e: - self.report({"WARNING"}, e.message) - return {'FINISHED'} - - self.add_layer_node(context, new_layer) - return {'FINISHED'} + 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"} -class LayerOperator(): +class DeleteLayer(bpy.types.Operator): + """Delete a saved Bioxel layer""" - def operate(self, layer_obj: bpy.types.Object, context): - """do the operation""" - ... + bl_idname = "bioxel.delete_layer" + bl_label = "Delete Layer" + 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'} - - 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 + cache_id: bpy.props.StringProperty() # type: ignore 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'} - else: - return self.execute(context) - - -class RelocateLayer(bpy.types.Operator): - bl_idname = "bioxelnodes.relocate_layer" - bl_label = "Relocate Layer Cache" - bl_description = "Relocate layer cache" - bl_icon = "FILE" - - filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore + return context.window_manager.invoke_confirm(self, event) def execute(self, context): - layer_obj = get_selected_layer(context) - if layer_obj == None: - self.report({"WARNING"}, "Get no layer.") - return {'FINISHED'} - - self.operate(layer_obj, context) + if not self.cache_id: + self.report({"ERROR"}, "Missing cache id") + return {"CANCELLED"} - return {'FINISHED'} + 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 + ) - 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 + 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"} - set_layer_prop_value(layer_obj, "name", self.new_name) + self.report({"INFO"}, "Layer removed") + return {"FINISHED"} - 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 AddLayerNode(bpy.types.Operator): + """将选中的图层添加到几何节点图""" + bl_idname = "bioxel.add_layer_node" + bl_label = "添加图层节点" + bl_options = {"REGISTER", "UNDO"} -class ResampleLayer(bpy.types.Operator, OutputLayerOperator): - bl_idname = "bioxelnodes.resample_layer" - bl_label = "Resample Value" - bl_description = "Resample value" - bl_icon = "ALIASED" + cache_id: bpy.props.StringProperty() # type: ignore # 图层ID - smooth: bpy.props.IntProperty(name="Smooth Size", - default=0, - soft_min=0, soft_max=5, - options={"SKIP_SAVE"}) # type: ignore + def execute(self, context): + # 获取图层数据 + caches = get_layer_caches() - bioxel_size: bpy.props.FloatProperty( - name="Bioxel Size", - soft_min=0.1, soft_max=10.0, - default=1, - ) # type: ignore + # 查找目标图层 + entry = next( + (c for c in caches if str(c.get("id", "")) == str(self.cache_id)), None + ) - @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) + if not entry: + self.report({"ERROR"}, "Layer not found") + return {"CANCELLED"} - 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'} + # 创建并设置节点属性 + try: + layer_node = add_bioxel_node("O Layer") + except: + self.report({"ERROR"}, "Fail to add layer node") + return {"CANCELLED"} + + 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"] + + 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 -class SignScalar(bpy.types.Operator, OutputLayerOperator): - bl_idname = "bioxelnodes.sign_scalar" - bl_label = "Sign Value" - bl_description = "Sign value" - bl_icon = "REMOVE" + for socket_name in layer_node.outputs.keys(): + if socket_name != entry["kind"].capitalize(): + layer_node.outputs[socket_name].hide = True - 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 + self.report({"INFO"}, f"Successfully created node: {entry['name']}") + return {"FINISHED"} - 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 RelocatePath(bpy.types.Operator): + """Locate and update the missing layer folder, and update all related nodes""" -class FillOperator(OutputLayerOperator): + bl_idname = "bioxel.find_layer_folder" + bl_label = "Relocate" + bl_options = {"REGISTER", "UNDO"} - fill_value: bpy.props.FloatProperty( - name="Fill Value", - soft_min=0, soft_max=1024.0, - default=0, - ) # type: ignore + cache_id: bpy.props.StringProperty(options={"HIDDEN"}) # 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, - ) # 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, + directory: bpy.props.StringProperty( + name="Layer Folder", + description="Select the correct folder for this layer", + subtype="DIR_PATH", ) # 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 invoke(self, context, event): + context.window_manager.fileselect_add(self) + return {"RUNNING_MODAL"} def execute(self, context): - layer_objs = self.get_layers(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"} + + 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"} + + 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 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 - fails = [] - for layer_obj in layer_objs: - try: - self.operate(layer_obj, context) - except: - fails.append(layer_obj) + self.report({"INFO"}, f"Cache path updated: {new_path}") + return {"FINISHED"} - 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 SaveLayer(bpy.types.Operator): + """Copy the entire layer folder (named by id) to a target folder and update its path in the cache""" + bl_idname = "bioxel.cache_layer_to_folder" + bl_label = "Save Layer" + bl_options = {"REGISTER", "UNDO"} -class SaveLayersCache(BatchLayerOperator): - fail_msg = "fail to save." + cache_id: bpy.props.StringProperty(options={"HIDDEN"}) # type: ignore - cache_dir: bpy.props.StringProperty( - name="Cache Directory", - subtype='DIR_PATH', - default="//" + use_relative: bpy.props.BoolProperty( + name="Relative Path", + description="Store the path relative to the current Blender file", + default=True, ) # 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'} + 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_props_dialog(self) - return {'RUNNING_MODAL'} - - -class RemoveLayers(BatchLayerOperator): - fail_msg = "fail to remove." - - def operate(self, layer_obj, context): - for node_group in bpy.data.node_groups: - for node in node_group.nodes: - if get_node_type(node) == "BioxelNodes_FetchLayer": - if node.inputs[0].default_value == layer_obj: - node_group.nodes.remove(node) + # 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"} - cache_filepath = Path(bpy.path.abspath( - layer_obj.data.filepath)).resolve() - - if cache_filepath.is_file(): - if layer_obj.data.is_sequence: - for f in cache_filepath.parent.iterdir(): - f.unlink(missing_ok=True) - else: - cache_filepath.unlink(missing_ok=True) + 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() - # also remove layer object - bpy.data.volumes.remove(layer_obj.data) + try: + if src_dir != dst_dir: + if dst_dir.exists(): + shutil.rmtree(dst_dir) + shutil.copytree(src_dir, dst_dir) - return {'FINISHED'} + # Save as relative path if requested and possible - def invoke(self, context, event): - context.window_manager.invoke_confirm(self, - event, - message=f"Are you sure to remove them?") - return {'RUNNING_MODAL'} + 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 -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" + set_layer_caches(caches) + refresh_bioxel_panels(context) - success_msg = "Successfully saved all selected layers." + # 遍历所有 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 - 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) + 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 RemoveSelectedLayers(bpy.types.Operator, RemoveLayers): - bl_idname = "bioxelnodes.remove_selected_layers" - bl_label = "Remove Selected Layers" - bl_description = "Remove selected layers" - bl_icon = "TRASH" +class SelectAndFocusNode(bpy.types.Operator): + """Select and focus the specified node in the current node tree""" - success_msg = "Successfully removed 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): - 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"} diff --git a/src/bioxelnodes/operators/misc.py b/src/bioxelnodes/operators/misc.py index c0275e8..7693ece 100644 --- a/src/bioxelnodes/operators/misc.py +++ b/src/bioxelnodes/operators/misc.py @@ -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 @@ def execute(self, context): 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 - - 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)] - + bpy.context.scene.cycles.volume_bounces = 16 + bpy.context.scene.cycles.transparent_max_bounces = 32 + bpy.context.scene.cycles.volume_biased = False -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) - bpy.context.scene.eevee.use_shadows = True - bpy.context.scene.eevee.use_volumetric_shadows = True + # 从文本数据块加载状态 + 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 + ) - return {'FINISHED'} + 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"] + + # 恢复视图着色设置(如果之前有保存) + 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"} diff --git a/src/bioxelnodes/operators/node.py b/src/bioxelnodes/operators/node.py index e0d1229..09ba8ef 100644 --- a/src/bioxelnodes/operators/node.py +++ b/src/bioxelnodes/operators/node.py @@ -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 diff --git a/src/bioxelnodes/operators/structure.py b/src/bioxelnodes/operators/structure.py new file mode 100644 index 0000000..c4acf87 --- /dev/null +++ b/src/bioxelnodes/operators/structure.py @@ -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"} diff --git a/src/bioxelnodes/panels.py b/src/bioxelnodes/panels.py new file mode 100644 index 0000000..5c87684 --- /dev/null +++ b/src/bioxelnodes/panels.py @@ -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") diff --git a/src/bioxelnodes/preferences.py b/src/bioxelnodes/preferences.py index ff9f9ea..650e990 100644 --- a/src/bioxelnodes/preferences.py +++ b/src/bioxelnodes/preferences.py @@ -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") diff --git a/src/bioxelnodes/props.py b/src/bioxelnodes/props.py index e931831..e79b38b 100644 --- a/src/bioxelnodes/props.py +++ b/src/bioxelnodes/props.py @@ -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() -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 + 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 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 + if not items: + items = [("nothing_found", "No Layers", + "No layers found", "QUESTION", 0)] - layer_list_UL.layer_list_active = -1 + return items -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_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_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 +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 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 diff --git a/src/bioxelnodes/utils.py b/src/bioxelnodes/utils.py index 2bce786..953e792 100644 --- a/src/bioxelnodes/utils.py +++ b/src/bioxelnodes/utils.py @@ -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 diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/uv.lock b/uv.lock index 8eab207..58a3050 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 1 +revision = 2 requires-python = ">=3.11.0, <3.12" [[package]] @@ -9,26 +9,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycodestyle" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/52/65556a5f917a4b273fd1b705f98687a6bd721dbc45966f0f6687e90a18b0/autopep8-2.3.1.tar.gz", hash = "sha256:8d6c87eba648fdcfc83e29b788910b8643171c395d9c4bcf115ece035b9c9dda", size = 92064 } +sdist = { url = "https://files.pythonhosted.org/packages/6c/52/65556a5f917a4b273fd1b705f98687a6bd721dbc45966f0f6687e90a18b0/autopep8-2.3.1.tar.gz", hash = "sha256:8d6c87eba648fdcfc83e29b788910b8643171c395d9c4bcf115ece035b9c9dda", size = 92064, upload-time = "2024-06-23T05:15:55.401Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/9e/f0beffe45b507dca9d7540fad42b316b2fd1076dc484c9b1f23d9da570d7/autopep8-2.3.1-py2.py3-none-any.whl", hash = "sha256:a203fe0fcad7939987422140ab17a930f684763bf7335bdb6709991dd7ef6c2d", size = 45667 }, + { url = "https://files.pythonhosted.org/packages/ad/9e/f0beffe45b507dca9d7540fad42b316b2fd1076dc484c9b1f23d9da570d7/autopep8-2.3.1-py2.py3-none-any.whl", hash = "sha256:a203fe0fcad7939987422140ab17a930f684763bf7335bdb6709991dd7ef6c2d", size = 45667, upload-time = "2024-06-23T05:15:51.29Z" }, ] [[package]] name = "babel" version = "2.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/d2/9671b93d623300f0aef82cde40e25357f11330bdde91743891b22a555bed/babel-2.15.0.tar.gz", hash = "sha256:8daf0e265d05768bc6c7a314cf1321e9a123afc328cc635c18622a2f30a04413", size = 9390000 } +sdist = { url = "https://files.pythonhosted.org/packages/15/d2/9671b93d623300f0aef82cde40e25357f11330bdde91743891b22a555bed/babel-2.15.0.tar.gz", hash = "sha256:8daf0e265d05768bc6c7a314cf1321e9a123afc328cc635c18622a2f30a04413", size = 9390000, upload-time = "2024-05-05T13:54:45.003Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/45/377f7e32a5c93d94cd56542349b34efab5ca3f9e2fd5a68c5e93169aa32d/Babel-2.15.0-py3-none-any.whl", hash = "sha256:08706bdad8d0a3413266ab61bd6c34d0c28d6e1e7badf40a2cebe67644e2e1fb", size = 9634913 }, + { url = "https://files.pythonhosted.org/packages/27/45/377f7e32a5c93d94cd56542349b34efab5ca3f9e2fd5a68c5e93169aa32d/Babel-2.15.0-py3-none-any.whl", hash = "sha256:08706bdad8d0a3413266ab61bd6c34d0c28d6e1e7badf40a2cebe67644e2e1fb", size = 9634913, upload-time = "2024-05-05T13:54:41.446Z" }, ] [[package]] name = "bioxelnodes" -version = "1.0.7" +version = "1.0.9" source = { editable = "." } dependencies = [ { name = "h5py" }, + { name = "matplotlib" }, { name = "mrcfile" }, { name = "pyometiff" }, { name = "simpleitk" }, @@ -42,6 +43,7 @@ dev = [ { name = "mike" }, { name = "mkdocs" }, { name = "mkdocs-material" }, + { name = "pip" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-dependency" }, @@ -51,6 +53,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "h5py", specifier = "==3.11.0" }, + { name = "matplotlib", specifier = "==3.10.7" }, { name = "mrcfile", specifier = "==1.5.1" }, { name = "pyometiff", specifier = "==1.0.0" }, { name = "simpleitk", specifier = "==2.3.1" }, @@ -64,6 +67,7 @@ dev = [ { name = "mike", specifier = ">=2.1.2,<3" }, { name = "mkdocs", specifier = ">=1.6.0,<2" }, { name = "mkdocs-material", specifier = ">=9.5.30,<10" }, + { name = "pip", specifier = ">=25.3" }, { name = "pytest", specifier = ">=8.3.2,<9" }, { name = "pytest-cov", specifier = ">=5.0.0,<6" }, { name = "pytest-dependency", specifier = ">=0.6.0,<0.7" }, @@ -81,19 +85,19 @@ dependencies = [ { name = "zstandard" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/5a/d085d433ad26687d7ba5be235c57679780a3b41316cd7ece1ee65694ebad/bpy-4.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e6e8857c9f483d42f5388642601080ad3e7488970cbc9e59efeedf4a8c26c90b", size = 207901162 }, - { url = "https://files.pythonhosted.org/packages/39/03/579ec8a0d592424bd5743138ff40f2eca1944e427546721cfff091dbe730/bpy-4.2.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4fd0e9872dceb37209d298ccbe77e6c3d0421501ef71b464dde22d09607966ec", size = 220963874 }, - { url = "https://files.pythonhosted.org/packages/0b/bc/5245f98dafa39166a4d4701d64077d281a36110e780011de5c38135089aa/bpy-4.2.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a8217c2bd7b3424d1f51ce0698c0bf65439de352f7f6ed7b9942e2a9e5eb121b", size = 519384061 }, - { url = "https://files.pythonhosted.org/packages/c4/b0/6f18776208e13acb9bdbf3b5df9db9de42b09ab096aae68b171db422cccb/bpy-4.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:26ce6210deb8316816938cb1c62c7fd257a8ef38d0c20b9f031a7a47a1492cac", size = 315442011 }, + { url = "https://files.pythonhosted.org/packages/ea/5a/d085d433ad26687d7ba5be235c57679780a3b41316cd7ece1ee65694ebad/bpy-4.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e6e8857c9f483d42f5388642601080ad3e7488970cbc9e59efeedf4a8c26c90b", size = 207901162, upload-time = "2024-07-25T09:03:05.781Z" }, + { url = "https://files.pythonhosted.org/packages/39/03/579ec8a0d592424bd5743138ff40f2eca1944e427546721cfff091dbe730/bpy-4.2.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4fd0e9872dceb37209d298ccbe77e6c3d0421501ef71b464dde22d09607966ec", size = 220963874, upload-time = "2024-07-25T09:05:08.352Z" }, + { url = "https://files.pythonhosted.org/packages/0b/bc/5245f98dafa39166a4d4701d64077d281a36110e780011de5c38135089aa/bpy-4.2.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a8217c2bd7b3424d1f51ce0698c0bf65439de352f7f6ed7b9942e2a9e5eb121b", size = 519384061, upload-time = "2024-07-25T09:04:30.48Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b0/6f18776208e13acb9bdbf3b5df9db9de42b09ab096aae68b171db422cccb/bpy-4.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:26ce6210deb8316816938cb1c62c7fd257a8ef38d0c20b9f031a7a47a1492cac", size = 315442011, upload-time = "2024-07-25T09:03:45.29Z" }, ] [[package]] name = "certifi" version = "2024.7.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/02/a95f2b11e207f68bc64d7aae9666fed2e2b3f307748d5123dffb72a1bbea/certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b", size = 164065 } +sdist = { url = "https://files.pythonhosted.org/packages/c2/02/a95f2b11e207f68bc64d7aae9666fed2e2b3f307748d5123dffb72a1bbea/certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b", size = 164065, upload-time = "2024-07-04T01:36:11.653Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/d5/c84e1a17bf61d4df64ca866a1c9a913874b4e9bdc131ec689a0ad013fb36/certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90", size = 162960 }, + { url = "https://files.pythonhosted.org/packages/1c/d5/c84e1a17bf61d4df64ca866a1c9a913874b4e9bdc131ec689a0ad013fb36/certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90", size = 162960, upload-time = "2024-07-04T01:36:09.038Z" }, ] [[package]] @@ -103,43 +107,43 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/ce/95b0bae7968c65473e1298efb042e10cafc7bafc14d9e4f154008241c91d/cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0", size = 512873 } +sdist = { url = "https://files.pythonhosted.org/packages/68/ce/95b0bae7968c65473e1298efb042e10cafc7bafc14d9e4f154008241c91d/cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0", size = 512873, upload-time = "2023-09-28T18:02:04.656Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/c8/ce05a6cba2bec12d4b28285e66c53cc88dd7385b102dea7231da3b74cfef/cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404", size = 182415 }, - { url = "https://files.pythonhosted.org/packages/18/6c/0406611f3d5aadf4c5b08f6c095d874aed8dfc2d3a19892707d72536d5dc/cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417", size = 176745 }, - { url = "https://files.pythonhosted.org/packages/58/ac/2a3ea436a6cbaa8f75ddcab39010e5e0817a18f26fef5d2fe2e0c7df3425/cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627", size = 443787 }, - { url = "https://files.pythonhosted.org/packages/b5/23/ea84dd4985649fcc179ba3a6c9390412e924d20b0244dc71a6545788f5a2/cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936", size = 466550 }, - { url = "https://files.pythonhosted.org/packages/36/44/124481b75d228467950b9e81d20ec963f33517ca551f08956f2838517ece/cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d", size = 474224 }, - { url = "https://files.pythonhosted.org/packages/e4/9a/7169ae3a67a7bb9caeb2249f0617ac1458df118305c53afa3dec4a9029cd/cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56", size = 457175 }, - { url = "https://files.pythonhosted.org/packages/9b/89/a31c81e36bbb793581d8bba4406a8aac4ba84b2559301c44eef81f4cf5df/cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e", size = 464825 }, - { url = "https://files.pythonhosted.org/packages/e0/80/52b71420d68c4be18873318f6735c742f1172bb3b18d23f0306e6444d410/cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc", size = 452727 }, - { url = "https://files.pythonhosted.org/packages/47/e3/b6832b1b9a1b6170c585ee2c2d30baf64d0a497c17e6623f42cfeb59c114/cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb", size = 476370 }, - { url = "https://files.pythonhosted.org/packages/4a/ac/a4046ab3d72536eff8bc30b39d767f69bd8be715c5e395b71cfca26f03d9/cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab", size = 172849 }, - { url = "https://files.pythonhosted.org/packages/5a/c7/694814b3757878b29da39bc2f0cf9d20295f4c1e0a0bde7971708d5f23f8/cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba", size = 181495 }, + { url = "https://files.pythonhosted.org/packages/95/c8/ce05a6cba2bec12d4b28285e66c53cc88dd7385b102dea7231da3b74cfef/cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404", size = 182415, upload-time = "2023-09-28T18:00:46.724Z" }, + { url = "https://files.pythonhosted.org/packages/18/6c/0406611f3d5aadf4c5b08f6c095d874aed8dfc2d3a19892707d72536d5dc/cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417", size = 176745, upload-time = "2023-09-28T18:00:48.528Z" }, + { url = "https://files.pythonhosted.org/packages/58/ac/2a3ea436a6cbaa8f75ddcab39010e5e0817a18f26fef5d2fe2e0c7df3425/cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627", size = 443787, upload-time = "2023-09-28T18:00:50.669Z" }, + { url = "https://files.pythonhosted.org/packages/b5/23/ea84dd4985649fcc179ba3a6c9390412e924d20b0244dc71a6545788f5a2/cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936", size = 466550, upload-time = "2023-09-28T18:00:52.874Z" }, + { url = "https://files.pythonhosted.org/packages/36/44/124481b75d228467950b9e81d20ec963f33517ca551f08956f2838517ece/cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d", size = 474224, upload-time = "2023-09-28T18:00:54.564Z" }, + { url = "https://files.pythonhosted.org/packages/e4/9a/7169ae3a67a7bb9caeb2249f0617ac1458df118305c53afa3dec4a9029cd/cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56", size = 457175, upload-time = "2023-09-28T18:00:56.126Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/a31c81e36bbb793581d8bba4406a8aac4ba84b2559301c44eef81f4cf5df/cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e", size = 464825, upload-time = "2023-09-28T18:00:57.821Z" }, + { url = "https://files.pythonhosted.org/packages/e0/80/52b71420d68c4be18873318f6735c742f1172bb3b18d23f0306e6444d410/cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc", size = 452727, upload-time = "2023-09-28T18:00:59.395Z" }, + { url = "https://files.pythonhosted.org/packages/47/e3/b6832b1b9a1b6170c585ee2c2d30baf64d0a497c17e6623f42cfeb59c114/cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb", size = 476370, upload-time = "2023-09-28T18:01:01.4Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ac/a4046ab3d72536eff8bc30b39d767f69bd8be715c5e395b71cfca26f03d9/cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab", size = 172849, upload-time = "2023-09-28T18:01:03.652Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c7/694814b3757878b29da39bc2f0cf9d20295f4c1e0a0bde7971708d5f23f8/cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba", size = 181495, upload-time = "2023-09-28T18:01:05.204Z" }, ] [[package]] name = "charset-normalizer" version = "3.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/63/09/c1bc53dab74b1816a00d8d030de5bf98f724c52c1635e07681d312f20be8/charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", size = 104809 } +sdist = { url = "https://files.pythonhosted.org/packages/63/09/c1bc53dab74b1816a00d8d030de5bf98f724c52c1635e07681d312f20be8/charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", size = 104809, upload-time = "2023-11-01T04:04:59.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/77/02839016f6fbbf808e8b38601df6e0e66c17bbab76dff4613f7511413597/charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", size = 191647 }, - { url = "https://files.pythonhosted.org/packages/3e/33/21a875a61057165e92227466e54ee076b73af1e21fe1b31f1e292251aa1e/charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", size = 121434 }, - { url = "https://files.pythonhosted.org/packages/dd/51/68b61b90b24ca35495956b718f35a9756ef7d3dd4b3c1508056fa98d1a1b/charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", size = 118979 }, - { url = "https://files.pythonhosted.org/packages/e4/a6/7ee57823d46331ddc37dd00749c95b0edec2c79b15fc0d6e6efb532e89ac/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", size = 136582 }, - { url = "https://files.pythonhosted.org/packages/74/f1/0d9fe69ac441467b737ba7f48c68241487df2f4522dd7246d9426e7c690e/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", size = 146645 }, - { url = "https://files.pythonhosted.org/packages/05/31/e1f51c76db7be1d4aef220d29fbfa5dbb4a99165d9833dcbf166753b6dc0/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", size = 139398 }, - { url = "https://files.pythonhosted.org/packages/40/26/f35951c45070edc957ba40a5b1db3cf60a9dbb1b350c2d5bef03e01e61de/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", size = 140273 }, - { url = "https://files.pythonhosted.org/packages/07/07/7e554f2bbce3295e191f7e653ff15d55309a9ca40d0362fcdab36f01063c/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", size = 142577 }, - { url = "https://files.pythonhosted.org/packages/d8/b5/eb705c313100defa57da79277d9207dc8d8e45931035862fa64b625bfead/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", size = 137747 }, - { url = "https://files.pythonhosted.org/packages/19/28/573147271fd041d351b438a5665be8223f1dd92f273713cb882ddafe214c/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", size = 143375 }, - { url = "https://files.pythonhosted.org/packages/cf/7c/f3b682fa053cc21373c9a839e6beba7705857075686a05c72e0f8c4980ca/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", size = 148474 }, - { url = "https://files.pythonhosted.org/packages/1e/49/7ab74d4ac537ece3bc3334ee08645e231f39f7d6df6347b29a74b0537103/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", size = 140232 }, - { url = "https://files.pythonhosted.org/packages/2d/dc/9dacba68c9ac0ae781d40e1a0c0058e26302ea0660e574ddf6797a0347f7/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", size = 140859 }, - { url = "https://files.pythonhosted.org/packages/6c/c2/4a583f800c0708dd22096298e49f887b49d9746d0e78bfc1d7e29816614c/charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", size = 92509 }, - { url = "https://files.pythonhosted.org/packages/57/ec/80c8d48ac8b1741d5b963797b7c0c869335619e13d4744ca2f67fc11c6fc/charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", size = 99870 }, - { url = "https://files.pythonhosted.org/packages/28/76/e6222113b83e3622caa4bb41032d0b1bf785250607392e1b778aca0b8a7d/charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", size = 48543 }, + { url = "https://files.pythonhosted.org/packages/68/77/02839016f6fbbf808e8b38601df6e0e66c17bbab76dff4613f7511413597/charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", size = 191647, upload-time = "2023-11-01T04:02:55.329Z" }, + { url = "https://files.pythonhosted.org/packages/3e/33/21a875a61057165e92227466e54ee076b73af1e21fe1b31f1e292251aa1e/charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", size = 121434, upload-time = "2023-11-01T04:02:57.173Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/68b61b90b24ca35495956b718f35a9756ef7d3dd4b3c1508056fa98d1a1b/charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", size = 118979, upload-time = "2023-11-01T04:02:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/e4/a6/7ee57823d46331ddc37dd00749c95b0edec2c79b15fc0d6e6efb532e89ac/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", size = 136582, upload-time = "2023-11-01T04:02:59.776Z" }, + { url = "https://files.pythonhosted.org/packages/74/f1/0d9fe69ac441467b737ba7f48c68241487df2f4522dd7246d9426e7c690e/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", size = 146645, upload-time = "2023-11-01T04:03:02.186Z" }, + { url = "https://files.pythonhosted.org/packages/05/31/e1f51c76db7be1d4aef220d29fbfa5dbb4a99165d9833dcbf166753b6dc0/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", size = 139398, upload-time = "2023-11-01T04:03:04.255Z" }, + { url = "https://files.pythonhosted.org/packages/40/26/f35951c45070edc957ba40a5b1db3cf60a9dbb1b350c2d5bef03e01e61de/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", size = 140273, upload-time = "2023-11-01T04:03:05.983Z" }, + { url = "https://files.pythonhosted.org/packages/07/07/7e554f2bbce3295e191f7e653ff15d55309a9ca40d0362fcdab36f01063c/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", size = 142577, upload-time = "2023-11-01T04:03:07.567Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b5/eb705c313100defa57da79277d9207dc8d8e45931035862fa64b625bfead/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", size = 137747, upload-time = "2023-11-01T04:03:08.886Z" }, + { url = "https://files.pythonhosted.org/packages/19/28/573147271fd041d351b438a5665be8223f1dd92f273713cb882ddafe214c/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", size = 143375, upload-time = "2023-11-01T04:03:10.613Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7c/f3b682fa053cc21373c9a839e6beba7705857075686a05c72e0f8c4980ca/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", size = 148474, upload-time = "2023-11-01T04:03:11.973Z" }, + { url = "https://files.pythonhosted.org/packages/1e/49/7ab74d4ac537ece3bc3334ee08645e231f39f7d6df6347b29a74b0537103/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", size = 140232, upload-time = "2023-11-01T04:03:13.505Z" }, + { url = "https://files.pythonhosted.org/packages/2d/dc/9dacba68c9ac0ae781d40e1a0c0058e26302ea0660e574ddf6797a0347f7/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", size = 140859, upload-time = "2023-11-01T04:03:17.362Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c2/4a583f800c0708dd22096298e49f887b49d9746d0e78bfc1d7e29816614c/charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", size = 92509, upload-time = "2023-11-01T04:03:21.453Z" }, + { url = "https://files.pythonhosted.org/packages/57/ec/80c8d48ac8b1741d5b963797b7c0c869335619e13d4744ca2f67fc11c6fc/charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", size = 99870, upload-time = "2023-11-01T04:03:22.723Z" }, + { url = "https://files.pythonhosted.org/packages/28/76/e6222113b83e3622caa4bb41032d0b1bf785250607392e1b778aca0b8a7d/charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", size = 48543, upload-time = "2023-11-01T04:04:58.622Z" }, ] [[package]] @@ -149,36 +153,63 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } +sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121, upload-time = "2023-08-17T17:29:11.868Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 }, + { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941, upload-time = "2023-08-17T17:29:10.08Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, ] [[package]] name = "coverage" version = "7.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/64/c8/a94ce9e17756aed521085ae716d627623374d34f92c1daf7162272ecb030/coverage-7.6.0.tar.gz", hash = "sha256:289cc803fa1dc901f84701ac10c9ee873619320f2f9aff38794db4a4a0268d51", size = 797590 } +sdist = { url = "https://files.pythonhosted.org/packages/64/c8/a94ce9e17756aed521085ae716d627623374d34f92c1daf7162272ecb030/coverage-7.6.0.tar.gz", hash = "sha256:289cc803fa1dc901f84701ac10c9ee873619320f2f9aff38794db4a4a0268d51", size = 797590, upload-time = "2024-07-11T17:28:12.31Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/03/a5140e4f336f0b71023435d7e1564da8e2ff5552e91dfd3ec26424b72c47/coverage-7.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7b525ab52ce18c57ae232ba6f7010297a87ced82a2383b1afd238849c1ff933", size = 205898 }, - { url = "https://files.pythonhosted.org/packages/a2/d3/1f839af6d6cbaee2c50a0bf2d51964b56272e8b94a6504b6ab7c7ecd66c9/coverage-7.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bea27c4269234e06f621f3fac3925f56ff34bc14521484b8f66a580aacc2e7d", size = 206345 }, - { url = "https://files.pythonhosted.org/packages/f9/cf/77b3fb7e132cbae1f112458399157f67f69d5264a9e5a6eae3b91e580b79/coverage-7.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed8d1d1821ba5fc88d4a4f45387b65de52382fa3ef1f0115a4f7a20cdfab0e94", size = 238389 }, - { url = "https://files.pythonhosted.org/packages/a8/aa/da317fae6b6057428c72f1023a61d57a79a049f03bc4cbe6ffb7cb61e698/coverage-7.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01c322ef2bbe15057bc4bf132b525b7e3f7206f071799eb8aa6ad1940bcf5fb1", size = 235967 }, - { url = "https://files.pythonhosted.org/packages/39/33/da23d8bfdfdc5c221d2c9a2f169f63a4e04b9a0327c1c67777157155e4d6/coverage-7.6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03cafe82c1b32b770a29fd6de923625ccac3185a54a5e66606da26d105f37dac", size = 237659 }, - { url = "https://files.pythonhosted.org/packages/4d/d7/acea77ab27215da6afff2d5228a64cc4af7db83c481c7b14f47f34cad100/coverage-7.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d1b923fc4a40c5832be4f35a5dab0e5ff89cddf83bb4174499e02ea089daf57", size = 236585 }, - { url = "https://files.pythonhosted.org/packages/e5/b5/fc782807b3e984d0bb0472da8c7aa820ed2827ed95b9fc95c63ccb88f1f9/coverage-7.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4b03741e70fb811d1a9a1d75355cf391f274ed85847f4b78e35459899f57af4d", size = 235238 }, - { url = "https://files.pythonhosted.org/packages/80/88/649d1047bfcbf51227726bfa47073e3b5b94310acbd66f6457c274f74391/coverage-7.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a73d18625f6a8a1cbb11eadc1d03929f9510f4131879288e3f7922097a429f63", size = 236022 }, - { url = "https://files.pythonhosted.org/packages/91/41/eeba3fca0f4b6820184572c50d911959f5d108ec5f8e55fd30f0981d5209/coverage-7.6.0-cp311-cp311-win32.whl", hash = "sha256:65fa405b837060db569a61ec368b74688f429b32fa47a8929a7a2f9b47183713", size = 208431 }, - { url = "https://files.pythonhosted.org/packages/54/bb/9f2d3303441e87d27221fc9de4de63994a9570c899726a4f10ac16f79824/coverage-7.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:6379688fb4cfa921ae349c76eb1a9ab26b65f32b03d46bb0eed841fd4cb6afb1", size = 209314 }, + { url = "https://files.pythonhosted.org/packages/1b/03/a5140e4f336f0b71023435d7e1564da8e2ff5552e91dfd3ec26424b72c47/coverage-7.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7b525ab52ce18c57ae232ba6f7010297a87ced82a2383b1afd238849c1ff933", size = 205898, upload-time = "2024-07-11T17:26:36.421Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d3/1f839af6d6cbaee2c50a0bf2d51964b56272e8b94a6504b6ab7c7ecd66c9/coverage-7.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bea27c4269234e06f621f3fac3925f56ff34bc14521484b8f66a580aacc2e7d", size = 206345, upload-time = "2024-07-11T17:26:38.317Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cf/77b3fb7e132cbae1f112458399157f67f69d5264a9e5a6eae3b91e580b79/coverage-7.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed8d1d1821ba5fc88d4a4f45387b65de52382fa3ef1f0115a4f7a20cdfab0e94", size = 238389, upload-time = "2024-07-11T17:26:40.105Z" }, + { url = "https://files.pythonhosted.org/packages/a8/aa/da317fae6b6057428c72f1023a61d57a79a049f03bc4cbe6ffb7cb61e698/coverage-7.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01c322ef2bbe15057bc4bf132b525b7e3f7206f071799eb8aa6ad1940bcf5fb1", size = 235967, upload-time = "2024-07-11T17:26:42.098Z" }, + { url = "https://files.pythonhosted.org/packages/39/33/da23d8bfdfdc5c221d2c9a2f169f63a4e04b9a0327c1c67777157155e4d6/coverage-7.6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03cafe82c1b32b770a29fd6de923625ccac3185a54a5e66606da26d105f37dac", size = 237659, upload-time = "2024-07-11T17:26:44.066Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d7/acea77ab27215da6afff2d5228a64cc4af7db83c481c7b14f47f34cad100/coverage-7.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d1b923fc4a40c5832be4f35a5dab0e5ff89cddf83bb4174499e02ea089daf57", size = 236585, upload-time = "2024-07-11T17:26:46.029Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b5/fc782807b3e984d0bb0472da8c7aa820ed2827ed95b9fc95c63ccb88f1f9/coverage-7.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4b03741e70fb811d1a9a1d75355cf391f274ed85847f4b78e35459899f57af4d", size = 235238, upload-time = "2024-07-11T17:26:47.745Z" }, + { url = "https://files.pythonhosted.org/packages/80/88/649d1047bfcbf51227726bfa47073e3b5b94310acbd66f6457c274f74391/coverage-7.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a73d18625f6a8a1cbb11eadc1d03929f9510f4131879288e3f7922097a429f63", size = 236022, upload-time = "2024-07-11T17:26:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/91/41/eeba3fca0f4b6820184572c50d911959f5d108ec5f8e55fd30f0981d5209/coverage-7.6.0-cp311-cp311-win32.whl", hash = "sha256:65fa405b837060db569a61ec368b74688f429b32fa47a8929a7a2f9b47183713", size = 208431, upload-time = "2024-07-11T17:26:52.125Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/9f2d3303441e87d27221fc9de4de63994a9570c899726a4f10ac16f79824/coverage-7.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:6379688fb4cfa921ae349c76eb1a9ab26b65f32b03d46bb0eed841fd4cb6afb1", size = 209314, upload-time = "2024-07-11T17:26:54.278Z" }, ] [package.optional-dependencies] @@ -186,21 +217,47 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + [[package]] name = "cython" version = "3.0.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d5/f7/2fdd9205a2eedee7d9b0abbf15944a1151eb943001dbdc5233b1d1cfc34e/Cython-3.0.10.tar.gz", hash = "sha256:dcc96739331fb854dcf503f94607576cfe8488066c61ca50dfd55836f132de99", size = 2751764 } +sdist = { url = "https://files.pythonhosted.org/packages/d5/f7/2fdd9205a2eedee7d9b0abbf15944a1151eb943001dbdc5233b1d1cfc34e/Cython-3.0.10.tar.gz", hash = "sha256:dcc96739331fb854dcf503f94607576cfe8488066c61ca50dfd55836f132de99", size = 2751764, upload-time = "2024-03-30T20:12:49.166Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/69/198e26fb362a35460cbc72596352228fb8bddb15d11a787acfd7fa30aec4/Cython-3.0.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:051069638abfb076900b0c2bcb6facf545655b3f429e80dd14365192074af5a4", size = 3120551 }, - { url = "https://files.pythonhosted.org/packages/8e/30/947edd67f2ca21da1b88cba9c7f24fd27923a068138e0077095927dbee9e/Cython-3.0.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:712760879600907189c7d0d346851525545484e13cd8b787e94bfd293da8ccf0", size = 3441040 }, - { url = "https://files.pythonhosted.org/packages/45/82/077c13035d6f45d8b8b74d67e9f73f2bfc54ef8d1f79572790f6f7d2b4f5/Cython-3.0.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38d40fa1324ac47c04483d151f5e092406a147eac88a18aec789cf01c089c3f2", size = 3596464 }, - { url = "https://files.pythonhosted.org/packages/a2/d5/54f108e43dcc94ddcd227ee258700165bf3e447f6b0fac316b5d822fb882/Cython-3.0.10-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bd49a3a9fdff65446a3e1c2bfc0ec85c6ce4c3cad27cd4ad7ba150a62b7fb59", size = 3640507 }, - { url = "https://files.pythonhosted.org/packages/1d/bb/0a8451b86144be96132531b4b868640c61744655dc92ed08d924ecaf8931/Cython-3.0.10-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e8df79b596633b8295eaa48b1157d796775c2bb078f32267d32f3001b687f2fd", size = 3462024 }, - { url = "https://files.pythonhosted.org/packages/44/7f/8262019f0076f5e79ce4de855d8601e90b66fb44925c161b1c9670a21889/Cython-3.0.10-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bcc9795990e525c192bc5c0775e441d7d56d7a7d02210451e9e13c0448dba51b", size = 3618064 }, - { url = "https://files.pythonhosted.org/packages/8d/11/01847d11faa22d058ceb441e3185364e13342b276ce25026118d0779ff5e/Cython-3.0.10-cp311-cp311-win32.whl", hash = "sha256:09f2000041db482cad3bfce94e1fa3a4c82b0e57390a164c02566cbbda8c4f12", size = 2577020 }, - { url = "https://files.pythonhosted.org/packages/18/ec/f47a721071d084d6c2b6783eb8d058b964b1450cb708d920d0d792f42001/Cython-3.0.10-cp311-cp311-win_amd64.whl", hash = "sha256:3919a55ec9b6c7db6f68a004c21c05ed540c40dbe459ced5d801d5a1f326a053", size = 2788624 }, - { url = "https://files.pythonhosted.org/packages/b6/83/b0a63fc7b315edd46821a1a381d18765c1353d201246da44558175cddd56/Cython-3.0.10-py2.py3-none-any.whl", hash = "sha256:fcbb679c0b43514d591577fd0d20021c55c240ca9ccafbdb82d3fb95e5edfee2", size = 1170278 }, + { url = "https://files.pythonhosted.org/packages/05/69/198e26fb362a35460cbc72596352228fb8bddb15d11a787acfd7fa30aec4/Cython-3.0.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:051069638abfb076900b0c2bcb6facf545655b3f429e80dd14365192074af5a4", size = 3120551, upload-time = "2024-03-30T20:31:33.105Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/947edd67f2ca21da1b88cba9c7f24fd27923a068138e0077095927dbee9e/Cython-3.0.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:712760879600907189c7d0d346851525545484e13cd8b787e94bfd293da8ccf0", size = 3441040, upload-time = "2024-03-30T20:13:59.802Z" }, + { url = "https://files.pythonhosted.org/packages/45/82/077c13035d6f45d8b8b74d67e9f73f2bfc54ef8d1f79572790f6f7d2b4f5/Cython-3.0.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38d40fa1324ac47c04483d151f5e092406a147eac88a18aec789cf01c089c3f2", size = 3596464, upload-time = "2024-03-30T20:14:03.659Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d5/54f108e43dcc94ddcd227ee258700165bf3e447f6b0fac316b5d822fb882/Cython-3.0.10-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bd49a3a9fdff65446a3e1c2bfc0ec85c6ce4c3cad27cd4ad7ba150a62b7fb59", size = 3640507, upload-time = "2024-03-30T20:14:07.084Z" }, + { url = "https://files.pythonhosted.org/packages/1d/bb/0a8451b86144be96132531b4b868640c61744655dc92ed08d924ecaf8931/Cython-3.0.10-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e8df79b596633b8295eaa48b1157d796775c2bb078f32267d32f3001b687f2fd", size = 3462024, upload-time = "2024-03-30T20:14:10.886Z" }, + { url = "https://files.pythonhosted.org/packages/44/7f/8262019f0076f5e79ce4de855d8601e90b66fb44925c161b1c9670a21889/Cython-3.0.10-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bcc9795990e525c192bc5c0775e441d7d56d7a7d02210451e9e13c0448dba51b", size = 3618064, upload-time = "2024-03-30T20:14:14.326Z" }, + { url = "https://files.pythonhosted.org/packages/8d/11/01847d11faa22d058ceb441e3185364e13342b276ce25026118d0779ff5e/Cython-3.0.10-cp311-cp311-win32.whl", hash = "sha256:09f2000041db482cad3bfce94e1fa3a4c82b0e57390a164c02566cbbda8c4f12", size = 2577020, upload-time = "2024-03-30T20:14:16.725Z" }, + { url = "https://files.pythonhosted.org/packages/18/ec/f47a721071d084d6c2b6783eb8d058b964b1450cb708d920d0d792f42001/Cython-3.0.10-cp311-cp311-win_amd64.whl", hash = "sha256:3919a55ec9b6c7db6f68a004c21c05ed540c40dbe459ced5d801d5a1f326a053", size = 2788624, upload-time = "2024-03-30T20:14:19.5Z" }, + { url = "https://files.pythonhosted.org/packages/b6/83/b0a63fc7b315edd46821a1a381d18765c1353d201246da44558175cddd56/Cython-3.0.10-py2.py3-none-any.whl", hash = "sha256:fcbb679c0b43514d591577fd0d20021c55c240ca9ccafbdb82d3fb95e5edfee2", size = 1170278, upload-time = "2024-03-30T20:12:43.74Z" }, +] + +[[package]] +name = "fonttools" +version = "4.60.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/42/97a13e47a1e51a5a7142475bbcf5107fe3a68fc34aef331c897d5fb98ad0/fonttools-4.60.1.tar.gz", hash = "sha256:ef00af0439ebfee806b25f24c8f92109157ff3fac5731dc7867957812e87b8d9", size = 3559823, upload-time = "2025-09-29T21:13:27.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/85/639aa9bface1537e0fb0f643690672dde0695a5bbbc90736bc571b0b1941/fonttools-4.60.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7b4c32e232a71f63a5d00259ca3d88345ce2a43295bb049d21061f338124246f", size = 2831872, upload-time = "2025-09-29T21:11:20.329Z" }, + { url = "https://files.pythonhosted.org/packages/6b/47/3c63158459c95093be9618794acb1067b3f4d30dcc5c3e8114b70e67a092/fonttools-4.60.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3630e86c484263eaac71d117085d509cbcf7b18f677906824e4bace598fb70d2", size = 2356990, upload-time = "2025-09-29T21:11:22.754Z" }, + { url = "https://files.pythonhosted.org/packages/94/dd/1934b537c86fcf99f9761823f1fc37a98fbd54568e8e613f29a90fed95a9/fonttools-4.60.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5c1015318e4fec75dd4943ad5f6a206d9727adf97410d58b7e32ab644a807914", size = 5042189, upload-time = "2025-09-29T21:11:25.061Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d2/9f4e4c4374dd1daa8367784e1bd910f18ba886db1d6b825b12edf6db3edc/fonttools-4.60.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e6c58beb17380f7c2ea181ea11e7db8c0ceb474c9dd45f48e71e2cb577d146a1", size = 4978683, upload-time = "2025-09-29T21:11:27.693Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c4/0fb2dfd1ecbe9a07954cc13414713ed1eab17b1c0214ef07fc93df234a47/fonttools-4.60.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec3681a0cb34c255d76dd9d865a55f260164adb9fa02628415cdc2d43ee2c05d", size = 5021372, upload-time = "2025-09-29T21:11:30.257Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d5/495fc7ae2fab20223cc87179a8f50f40f9a6f821f271ba8301ae12bb580f/fonttools-4.60.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f4b5c37a5f40e4d733d3bbaaef082149bee5a5ea3156a785ff64d949bd1353fa", size = 5132562, upload-time = "2025-09-29T21:11:32.737Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fa/021dab618526323c744e0206b3f5c8596a2e7ae9aa38db5948a131123e83/fonttools-4.60.1-cp311-cp311-win32.whl", hash = "sha256:398447f3d8c0c786cbf1209711e79080a40761eb44b27cdafffb48f52bcec258", size = 2230288, upload-time = "2025-09-29T21:11:35.015Z" }, + { url = "https://files.pythonhosted.org/packages/bb/78/0e1a6d22b427579ea5c8273e1c07def2f325b977faaf60bb7ddc01456cb1/fonttools-4.60.1-cp311-cp311-win_amd64.whl", hash = "sha256:d066ea419f719ed87bc2c99a4a4bfd77c2e5949cb724588b9dd58f3fd90b92bf", size = 2278184, upload-time = "2025-09-29T21:11:37.434Z" }, + { url = "https://files.pythonhosted.org/packages/c7/93/0dd45cd283c32dea1545151d8c3637b4b8c53cdb3a625aeb2885b184d74d/fonttools-4.60.1-py3-none-any.whl", hash = "sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb", size = 1143175, upload-time = "2025-09-29T21:13:24.134Z" }, ] [[package]] @@ -210,9 +267,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943 } +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034 }, + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] [[package]] @@ -222,21 +279,21 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/8f/e557819155a282da36fb21f8de4730cfd10a964b52b3ae8d20157ac1c668/h5py-3.11.0.tar.gz", hash = "sha256:7b7e8f78072a2edec87c9836f25f34203fd492a4475709a18b417a33cfb21fa9", size = 406519 } +sdist = { url = "https://files.pythonhosted.org/packages/52/8f/e557819155a282da36fb21f8de4730cfd10a964b52b3ae8d20157ac1c668/h5py-3.11.0.tar.gz", hash = "sha256:7b7e8f78072a2edec87c9836f25f34203fd492a4475709a18b417a33cfb21fa9", size = 406519, upload-time = "2024-04-10T10:52:39.585Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/52/38bb74cc4362738cc7ef819503fc54d70f0c3a7378519ccb0ac309389122/h5py-3.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbd732a08187a9e2a6ecf9e8af713f1d68256ee0f7c8b652a32795670fb481ba", size = 3489913 }, - { url = "https://files.pythonhosted.org/packages/f0/af/dfbea0c69fe725e9e77259d42f4e14eb582eb094200aaf697feb36f513d8/h5py-3.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75bd7b3d93fbeee40860fd70cdc88df4464e06b70a5ad9ce1446f5f32eb84007", size = 2946912 }, - { url = "https://files.pythonhosted.org/packages/af/26/f231ee425c8df93c1abbead3d90ea4a5ff3d6aa49e0edfd3b4c017e74844/h5py-3.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52c416f8eb0daae39dabe71415cb531f95dce2d81e1f61a74537a50c63b28ab3", size = 5420165 }, - { url = "https://files.pythonhosted.org/packages/d8/5e/b7b83cfe60504cc4d24746aed04353af7ea8ec104e597e5ae71b8d0390cb/h5py-3.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:083e0329ae534a264940d6513f47f5ada617da536d8dccbafc3026aefc33c90e", size = 2979079 }, + { url = "https://files.pythonhosted.org/packages/a0/52/38bb74cc4362738cc7ef819503fc54d70f0c3a7378519ccb0ac309389122/h5py-3.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbd732a08187a9e2a6ecf9e8af713f1d68256ee0f7c8b652a32795670fb481ba", size = 3489913, upload-time = "2024-04-10T10:49:15.92Z" }, + { url = "https://files.pythonhosted.org/packages/f0/af/dfbea0c69fe725e9e77259d42f4e14eb582eb094200aaf697feb36f513d8/h5py-3.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75bd7b3d93fbeee40860fd70cdc88df4464e06b70a5ad9ce1446f5f32eb84007", size = 2946912, upload-time = "2024-04-10T10:49:25.757Z" }, + { url = "https://files.pythonhosted.org/packages/af/26/f231ee425c8df93c1abbead3d90ea4a5ff3d6aa49e0edfd3b4c017e74844/h5py-3.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52c416f8eb0daae39dabe71415cb531f95dce2d81e1f61a74537a50c63b28ab3", size = 5420165, upload-time = "2024-04-10T10:49:57.203Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5e/b7b83cfe60504cc4d24746aed04353af7ea8ec104e597e5ae71b8d0390cb/h5py-3.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:083e0329ae534a264940d6513f47f5ada617da536d8dccbafc3026aefc33c90e", size = 2979079, upload-time = "2024-04-10T10:50:11.4Z" }, ] [[package]] name = "idna" version = "3.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/ed/f86a79a07470cb07819390452f178b3bef1d375f2ec021ecfc709fc7cf07/idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc", size = 189575 } +sdist = { url = "https://files.pythonhosted.org/packages/21/ed/f86a79a07470cb07819390452f178b3bef1d375f2ec021ecfc709fc7cf07/idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc", size = 189575, upload-time = "2024-04-11T03:34:43.276Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/3e/741d8c82801c347547f8a2a06aa57dbb1992be9e948df2ea0eda2c8b79e8/idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0", size = 66836 }, + { url = "https://files.pythonhosted.org/packages/e5/3e/741d8c82801c347547f8a2a06aa57dbb1992be9e948df2ea0eda2c8b79e8/idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0", size = 66836, upload-time = "2024-04-11T03:34:41.447Z" }, ] [[package]] @@ -246,15 +303,15 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/cb/62e31d1367baded4bfd0bdf5c8e57da06db81ef4e472080ca5306e5588f2/imagecodecs-2024.6.1.tar.gz", hash = "sha256:0f3e94b7f51e2f78287b7ffae82cd850b1007639148894538274fa50bd179886", size = 9459867 } +sdist = { url = "https://files.pythonhosted.org/packages/f7/cb/62e31d1367baded4bfd0bdf5c8e57da06db81ef4e472080ca5306e5588f2/imagecodecs-2024.6.1.tar.gz", hash = "sha256:0f3e94b7f51e2f78287b7ffae82cd850b1007639148894538274fa50bd179886", size = 9459867, upload-time = "2024-06-02T05:48:03.255Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/21/7d/f891de3b967c6928da33ce660f982865caccc25532f4a68d0dd7ab7bbee3/imagecodecs-2024.6.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:328ea133e0f292cf54c5feb13e247fbf45a6055c8dc6822e841c208d2dc5c96a", size = 15112249 }, - { url = "https://files.pythonhosted.org/packages/7c/49/ef1569a1b4994444e789be74a689f9f933928cf09ed7d58bcba3d58693a1/imagecodecs-2024.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8045ea3a9c9de78ea00e2a387f47d784434bfad05967decbe0c1b3bee5aadf25", size = 12485242 }, - { url = "https://files.pythonhosted.org/packages/e2/dd/3abc85097d0596fc0c6e90b18fca8a46cb60e70ebb62016718c66c588af7/imagecodecs-2024.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42bd9ec14e4d38f15e2fa387c90b726dba42c16da0a9b6ff2c23e01478b8cd93", size = 40346211 }, - { url = "https://files.pythonhosted.org/packages/24/bc/37c6949cf5234098ee6e733b648f2dfbc92fc115262aba38d0ebd2e78570/imagecodecs-2024.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9eb5b2d755a64de9a7e0604d5dcc1151c96b43b4e5ac69bebc6d8d790b77ca58", size = 41580948 }, - { url = "https://files.pythonhosted.org/packages/e2/45/05962232ec0878e7f0a4605b7afd283d13346f60a08675acdeaea0719cf4/imagecodecs-2024.6.1-cp311-cp311-win32.whl", hash = "sha256:03ace438a843e024239cddbe7fe6940bd2a6cf3316b08c281b95842b5217c0f7", size = 21376522 }, - { url = "https://files.pythonhosted.org/packages/08/a4/6e8cc00146d89fd25d82090d1ec8fae6ca7530500c0bf163f1eb400ee572/imagecodecs-2024.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:cd926589c6e3c564490b93258b1a2ca3b040da10c21e99b618b7be6dd76b2a25", size = 25725984 }, - { url = "https://files.pythonhosted.org/packages/a4/99/0b0a5ce0d6a05dd25ca9df608b481af35a4ad5aa0259078a495ddd5f159b/imagecodecs-2024.6.1-cp311-cp311-win_arm64.whl", hash = "sha256:101fcef57aedb8730d1d2d1779dfbaa23daf7e50cd4130e88945a4fe34d0212f", size = 20803413 }, + { url = "https://files.pythonhosted.org/packages/21/7d/f891de3b967c6928da33ce660f982865caccc25532f4a68d0dd7ab7bbee3/imagecodecs-2024.6.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:328ea133e0f292cf54c5feb13e247fbf45a6055c8dc6822e841c208d2dc5c96a", size = 15112249, upload-time = "2024-06-02T06:30:31.015Z" }, + { url = "https://files.pythonhosted.org/packages/7c/49/ef1569a1b4994444e789be74a689f9f933928cf09ed7d58bcba3d58693a1/imagecodecs-2024.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8045ea3a9c9de78ea00e2a387f47d784434bfad05967decbe0c1b3bee5aadf25", size = 12485242, upload-time = "2024-06-02T05:46:04.922Z" }, + { url = "https://files.pythonhosted.org/packages/e2/dd/3abc85097d0596fc0c6e90b18fca8a46cb60e70ebb62016718c66c588af7/imagecodecs-2024.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42bd9ec14e4d38f15e2fa387c90b726dba42c16da0a9b6ff2c23e01478b8cd93", size = 40346211, upload-time = "2024-06-02T05:46:13.237Z" }, + { url = "https://files.pythonhosted.org/packages/24/bc/37c6949cf5234098ee6e733b648f2dfbc92fc115262aba38d0ebd2e78570/imagecodecs-2024.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9eb5b2d755a64de9a7e0604d5dcc1151c96b43b4e5ac69bebc6d8d790b77ca58", size = 41580948, upload-time = "2024-06-02T05:46:22.602Z" }, + { url = "https://files.pythonhosted.org/packages/e2/45/05962232ec0878e7f0a4605b7afd283d13346f60a08675acdeaea0719cf4/imagecodecs-2024.6.1-cp311-cp311-win32.whl", hash = "sha256:03ace438a843e024239cddbe7fe6940bd2a6cf3316b08c281b95842b5217c0f7", size = 21376522, upload-time = "2024-06-02T05:46:28.767Z" }, + { url = "https://files.pythonhosted.org/packages/08/a4/6e8cc00146d89fd25d82090d1ec8fae6ca7530500c0bf163f1eb400ee572/imagecodecs-2024.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:cd926589c6e3c564490b93258b1a2ca3b040da10c21e99b618b7be6dd76b2a25", size = 25725984, upload-time = "2024-06-02T05:46:34.468Z" }, + { url = "https://files.pythonhosted.org/packages/a4/99/0b0a5ce0d6a05dd25ca9df608b481af35a4ad5aa0259078a495ddd5f159b/imagecodecs-2024.6.1-cp311-cp311-win_arm64.whl", hash = "sha256:101fcef57aedb8730d1d2d1779dfbaa23daf7e50cd4130e88945a4fe34d0212f", size = 20803413, upload-time = "2024-06-02T05:46:41.326Z" }, ] [[package]] @@ -264,27 +321,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f6/a1/db39a513aa99ab3442010a994eef1cb977a436aded53042e69bee6959f74/importlib_metadata-8.2.0.tar.gz", hash = "sha256:72e8d4399996132204f9a16dcc751af254a48f8d1b20b9ff0f98d4a8f901e73d", size = 53907 } +sdist = { url = "https://files.pythonhosted.org/packages/f6/a1/db39a513aa99ab3442010a994eef1cb977a436aded53042e69bee6959f74/importlib_metadata-8.2.0.tar.gz", hash = "sha256:72e8d4399996132204f9a16dcc751af254a48f8d1b20b9ff0f98d4a8f901e73d", size = 53907, upload-time = "2024-07-24T15:22:17.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/47/bb25ec04985d0693da478797c3d8c1092b140f3a53ccb984fbbd38affa5b/importlib_metadata-8.2.0-py3-none-any.whl", hash = "sha256:11901fa0c2f97919b288679932bb64febaeacf289d18ac84dd68cb2e74213369", size = 25920 }, + { url = "https://files.pythonhosted.org/packages/82/47/bb25ec04985d0693da478797c3d8c1092b140f3a53ccb984fbbd38affa5b/importlib_metadata-8.2.0-py3-none-any.whl", hash = "sha256:11901fa0c2f97919b288679932bb64febaeacf289d18ac84dd68cb2e74213369", size = 25920, upload-time = "2024-07-24T15:22:15.491Z" }, ] [[package]] name = "importlib-resources" version = "6.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c8/9d/6ee73859d6be81c6ea7ebac89655e92740296419bd37e5c8abdb5b62fd55/importlib_resources-6.4.0.tar.gz", hash = "sha256:cdb2b453b8046ca4e3798eb1d84f3cce1446a0e8e7b5ef4efb600f19fc398145", size = 42040 } +sdist = { url = "https://files.pythonhosted.org/packages/c8/9d/6ee73859d6be81c6ea7ebac89655e92740296419bd37e5c8abdb5b62fd55/importlib_resources-6.4.0.tar.gz", hash = "sha256:cdb2b453b8046ca4e3798eb1d84f3cce1446a0e8e7b5ef4efb600f19fc398145", size = 42040, upload-time = "2024-03-21T13:42:34.888Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/06/4df55e1b7b112d183f65db9503bff189e97179b256e1ea450a3c365241e0/importlib_resources-6.4.0-py3-none-any.whl", hash = "sha256:50d10f043df931902d4194ea07ec57960f66a80449ff867bfe782b4c486ba78c", size = 38168 }, + { url = "https://files.pythonhosted.org/packages/75/06/4df55e1b7b112d183f65db9503bff189e97179b256e1ea450a3c365241e0/importlib_resources-6.4.0-py3-none-any.whl", hash = "sha256:50d10f043df931902d4194ea07ec57960f66a80449ff867bfe782b4c486ba78c", size = 38168, upload-time = "2024-03-21T13:42:33.243Z" }, ] [[package]] name = "iniconfig" version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, ] [[package]] @@ -294,74 +351,129 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ed/55/39036716d19cab0747a5020fc7e907f362fbf48c984b14e62127f7e68e5d/jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", size = 240245 } +sdist = { url = "https://files.pythonhosted.org/packages/ed/55/39036716d19cab0747a5020fc7e907f362fbf48c984b14e62127f7e68e5d/jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", size = 240245, upload-time = "2024-05-05T23:42:02.455Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/80/3a54838c3fb461f6fec263ebf3a3a41771bd05190238de3486aae8540c36/jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d", size = 133271 }, + { url = "https://files.pythonhosted.org/packages/31/80/3a54838c3fb461f6fec263ebf3a3a41771bd05190238de3486aae8540c36/jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d", size = 133271, upload-time = "2024-05-05T23:41:59.928Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/ab/c80b0d5a9d8a1a65f4f815f2afff9798b12c3b9f31f1d304dd233dd920e2/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16", size = 124167, upload-time = "2025-08-10T21:25:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c0/27fe1a68a39cf62472a300e2879ffc13c0538546c359b86f149cc19f6ac3/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089", size = 66579, upload-time = "2025-08-10T21:25:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/31/a2/a12a503ac1fd4943c50f9822678e8015a790a13b5490354c68afb8489814/kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543", size = 65309, upload-time = "2025-08-10T21:25:55.76Z" }, + { url = "https://files.pythonhosted.org/packages/66/e1/e533435c0be77c3f64040d68d7a657771194a63c279f55573188161e81ca/kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61", size = 1435596, upload-time = "2025-08-10T21:25:56.861Z" }, + { url = "https://files.pythonhosted.org/packages/67/1e/51b73c7347f9aabdc7215aa79e8b15299097dc2f8e67dee2b095faca9cb0/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1", size = 1246548, upload-time = "2025-08-10T21:25:58.246Z" }, + { url = "https://files.pythonhosted.org/packages/21/aa/72a1c5d1e430294f2d32adb9542719cfb441b5da368d09d268c7757af46c/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872", size = 1263618, upload-time = "2025-08-10T21:25:59.857Z" }, + { url = "https://files.pythonhosted.org/packages/a3/af/db1509a9e79dbf4c260ce0cfa3903ea8945f6240e9e59d1e4deb731b1a40/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26", size = 1317437, upload-time = "2025-08-10T21:26:01.105Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f2/3ea5ee5d52abacdd12013a94130436e19969fa183faa1e7c7fbc89e9a42f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028", size = 2195742, upload-time = "2025-08-10T21:26:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9b/1efdd3013c2d9a2566aa6a337e9923a00590c516add9a1e89a768a3eb2fc/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771", size = 2290810, upload-time = "2025-08-10T21:26:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e5/cfdc36109ae4e67361f9bc5b41323648cb24a01b9ade18784657e022e65f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a", size = 2461579, upload-time = "2025-08-10T21:26:05.317Z" }, + { url = "https://files.pythonhosted.org/packages/62/86/b589e5e86c7610842213994cdea5add00960076bef4ae290c5fa68589cac/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464", size = 2268071, upload-time = "2025-08-10T21:26:06.686Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c6/f8df8509fd1eee6c622febe54384a96cfaf4d43bf2ccec7a0cc17e4715c9/kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2", size = 73840, upload-time = "2025-08-10T21:26:07.94Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2d/16e0581daafd147bc11ac53f032a2b45eabac897f42a338d0a13c1e5c436/kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7", size = 65159, upload-time = "2025-08-10T21:26:09.048Z" }, + { url = "https://files.pythonhosted.org/packages/a3/0f/36d89194b5a32c054ce93e586d4049b6c2c22887b0eb229c61c68afd3078/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5", size = 60104, upload-time = "2025-08-10T21:27:43.287Z" }, + { url = "https://files.pythonhosted.org/packages/52/ba/4ed75f59e4658fd21fe7dde1fee0ac397c678ec3befba3fe6482d987af87/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa", size = 58592, upload-time = "2025-08-10T21:27:44.314Z" }, + { url = "https://files.pythonhosted.org/packages/33/01/a8ea7c5ea32a9b45ceeaee051a04c8ed4320f5add3c51bfa20879b765b70/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2", size = 80281, upload-time = "2025-08-10T21:27:45.369Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/dbd2ecdce306f1d07a1aaf324817ee993aab7aee9db47ceac757deabafbe/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f", size = 78009, upload-time = "2025-08-10T21:27:46.376Z" }, + { url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929, upload-time = "2025-08-10T21:27:48.236Z" }, ] [[package]] name = "lxml" version = "5.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/63/f7/ffbb6d2eb67b80a45b8a0834baa5557a14a5ffce0979439e7cd7f0c4055b/lxml-5.2.2.tar.gz", hash = "sha256:bb2dc4898180bea79863d5487e5f9c7c34297414bad54bcd0f0852aee9cfdb87", size = 3678631 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/6a/24e9f77d17668dd4ac0a6c2a56113fd3e0db07cee51e3a67afcd47c597e5/lxml-5.2.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:45f9494613160d0405682f9eee781c7e6d1bf45f819654eb249f8f46a2c22545", size = 8137962 }, - { url = "https://files.pythonhosted.org/packages/4e/42/3bfe92749715c819763d2205370ecc7f586b44e277f38839e27cce7d6bb8/lxml-5.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b0b3f2df149efb242cee2ffdeb6674b7f30d23c9a7af26595099afaf46ef4e88", size = 4424403 }, - { url = "https://files.pythonhosted.org/packages/d5/fd/4899215277e3ef1767019fab178fad8a149081f80cf886a73dc0ba1badae/lxml-5.2.2-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d28cb356f119a437cc58a13f8135ab8a4c8ece18159eb9194b0d269ec4e28083", size = 5099309 }, - { url = "https://files.pythonhosted.org/packages/15/3d/d84d07fc450af34ce49b88a5aac805b486f38c9f9305fba647a39367c51c/lxml-5.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:657a972f46bbefdbba2d4f14413c0d079f9ae243bd68193cb5061b9732fa54c1", size = 4810147 }, - { url = "https://files.pythonhosted.org/packages/bc/c6/32af0ec3e8323e12212c064f924ddf993017e68d1f50e03da2a3be1289c1/lxml-5.2.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b74b9ea10063efb77a965a8d5f4182806fbf59ed068b3c3fd6f30d2ac7bee734", size = 5406165 }, - { url = "https://files.pythonhosted.org/packages/67/c7/6060ea3efbd1a60a10963b1b09f493fc8f6f6728310a7a77479a3f9ab20a/lxml-5.2.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07542787f86112d46d07d4f3c4e7c760282011b354d012dc4141cc12a68cef5f", size = 4866924 }, - { url = "https://files.pythonhosted.org/packages/8a/f7/f5df71c70c4d14d186dd86fd0e9ebeffdb63b9b86fb19fe9315f9576266b/lxml-5.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:303f540ad2dddd35b92415b74b900c749ec2010e703ab3bfd6660979d01fd4ed", size = 4967116 }, - { url = "https://files.pythonhosted.org/packages/4e/56/c35969591789763657eb16c2fa79c924823b97da5536da8b89e11582da89/lxml-5.2.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:2eb2227ce1ff998faf0cd7fe85bbf086aa41dfc5af3b1d80867ecfe75fb68df3", size = 4811365 }, - { url = "https://files.pythonhosted.org/packages/e7/28/1809a5406282c03df561a3c8143c143bd515d5668f1a138f51aec6d2618e/lxml-5.2.2-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:1d8a701774dfc42a2f0b8ccdfe7dbc140500d1049e0632a611985d943fcf12df", size = 5452505 }, - { url = "https://files.pythonhosted.org/packages/99/a1/d91217a8d7fef5ac41af87c916d322c273a9b2047c735ea1dafa2ac46d16/lxml-5.2.2-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:56793b7a1a091a7c286b5f4aa1fe4ae5d1446fe742d00cdf2ffb1077865db10d", size = 4973479 }, - { url = "https://files.pythonhosted.org/packages/ad/b7/0dc82afed00c4c189cfd0b83464f9a431c66de8e73d911063956a147276a/lxml-5.2.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eb00b549b13bd6d884c863554566095bf6fa9c3cecb2e7b399c4bc7904cb33b5", size = 5013920 }, - { url = "https://files.pythonhosted.org/packages/5f/e0/4cd021850f2e8ab5ce6ce294556300bd4b5c1eb7def88b5f859449dc883c/lxml-5.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a2569a1f15ae6c8c64108a2cd2b4a858fc1e13d25846be0666fc144715e32ab", size = 4849156 }, - { url = "https://files.pythonhosted.org/packages/f0/f4/fb01451fda1e121eb8f117a00040454ca05a9c9d82b308272042eebd05f3/lxml-5.2.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:8cf85a6e40ff1f37fe0f25719aadf443686b1ac7652593dc53c7ef9b8492b115", size = 5408551 }, - { url = "https://files.pythonhosted.org/packages/2f/ca/0376418e202e9423d47f86ae09db885fa6e109d0efb602f6468e6d1e8e77/lxml-5.2.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:d237ba6664b8e60fd90b8549a149a74fcc675272e0e95539a00522e4ca688b04", size = 4829966 }, - { url = "https://files.pythonhosted.org/packages/74/c4/4e6f5e2be18f8eb76dff5bff3619c2c654650fee93aea37a92866efe90bc/lxml-5.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0b3f5016e00ae7630a4b83d0868fca1e3d494c78a75b1c7252606a3a1c5fc2ad", size = 4976003 }, - { url = "https://files.pythonhosted.org/packages/3b/ca/5d74a0572c911f8dbf12d89abe0fdcbe0409c18978b5694392becd4d56fb/lxml-5.2.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:23441e2b5339bc54dc949e9e675fa35efe858108404ef9aa92f0456929ef6fe8", size = 4852709 }, - { url = "https://files.pythonhosted.org/packages/83/07/d95e9663ad8d875f344930e4fb52a0a6f56225267d3cc6e3e9bfa44ca736/lxml-5.2.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb0ba3e8566548d6c8e7dd82a8229ff47bd8fb8c2da237607ac8e5a1b8312e5", size = 5479261 }, - { url = "https://files.pythonhosted.org/packages/df/e1/9ebae8d05492a8e9f632f2add15199e7bca5c1b063444e360a7bde685718/lxml-5.2.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:79d1fb9252e7e2cfe4de6e9a6610c7cbb99b9708e2c3e29057f487de5a9eaefa", size = 4944199 }, - { url = "https://files.pythonhosted.org/packages/ec/ab/189f571450e3393d4d442f88683d11b5a47c88e66a4e6b0d467500360483/lxml-5.2.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6dcc3d17eac1df7859ae01202e9bb11ffa8c98949dcbeb1069c8b9a75917e01b", size = 5033723 }, - { url = "https://files.pythonhosted.org/packages/80/d7/f28ccad4f08596592a58ad945c636140268bb4de9dcf4d10c9f72108d8a5/lxml-5.2.2-cp311-cp311-win32.whl", hash = "sha256:4c30a2f83677876465f44c018830f608fa3c6a8a466eb223535035fbc16f3438", size = 3475657 }, - { url = "https://files.pythonhosted.org/packages/04/19/d6aa2d980f220a04c91d4de538d2fea1a65535e7b0a4aec0998ce46e3667/lxml-5.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:49095a38eb333aaf44c06052fd2ec3b8f23e19747ca7ec6f6c954ffea6dbf7be", size = 3816665 }, +sdist = { url = "https://files.pythonhosted.org/packages/63/f7/ffbb6d2eb67b80a45b8a0834baa5557a14a5ffce0979439e7cd7f0c4055b/lxml-5.2.2.tar.gz", hash = "sha256:bb2dc4898180bea79863d5487e5f9c7c34297414bad54bcd0f0852aee9cfdb87", size = 3678631, upload-time = "2024-05-13T05:58:21.968Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/6a/24e9f77d17668dd4ac0a6c2a56113fd3e0db07cee51e3a67afcd47c597e5/lxml-5.2.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:45f9494613160d0405682f9eee781c7e6d1bf45f819654eb249f8f46a2c22545", size = 8137962, upload-time = "2024-05-13T05:08:37.117Z" }, + { url = "https://files.pythonhosted.org/packages/4e/42/3bfe92749715c819763d2205370ecc7f586b44e277f38839e27cce7d6bb8/lxml-5.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b0b3f2df149efb242cee2ffdeb6674b7f30d23c9a7af26595099afaf46ef4e88", size = 4424403, upload-time = "2024-05-13T05:09:02.004Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fd/4899215277e3ef1767019fab178fad8a149081f80cf886a73dc0ba1badae/lxml-5.2.2-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d28cb356f119a437cc58a13f8135ab8a4c8ece18159eb9194b0d269ec4e28083", size = 5099309, upload-time = "2024-05-13T05:09:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/15/3d/d84d07fc450af34ce49b88a5aac805b486f38c9f9305fba647a39367c51c/lxml-5.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:657a972f46bbefdbba2d4f14413c0d079f9ae243bd68193cb5061b9732fa54c1", size = 4810147, upload-time = "2024-05-13T05:09:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c6/32af0ec3e8323e12212c064f924ddf993017e68d1f50e03da2a3be1289c1/lxml-5.2.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b74b9ea10063efb77a965a8d5f4182806fbf59ed068b3c3fd6f30d2ac7bee734", size = 5406165, upload-time = "2024-05-13T05:10:24.534Z" }, + { url = "https://files.pythonhosted.org/packages/67/c7/6060ea3efbd1a60a10963b1b09f493fc8f6f6728310a7a77479a3f9ab20a/lxml-5.2.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07542787f86112d46d07d4f3c4e7c760282011b354d012dc4141cc12a68cef5f", size = 4866924, upload-time = "2024-05-13T05:10:49.944Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f7/f5df71c70c4d14d186dd86fd0e9ebeffdb63b9b86fb19fe9315f9576266b/lxml-5.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:303f540ad2dddd35b92415b74b900c749ec2010e703ab3bfd6660979d01fd4ed", size = 4967116, upload-time = "2024-05-13T05:11:19.005Z" }, + { url = "https://files.pythonhosted.org/packages/4e/56/c35969591789763657eb16c2fa79c924823b97da5536da8b89e11582da89/lxml-5.2.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:2eb2227ce1ff998faf0cd7fe85bbf086aa41dfc5af3b1d80867ecfe75fb68df3", size = 4811365, upload-time = "2024-05-13T05:11:46.543Z" }, + { url = "https://files.pythonhosted.org/packages/e7/28/1809a5406282c03df561a3c8143c143bd515d5668f1a138f51aec6d2618e/lxml-5.2.2-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:1d8a701774dfc42a2f0b8ccdfe7dbc140500d1049e0632a611985d943fcf12df", size = 5452505, upload-time = "2024-05-13T05:12:14.507Z" }, + { url = "https://files.pythonhosted.org/packages/99/a1/d91217a8d7fef5ac41af87c916d322c273a9b2047c735ea1dafa2ac46d16/lxml-5.2.2-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:56793b7a1a091a7c286b5f4aa1fe4ae5d1446fe742d00cdf2ffb1077865db10d", size = 4973479, upload-time = "2024-05-13T05:12:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b7/0dc82afed00c4c189cfd0b83464f9a431c66de8e73d911063956a147276a/lxml-5.2.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eb00b549b13bd6d884c863554566095bf6fa9c3cecb2e7b399c4bc7904cb33b5", size = 5013920, upload-time = "2024-05-13T05:13:10.517Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e0/4cd021850f2e8ab5ce6ce294556300bd4b5c1eb7def88b5f859449dc883c/lxml-5.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a2569a1f15ae6c8c64108a2cd2b4a858fc1e13d25846be0666fc144715e32ab", size = 4849156, upload-time = "2024-05-13T05:13:35.768Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/fb01451fda1e121eb8f117a00040454ca05a9c9d82b308272042eebd05f3/lxml-5.2.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:8cf85a6e40ff1f37fe0f25719aadf443686b1ac7652593dc53c7ef9b8492b115", size = 5408551, upload-time = "2024-05-13T05:14:08.124Z" }, + { url = "https://files.pythonhosted.org/packages/2f/ca/0376418e202e9423d47f86ae09db885fa6e109d0efb602f6468e6d1e8e77/lxml-5.2.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:d237ba6664b8e60fd90b8549a149a74fcc675272e0e95539a00522e4ca688b04", size = 4829966, upload-time = "2024-05-13T05:14:39.48Z" }, + { url = "https://files.pythonhosted.org/packages/74/c4/4e6f5e2be18f8eb76dff5bff3619c2c654650fee93aea37a92866efe90bc/lxml-5.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0b3f5016e00ae7630a4b83d0868fca1e3d494c78a75b1c7252606a3a1c5fc2ad", size = 4976003, upload-time = "2024-05-13T05:15:02.719Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ca/5d74a0572c911f8dbf12d89abe0fdcbe0409c18978b5694392becd4d56fb/lxml-5.2.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:23441e2b5339bc54dc949e9e675fa35efe858108404ef9aa92f0456929ef6fe8", size = 4852709, upload-time = "2024-05-13T05:15:26.151Z" }, + { url = "https://files.pythonhosted.org/packages/83/07/d95e9663ad8d875f344930e4fb52a0a6f56225267d3cc6e3e9bfa44ca736/lxml-5.2.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb0ba3e8566548d6c8e7dd82a8229ff47bd8fb8c2da237607ac8e5a1b8312e5", size = 5479261, upload-time = "2024-05-13T05:15:56.219Z" }, + { url = "https://files.pythonhosted.org/packages/df/e1/9ebae8d05492a8e9f632f2add15199e7bca5c1b063444e360a7bde685718/lxml-5.2.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:79d1fb9252e7e2cfe4de6e9a6610c7cbb99b9708e2c3e29057f487de5a9eaefa", size = 4944199, upload-time = "2024-05-13T05:16:25.616Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ab/189f571450e3393d4d442f88683d11b5a47c88e66a4e6b0d467500360483/lxml-5.2.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6dcc3d17eac1df7859ae01202e9bb11ffa8c98949dcbeb1069c8b9a75917e01b", size = 5033723, upload-time = "2024-05-13T05:16:49.077Z" }, + { url = "https://files.pythonhosted.org/packages/80/d7/f28ccad4f08596592a58ad945c636140268bb4de9dcf4d10c9f72108d8a5/lxml-5.2.2-cp311-cp311-win32.whl", hash = "sha256:4c30a2f83677876465f44c018830f608fa3c6a8a466eb223535035fbc16f3438", size = 3475657, upload-time = "2024-05-13T05:17:03.477Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/d6aa2d980f220a04c91d4de538d2fea1a65535e7b0a4aec0998ce46e3667/lxml-5.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:49095a38eb333aaf44c06052fd2ec3b8f23e19747ca7ec6f6c954ffea6dbf7be", size = 3816665, upload-time = "2024-05-13T05:17:24.698Z" }, ] [[package]] name = "markdown" version = "3.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/02/4785861427848cc11e452cc62bb541006a1087cf04a1de83aedd5530b948/Markdown-3.6.tar.gz", hash = "sha256:ed4f41f6daecbeeb96e576ce414c41d2d876daa9a16cb35fa8ed8c2ddfad0224", size = 354715 } +sdist = { url = "https://files.pythonhosted.org/packages/22/02/4785861427848cc11e452cc62bb541006a1087cf04a1de83aedd5530b948/Markdown-3.6.tar.gz", hash = "sha256:ed4f41f6daecbeeb96e576ce414c41d2d876daa9a16cb35fa8ed8c2ddfad0224", size = 354715, upload-time = "2024-03-14T15:37:59.775Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/b3/0c0c994fe49cd661084f8d5dc06562af53818cc0abefaca35bdc894577c3/Markdown-3.6-py3-none-any.whl", hash = "sha256:48f276f4d8cfb8ce6527c8f79e2ee29708508bf4d40aa410fbc3b4ee832c850f", size = 105381 }, + { url = "https://files.pythonhosted.org/packages/fc/b3/0c0c994fe49cd661084f8d5dc06562af53818cc0abefaca35bdc894577c3/Markdown-3.6-py3-none-any.whl", hash = "sha256:48f276f4d8cfb8ce6527c8f79e2ee29708508bf4d40aa410fbc3b4ee832c850f", size = 105381, upload-time = "2024-03-14T15:37:57.457Z" }, ] [[package]] name = "markupsafe" version = "2.1.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/87/5b/aae44c6655f3801e81aa3eef09dbbf012431987ba564d7231722f68df02d/MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", size = 19384 } +sdist = { url = "https://files.pythonhosted.org/packages/87/5b/aae44c6655f3801e81aa3eef09dbbf012431987ba564d7231722f68df02d/MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", size = 19384, upload-time = "2024-02-02T16:31:22.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/e7/291e55127bb2ae67c64d66cef01432b5933859dfb7d6949daa721b89d0b3/MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", size = 18219, upload-time = "2024-02-02T16:30:19.988Z" }, + { url = "https://files.pythonhosted.org/packages/6b/cb/aed7a284c00dfa7c0682d14df85ad4955a350a21d2e3b06d8240497359bf/MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", size = 14098, upload-time = "2024-02-02T16:30:21.063Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cf/35fe557e53709e93feb65575c93927942087e9b97213eabc3fe9d5b25a55/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", size = 29014, upload-time = "2024-02-02T16:30:22.926Z" }, + { url = "https://files.pythonhosted.org/packages/97/18/c30da5e7a0e7f4603abfc6780574131221d9148f323752c2755d48abad30/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", size = 28220, upload-time = "2024-02-02T16:30:24.76Z" }, + { url = "https://files.pythonhosted.org/packages/0c/40/2e73e7d532d030b1e41180807a80d564eda53babaf04d65e15c1cf897e40/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", size = 27756, upload-time = "2024-02-02T16:30:25.877Z" }, + { url = "https://files.pythonhosted.org/packages/18/46/5dca760547e8c59c5311b332f70605d24c99d1303dd9a6e1fc3ed0d73561/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", size = 33988, upload-time = "2024-02-02T16:30:26.935Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c5/27febe918ac36397919cd4a67d5579cbbfa8da027fa1238af6285bb368ea/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", size = 32718, upload-time = "2024-02-02T16:30:28.111Z" }, + { url = "https://files.pythonhosted.org/packages/f8/81/56e567126a2c2bc2684d6391332e357589a96a76cb9f8e5052d85cb0ead8/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", size = 33317, upload-time = "2024-02-02T16:30:29.214Z" }, + { url = "https://files.pythonhosted.org/packages/00/0b/23f4b2470accb53285c613a3ab9ec19dc944eaf53592cb6d9e2af8aa24cc/MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", size = 16670, upload-time = "2024-02-02T16:30:30.915Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a2/c78a06a9ec6d04b3445a949615c4c7ed86a0b2eb68e44e7541b9d57067cc/MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", size = 17224, upload-time = "2024-02-02T16:30:32.09Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/e2/d2d5295be2f44c678ebaf3544ba32d20c1f9ef08c49fe47f496180e1db15/matplotlib-3.10.7.tar.gz", hash = "sha256:a06ba7e2a2ef9131c79c49e63dad355d2d878413a0376c1727c8b9335ff731c7", size = 34804865, upload-time = "2025-10-09T00:28:00.669Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/e7/291e55127bb2ae67c64d66cef01432b5933859dfb7d6949daa721b89d0b3/MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", size = 18219 }, - { url = "https://files.pythonhosted.org/packages/6b/cb/aed7a284c00dfa7c0682d14df85ad4955a350a21d2e3b06d8240497359bf/MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", size = 14098 }, - { url = "https://files.pythonhosted.org/packages/1c/cf/35fe557e53709e93feb65575c93927942087e9b97213eabc3fe9d5b25a55/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", size = 29014 }, - { url = "https://files.pythonhosted.org/packages/97/18/c30da5e7a0e7f4603abfc6780574131221d9148f323752c2755d48abad30/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", size = 28220 }, - { url = "https://files.pythonhosted.org/packages/0c/40/2e73e7d532d030b1e41180807a80d564eda53babaf04d65e15c1cf897e40/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", size = 27756 }, - { url = "https://files.pythonhosted.org/packages/18/46/5dca760547e8c59c5311b332f70605d24c99d1303dd9a6e1fc3ed0d73561/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", size = 33988 }, - { url = "https://files.pythonhosted.org/packages/6d/c5/27febe918ac36397919cd4a67d5579cbbfa8da027fa1238af6285bb368ea/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", size = 32718 }, - { url = "https://files.pythonhosted.org/packages/f8/81/56e567126a2c2bc2684d6391332e357589a96a76cb9f8e5052d85cb0ead8/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", size = 33317 }, - { url = "https://files.pythonhosted.org/packages/00/0b/23f4b2470accb53285c613a3ab9ec19dc944eaf53592cb6d9e2af8aa24cc/MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", size = 16670 }, - { url = "https://files.pythonhosted.org/packages/b7/a2/c78a06a9ec6d04b3445a949615c4c7ed86a0b2eb68e44e7541b9d57067cc/MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", size = 17224 }, + { url = "https://files.pythonhosted.org/packages/fc/bc/0fb489005669127ec13f51be0c6adc074d7cf191075dab1da9fe3b7a3cfc/matplotlib-3.10.7-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:53b492410a6cd66c7a471de6c924f6ede976e963c0f3097a3b7abfadddc67d0a", size = 8257507, upload-time = "2025-10-09T00:26:19.073Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6a/d42588ad895279ff6708924645b5d2ed54a7fb2dc045c8a804e955aeace1/matplotlib-3.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d9749313deb729f08207718d29c86246beb2ea3fdba753595b55901dee5d2fd6", size = 8119565, upload-time = "2025-10-09T00:26:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/10/b7/4aa196155b4d846bd749cf82aa5a4c300cf55a8b5e0dfa5b722a63c0f8a0/matplotlib-3.10.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2222c7ba2cbde7fe63032769f6eb7e83ab3227f47d997a8453377709b7fe3a5a", size = 8692668, upload-time = "2025-10-09T00:26:22.967Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e7/664d2b97016f46683a02d854d730cfcf54ff92c1dafa424beebef50f831d/matplotlib-3.10.7-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e91f61a064c92c307c5a9dc8c05dc9f8a68f0a3be199d9a002a0622e13f874a1", size = 9521051, upload-time = "2025-10-09T00:26:25.041Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a3/37aef1404efa615f49b5758a5e0261c16dd88f389bc1861e722620e4a754/matplotlib-3.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6f1851eab59ca082c95df5a500106bad73672645625e04538b3ad0f69471ffcc", size = 9576878, upload-time = "2025-10-09T00:26:27.478Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/b145f9797126f3f809d177ca378de57c45413c5099c5990de2658760594a/matplotlib-3.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:6516ce375109c60ceec579e699524e9d504cd7578506f01150f7a6bc174a775e", size = 8115142, upload-time = "2025-10-09T00:26:29.774Z" }, + { url = "https://files.pythonhosted.org/packages/2e/39/63bca9d2b78455ed497fcf51a9c71df200a11048f48249038f06447fa947/matplotlib-3.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:b172db79759f5f9bc13ef1c3ef8b9ee7b37b0247f987fbbbdaa15e4f87fd46a9", size = 7992439, upload-time = "2025-10-09T00:26:40.32Z" }, + { url = "https://files.pythonhosted.org/packages/58/8f/76d5dc21ac64a49e5498d7f0472c0781dae442dd266a67458baec38288ec/matplotlib-3.10.7-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:15112bcbaef211bd663fa935ec33313b948e214454d949b723998a43357b17b0", size = 8252283, upload-time = "2025-10-09T00:27:54.739Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/9c5d4c2317feb31d819e38c9f947c942f42ebd4eb935fc6fd3518a11eaa7/matplotlib-3.10.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d2a959c640cdeecdd2ec3136e8ea0441da59bcaf58d67e9c590740addba2cb68", size = 8116733, upload-time = "2025-10-09T00:27:56.406Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cc/3fe688ff1355010937713164caacf9ed443675ac48a997bab6ed23b3f7c0/matplotlib-3.10.7-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3886e47f64611046bc1db523a09dd0a0a6bed6081e6f90e13806dd1d1d1b5e91", size = 8693919, upload-time = "2025-10-09T00:27:58.41Z" }, ] [[package]] name = "mergedeep" version = "1.3.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661 } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354 }, + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, ] [[package]] @@ -378,9 +490,9 @@ dependencies = [ { name = "pyyaml-env-tag" }, { name = "verspec" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/03/14135a1dc0266ecdbed3b9545c34b5268eeca74f8f0e16b72d0d4468a494/mike-2.1.2.tar.gz", hash = "sha256:d59cc8054c50f9c8a046cfd47f9b700cf9ff1b2b19f420bd8812ca6f94fa8bd3", size = 38085 } +sdist = { url = "https://files.pythonhosted.org/packages/df/03/14135a1dc0266ecdbed3b9545c34b5268eeca74f8f0e16b72d0d4468a494/mike-2.1.2.tar.gz", hash = "sha256:d59cc8054c50f9c8a046cfd47f9b700cf9ff1b2b19f420bd8812ca6f94fa8bd3", size = 38085, upload-time = "2024-06-24T16:35:59.486Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/73/fd9536c4609dcf3a1fec5f7c49b2a48fb9020e17e6dd731d0a1144aaf42a/mike-2.1.2-py3-none-any.whl", hash = "sha256:d61d9b423ab412d634ca2bd520136d5114e3cc73f4bbd1aa6a0c6625c04918c0", size = 33760 }, + { url = "https://files.pythonhosted.org/packages/4e/73/fd9536c4609dcf3a1fec5f7c49b2a48fb9020e17e6dd731d0a1144aaf42a/mike-2.1.2-py3-none-any.whl", hash = "sha256:d61d9b423ab412d634ca2bd520136d5114e3cc73f4bbd1aa6a0c6625c04918c0", size = 33760, upload-time = "2024-06-24T16:35:57.294Z" }, ] [[package]] @@ -402,9 +514,9 @@ dependencies = [ { name = "pyyaml-env-tag" }, { name = "watchdog" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cc/6b/26b33cc8ad54e8bc0345cddc061c2c5c23e364de0ecd97969df23f95a673/mkdocs-1.6.0.tar.gz", hash = "sha256:a73f735824ef83a4f3bcb7a231dcab23f5a838f88b7efc54a0eef5fbdbc3c512", size = 3888392 } +sdist = { url = "https://files.pythonhosted.org/packages/cc/6b/26b33cc8ad54e8bc0345cddc061c2c5c23e364de0ecd97969df23f95a673/mkdocs-1.6.0.tar.gz", hash = "sha256:a73f735824ef83a4f3bcb7a231dcab23f5a838f88b7efc54a0eef5fbdbc3c512", size = 3888392, upload-time = "2024-04-20T17:55:45.205Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/c0/930dcf5a3e96b9c8e7ad15502603fc61d495479699e2d2c381e3d37294d1/mkdocs-1.6.0-py3-none-any.whl", hash = "sha256:1eb5cb7676b7d89323e62b56235010216319217d4af5ddc543a91beb8d125ea7", size = 3862264 }, + { url = "https://files.pythonhosted.org/packages/b8/c0/930dcf5a3e96b9c8e7ad15502603fc61d495479699e2d2c381e3d37294d1/mkdocs-1.6.0-py3-none-any.whl", hash = "sha256:1eb5cb7676b7d89323e62b56235010216319217d4af5ddc543a91beb8d125ea7", size = 3862264, upload-time = "2024-04-20T17:55:42.126Z" }, ] [[package]] @@ -416,9 +528,9 @@ dependencies = [ { name = "platformdirs" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239 } +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521 }, + { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, ] [[package]] @@ -438,18 +550,18 @@ dependencies = [ { name = "regex" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a4/bd/00ada20787910d8ca0def5aeec7449849dbf6294c529c1e596df4a6457eb/mkdocs_material-9.5.30.tar.gz", hash = "sha256:3fd417dd42d679e3ba08b9e2d72cd8b8af142cc4a3969676ad6b00993dd182ec", size = 4103479 } +sdist = { url = "https://files.pythonhosted.org/packages/a4/bd/00ada20787910d8ca0def5aeec7449849dbf6294c529c1e596df4a6457eb/mkdocs_material-9.5.30.tar.gz", hash = "sha256:3fd417dd42d679e3ba08b9e2d72cd8b8af142cc4a3969676ad6b00993dd182ec", size = 4103479, upload-time = "2024-07-23T07:14:02.716Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/b4/fe7fd093d6560ee756f8ab41d5cc50b362ac85f7f0c0e00ee82fadbc4f6d/mkdocs_material-9.5.30-py3-none-any.whl", hash = "sha256:fc070689c5250a180e9b9d79d8491ef9a3a7acb240db0728728d6c31eeb131d4", size = 8817826 }, + { url = "https://files.pythonhosted.org/packages/f3/b4/fe7fd093d6560ee756f8ab41d5cc50b362ac85f7f0c0e00ee82fadbc4f6d/mkdocs_material-9.5.30-py3-none-any.whl", hash = "sha256:fc070689c5250a180e9b9d79d8491ef9a3a7acb240db0728728d6c31eeb131d4", size = 8817826, upload-time = "2024-07-23T07:13:57.765Z" }, ] [[package]] name = "mkdocs-material-extensions" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847 } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728 }, + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, ] [[package]] @@ -459,96 +571,131 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/28/0f3a0e0c8c6998a93598873ae2624baf6862c68cf4df955d78c1f4ca38c6/mrcfile-1.5.1.tar.gz", hash = "sha256:403c4bb0ac842410ce5ea501f4fddc91ea37c12ef869d508d3ac571868d82ac2", size = 56558 } +sdist = { url = "https://files.pythonhosted.org/packages/c2/28/0f3a0e0c8c6998a93598873ae2624baf6862c68cf4df955d78c1f4ca38c6/mrcfile-1.5.1.tar.gz", hash = "sha256:403c4bb0ac842410ce5ea501f4fddc91ea37c12ef869d508d3ac571868d82ac2", size = 56558, upload-time = "2024-07-11T11:36:10.983Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/f0/c791571925cd21db9488a08515dd3b86fa56bcc51be396865a9fee46f8bc/mrcfile-1.5.1-py2.py3-none-any.whl", hash = "sha256:06900f1245e66dd4617cbd4a7117a2d75d53fc4e5b74d811766f71a858b059a9", size = 44224 }, + { url = "https://files.pythonhosted.org/packages/50/f0/c791571925cd21db9488a08515dd3b86fa56bcc51be396865a9fee46f8bc/mrcfile-1.5.1-py2.py3-none-any.whl", hash = "sha256:06900f1245e66dd4617cbd4a7117a2d75d53fc4e5b74d811766f71a858b059a9", size = 44224, upload-time = "2024-07-11T11:36:08.595Z" }, ] [[package]] name = "numpy" version = "2.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/8a/0db635b225d2aa2984e405dc14bd2b0c324a0c312ea1bc9d283f2b83b038/numpy-2.0.1.tar.gz", hash = "sha256:485b87235796410c3519a699cfe1faab097e509e90ebb05dcd098db2ae87e7b3", size = 18872007 } +sdist = { url = "https://files.pythonhosted.org/packages/1c/8a/0db635b225d2aa2984e405dc14bd2b0c324a0c312ea1bc9d283f2b83b038/numpy-2.0.1.tar.gz", hash = "sha256:485b87235796410c3519a699cfe1faab097e509e90ebb05dcd098db2ae87e7b3", size = 18872007, upload-time = "2024-07-21T13:49:20.698Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/d6/ff66f4f87518a435538e15cc9e0477a88398512a18783e748914f0daf5ea/numpy-2.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75b4e316c5902d8163ef9d423b1c3f2f6252226d1aa5cd8a0a03a7d01ffc6268", size = 21238269 }, - { url = "https://files.pythonhosted.org/packages/c5/64/853cfc37494471e64ea9f7bf3bc3b4bb39450e6db5beeb05e2a66beef612/numpy-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6e4eeb6eb2fced786e32e6d8df9e755ce5be920d17f7ce00bc38fcde8ccdbf9e", size = 13334324 }, - { url = "https://files.pythonhosted.org/packages/d1/d8/597b4b2e396a77cbec677c9de33bb1789d5c3b66d653cb723d00eb331e99/numpy-2.0.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a1e01dcaab205fbece13c1410253a9eea1b1c9b61d237b6fa59bcc46e8e89343", size = 5298376 }, - { url = "https://files.pythonhosted.org/packages/64/58/8664ff3747ac719ae1a5b9c0020533435158180a27f2f88a2b7a253bb623/numpy-2.0.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:a8fc2de81ad835d999113ddf87d1ea2b0f4704cbd947c948d2f5513deafe5a7b", size = 6903817 }, - { url = "https://files.pythonhosted.org/packages/72/44/71ac0090d4ccb512fcac0ef0e5208248423a1ce30381541700470ac09b75/numpy-2.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a3d94942c331dd4e0e1147f7a8699a4aa47dffc11bf8a1523c12af8b2e91bbe", size = 13916254 }, - { url = "https://files.pythonhosted.org/packages/ef/27/39622993e8688a1f05898a3c3b2836b856f79c06637ebd4b71cb35cc9b18/numpy-2.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15eb4eca47d36ec3f78cde0a3a2ee24cf05ca7396ef808dda2c0ddad7c2bde67", size = 19540260 }, - { url = "https://files.pythonhosted.org/packages/6a/26/a32b5a6b3f090860aeefb3619bfea09f717d73908bd65e69e8ab0cac9c07/numpy-2.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:b83e16a5511d1b1f8a88cbabb1a6f6a499f82c062a4251892d9ad5d609863fb7", size = 19938602 }, - { url = "https://files.pythonhosted.org/packages/34/b6/a88a9953d0be231c67aa0b3714d6138507490753beaa927f0b33f20cdca2/numpy-2.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f87fec1f9bc1efd23f4227becff04bd0e979e23ca50cc92ec88b38489db3b55", size = 14425300 }, - { url = "https://files.pythonhosted.org/packages/e4/e2/e763e102bea9c188b43ea144a91c22bec669736889a6e0be0235d64666d7/numpy-2.0.1-cp311-cp311-win32.whl", hash = "sha256:36d3a9405fd7c511804dc56fc32974fa5533bdeb3cd1604d6b8ff1d292b819c4", size = 6467652 }, - { url = "https://files.pythonhosted.org/packages/3d/67/928e8f0d5c7fd32f32fb5caf92b186a1b3826dbaf5a294e13a976d6c38b6/numpy-2.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:08458fbf403bff5e2b45f08eda195d4b0c9b35682311da5a5a0a0925b11b9bd8", size = 16559280 }, + { url = "https://files.pythonhosted.org/packages/29/d6/ff66f4f87518a435538e15cc9e0477a88398512a18783e748914f0daf5ea/numpy-2.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75b4e316c5902d8163ef9d423b1c3f2f6252226d1aa5cd8a0a03a7d01ffc6268", size = 21238269, upload-time = "2024-07-21T13:33:30.438Z" }, + { url = "https://files.pythonhosted.org/packages/c5/64/853cfc37494471e64ea9f7bf3bc3b4bb39450e6db5beeb05e2a66beef612/numpy-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6e4eeb6eb2fced786e32e6d8df9e755ce5be920d17f7ce00bc38fcde8ccdbf9e", size = 13334324, upload-time = "2024-07-21T13:33:51.455Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d8/597b4b2e396a77cbec677c9de33bb1789d5c3b66d653cb723d00eb331e99/numpy-2.0.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a1e01dcaab205fbece13c1410253a9eea1b1c9b61d237b6fa59bcc46e8e89343", size = 5298376, upload-time = "2024-07-21T13:34:01.627Z" }, + { url = "https://files.pythonhosted.org/packages/64/58/8664ff3747ac719ae1a5b9c0020533435158180a27f2f88a2b7a253bb623/numpy-2.0.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:a8fc2de81ad835d999113ddf87d1ea2b0f4704cbd947c948d2f5513deafe5a7b", size = 6903817, upload-time = "2024-07-21T13:34:13.878Z" }, + { url = "https://files.pythonhosted.org/packages/72/44/71ac0090d4ccb512fcac0ef0e5208248423a1ce30381541700470ac09b75/numpy-2.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a3d94942c331dd4e0e1147f7a8699a4aa47dffc11bf8a1523c12af8b2e91bbe", size = 13916254, upload-time = "2024-07-21T13:34:35.466Z" }, + { url = "https://files.pythonhosted.org/packages/ef/27/39622993e8688a1f05898a3c3b2836b856f79c06637ebd4b71cb35cc9b18/numpy-2.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15eb4eca47d36ec3f78cde0a3a2ee24cf05ca7396ef808dda2c0ddad7c2bde67", size = 19540260, upload-time = "2024-07-21T13:35:09.114Z" }, + { url = "https://files.pythonhosted.org/packages/6a/26/a32b5a6b3f090860aeefb3619bfea09f717d73908bd65e69e8ab0cac9c07/numpy-2.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:b83e16a5511d1b1f8a88cbabb1a6f6a499f82c062a4251892d9ad5d609863fb7", size = 19938602, upload-time = "2024-07-21T13:35:38.996Z" }, + { url = "https://files.pythonhosted.org/packages/34/b6/a88a9953d0be231c67aa0b3714d6138507490753beaa927f0b33f20cdca2/numpy-2.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f87fec1f9bc1efd23f4227becff04bd0e979e23ca50cc92ec88b38489db3b55", size = 14425300, upload-time = "2024-07-21T13:36:01.419Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e2/e763e102bea9c188b43ea144a91c22bec669736889a6e0be0235d64666d7/numpy-2.0.1-cp311-cp311-win32.whl", hash = "sha256:36d3a9405fd7c511804dc56fc32974fa5533bdeb3cd1604d6b8ff1d292b819c4", size = 6467652, upload-time = "2024-07-21T13:36:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/3d/67/928e8f0d5c7fd32f32fb5caf92b186a1b3826dbaf5a294e13a976d6c38b6/numpy-2.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:08458fbf403bff5e2b45f08eda195d4b0c9b35682311da5a5a0a0925b11b9bd8", size = 16559280, upload-time = "2024-07-21T13:36:44.641Z" }, ] [[package]] name = "packaging" version = "24.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/65/50db4dda066951078f0a96cf12f4b9ada6e4b811516bf0262c0f4f7064d4/packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", size = 148788 } +sdist = { url = "https://files.pythonhosted.org/packages/51/65/50db4dda066951078f0a96cf12f4b9ada6e4b811516bf0262c0f4f7064d4/packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", size = 148788, upload-time = "2024-06-09T23:19:24.956Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985 }, + { url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985, upload-time = "2024-06-09T23:19:21.909Z" }, ] [[package]] name = "paginate" version = "0.5.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/68/58/e670a947136fdcece8ac5376b3df1369d29e4f6659b0c9b358605b115e9e/paginate-0.5.6.tar.gz", hash = "sha256:5e6007b6a9398177a7e1648d04fdd9f8c9766a1a945bceac82f1929e8c78af2d", size = 12840 } +sdist = { url = "https://files.pythonhosted.org/packages/68/58/e670a947136fdcece8ac5376b3df1369d29e4f6659b0c9b358605b115e9e/paginate-0.5.6.tar.gz", hash = "sha256:5e6007b6a9398177a7e1648d04fdd9f8c9766a1a945bceac82f1929e8c78af2d", size = 12840, upload-time = "2016-11-22T12:54:04.503Z" } [[package]] name = "pathspec" version = "0.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "pillow" +version = "12.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" }, + { url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" }, + { url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" }, + { url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" }, + { url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" }, + { url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" }, + { url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" }, + { url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" }, + { url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" }, + { url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" }, +] + +[[package]] +name = "pip" +version = "25.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/6e/74a3f0179a4a73a53d66ce57fdb4de0080a8baa1de0063de206d6167acc2/pip-25.3.tar.gz", hash = "sha256:8d0538dbbd7babbd207f261ed969c65de439f6bc9e5dbd3b3b9a77f25d95f343", size = 1803014, upload-time = "2025-10-25T00:55:41.394Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, + { url = "https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl", hash = "sha256:9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd", size = 1778622, upload-time = "2025-10-25T00:55:39.247Z" }, ] [[package]] name = "platformdirs" version = "4.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/52/0763d1d976d5c262df53ddda8d8d4719eedf9594d046f117c25a27261a19/platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3", size = 20916 } +sdist = { url = "https://files.pythonhosted.org/packages/f5/52/0763d1d976d5c262df53ddda8d8d4719eedf9594d046f117c25a27261a19/platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3", size = 20916, upload-time = "2024-05-15T03:18:23.372Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/13/2aa1f0e1364feb2c9ef45302f387ac0bd81484e9c9a4c5688a322fbdfd08/platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee", size = 18146 }, + { url = "https://files.pythonhosted.org/packages/68/13/2aa1f0e1364feb2c9ef45302f387ac0bd81484e9c9a4c5688a322fbdfd08/platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee", size = 18146, upload-time = "2024-05-15T03:18:21.209Z" }, ] [[package]] name = "pluggy" version = "1.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, ] [[package]] name = "pycodestyle" version = "2.12.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/10/56/52d8283e1a1c85695291040192776931782831e21117c84311cbdd63f70c/pycodestyle-2.12.0.tar.gz", hash = "sha256:442f950141b4f43df752dd303511ffded3a04c2b6fb7f65980574f0c31e6e79c", size = 39055 } +sdist = { url = "https://files.pythonhosted.org/packages/10/56/52d8283e1a1c85695291040192776931782831e21117c84311cbdd63f70c/pycodestyle-2.12.0.tar.gz", hash = "sha256:442f950141b4f43df752dd303511ffded3a04c2b6fb7f65980574f0c31e6e79c", size = 39055, upload-time = "2024-06-15T21:28:55.576Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/c4/bf8ede2d1641e0a2e027c6d0c7060e00332851ea772cc5cee42a4a207707/pycodestyle-2.12.0-py2.py3-none-any.whl", hash = "sha256:949a39f6b86c3e1515ba1787c2022131d165a8ad271b11370a8819aa070269e4", size = 31221 }, + { url = "https://files.pythonhosted.org/packages/55/c4/bf8ede2d1641e0a2e027c6d0c7060e00332851ea772cc5cee42a4a207707/pycodestyle-2.12.0-py2.py3-none-any.whl", hash = "sha256:949a39f6b86c3e1515ba1787c2022131d165a8ad271b11370a8819aa070269e4", size = 31221, upload-time = "2024-06-15T21:28:54.101Z" }, ] [[package]] name = "pycparser" version = "2.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, ] [[package]] name = "pygments" version = "2.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } +sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905, upload-time = "2024-05-04T13:42:02.013Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, + { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513, upload-time = "2024-05-04T13:41:57.345Z" }, ] [[package]] @@ -559,9 +706,9 @@ dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/d3/fb86beeaa4416f73a28a5e8d440976b7cada2b2d7b5e715b2bd849d4de32/pymdown_extensions-10.9.tar.gz", hash = "sha256:6ff740bcd99ec4172a938970d42b96128bdc9d4b9bcad72494f29921dc69b753", size = 812128 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/d3/fb86beeaa4416f73a28a5e8d440976b7cada2b2d7b5e715b2bd849d4de32/pymdown_extensions-10.9.tar.gz", hash = "sha256:6ff740bcd99ec4172a938970d42b96128bdc9d4b9bcad72494f29921dc69b753", size = 812128, upload-time = "2024-07-27T19:13:08.302Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/41/18b5dc5e97ec3ff1c2f51d372e570a9fbe231f1124dcc36dbc6b47f93058/pymdown_extensions-10.9-py3-none-any.whl", hash = "sha256:d323f7e90d83c86113ee78f3fe62fc9dee5f56b54d912660703ea1816fed5626", size = 250954 }, + { url = "https://files.pythonhosted.org/packages/7b/41/18b5dc5e97ec3ff1c2f51d372e570a9fbe231f1124dcc36dbc6b47f93058/pymdown_extensions-10.9-py3-none-any.whl", hash = "sha256:d323f7e90d83c86113ee78f3fe62fc9dee5f56b54d912660703ea1816fed5626", size = 250954, upload-time = "2024-07-27T19:13:06.009Z" }, ] [[package]] @@ -574,18 +721,18 @@ dependencies = [ { name = "numpy" }, { name = "tifffile" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fa/77/7e8d33d76d0bd6091f88a3c49030962da9f49797585eb24c427bffa4fa17/pyometiff-1.0.0.tar.gz", hash = "sha256:596b7a7377a5f2e50292aa52dd22e6347a2d2f21577e93d1fcdcd63942e597f7", size = 37703 } +sdist = { url = "https://files.pythonhosted.org/packages/fa/77/7e8d33d76d0bd6091f88a3c49030962da9f49797585eb24c427bffa4fa17/pyometiff-1.0.0.tar.gz", hash = "sha256:596b7a7377a5f2e50292aa52dd22e6347a2d2f21577e93d1fcdcd63942e597f7", size = 37703, upload-time = "2024-02-03T00:45:09.595Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/ef/2393ce39fb4f730acc930d0646f9d320ccad10032cb6bd5902f4b2429b11/pyometiff-1.0.0-py3-none-any.whl", hash = "sha256:6c274786f32b9bd662105af37419c7f2f7ee6194bc1a7283b4ab2a00c53cf632", size = 37405 }, + { url = "https://files.pythonhosted.org/packages/56/ef/2393ce39fb4f730acc930d0646f9d320ccad10032cb6bd5902f4b2429b11/pyometiff-1.0.0-py3-none-any.whl", hash = "sha256:6c274786f32b9bd662105af37419c7f2f7ee6194bc1a7283b4ab2a00c53cf632", size = 37405, upload-time = "2024-02-03T00:45:07.616Z" }, ] [[package]] name = "pyparsing" version = "3.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/3a/31fd28064d016a2182584d579e033ec95b809d8e220e74c4af6f0f2e8842/pyparsing-3.1.2.tar.gz", hash = "sha256:a1bac0ce561155ecc3ed78ca94d3c9378656ad4c94c1270de543f621420f94ad", size = 889571 } +sdist = { url = "https://files.pythonhosted.org/packages/46/3a/31fd28064d016a2182584d579e033ec95b809d8e220e74c4af6f0f2e8842/pyparsing-3.1.2.tar.gz", hash = "sha256:a1bac0ce561155ecc3ed78ca94d3c9378656ad4c94c1270de543f621420f94ad", size = 889571, upload-time = "2024-03-06T07:25:54.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/ea/6d76df31432a0e6fdf81681a895f009a4bb47b3c39036db3e1b528191d52/pyparsing-3.1.2-py3-none-any.whl", hash = "sha256:f9db75911801ed778fe61bb643079ff86601aca99fcae6345aa67292038fb742", size = 103245 }, + { url = "https://files.pythonhosted.org/packages/9d/ea/6d76df31432a0e6fdf81681a895f009a4bb47b3c39036db3e1b528191d52/pyparsing-3.1.2-py3-none-any.whl", hash = "sha256:f9db75911801ed778fe61bb643079ff86601aca99fcae6345aa67292038fb742", size = 103245, upload-time = "2024-03-06T07:25:50.845Z" }, ] [[package]] @@ -598,9 +745,9 @@ dependencies = [ { name = "packaging" }, { name = "pluggy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/8c/9862305bdcd6020bc7b45b1b5e7397a6caf1a33d3025b9a003b39075ffb2/pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce", size = 1439314 } +sdist = { url = "https://files.pythonhosted.org/packages/b4/8c/9862305bdcd6020bc7b45b1b5e7397a6caf1a33d3025b9a003b39075ffb2/pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce", size = 1439314, upload-time = "2024-07-25T10:40:00.159Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/f9/cf155cf32ca7d6fa3601bc4c5dd19086af4b320b706919d48a4c79081cf9/pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5", size = 341802 }, + { url = "https://files.pythonhosted.org/packages/0f/f9/cf155cf32ca7d6fa3601bc4c5dd19086af4b320b706919d48a4c79081cf9/pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5", size = 341802, upload-time = "2024-07-25T10:39:57.834Z" }, ] [[package]] @@ -611,9 +758,9 @@ dependencies = [ { name = "coverage", extra = ["toml"] }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042 } +sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042, upload-time = "2024-03-24T20:16:34.856Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990 }, + { url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990, upload-time = "2024-03-24T20:16:32.444Z" }, ] [[package]] @@ -624,7 +771,7 @@ dependencies = [ { name = "pytest" }, { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7e/3b/317cc04e77d707d338540ca67b619df8f247f3f4c9f40e67bf5ea503ad94/pytest-dependency-0.6.0.tar.gz", hash = "sha256:934b0e6a39d95995062c193f7eaeed8a8ffa06ff1bcef4b62b0dc74a708bacc1", size = 19499 } +sdist = { url = "https://files.pythonhosted.org/packages/7e/3b/317cc04e77d707d338540ca67b619df8f247f3f4c9f40e67bf5ea503ad94/pytest-dependency-0.6.0.tar.gz", hash = "sha256:934b0e6a39d95995062c193f7eaeed8a8ffa06ff1bcef4b62b0dc74a708bacc1", size = 19499, upload-time = "2023-12-31T20:38:54.991Z" } [[package]] name = "python-dateutil" @@ -633,25 +780,25 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] name = "pyyaml" version = "6.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/e5/af35f7ea75cf72f2cd079c95ee16797de7cd71f29ea7c68ae5ce7be1eda0/PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", size = 125201 } +sdist = { url = "https://files.pythonhosted.org/packages/cd/e5/af35f7ea75cf72f2cd079c95ee16797de7cd71f29ea7c68ae5ce7be1eda0/PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", size = 125201, upload-time = "2023-07-18T00:00:23.308Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/0d/26fb23e8863e0aeaac0c64e03fd27367ad2ae3f3cccf3798ee98ce160368/PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007", size = 187867 }, - { url = "https://files.pythonhosted.org/packages/28/09/55f715ddbf95a054b764b547f617e22f1d5e45d83905660e9a088078fe67/PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab", size = 167530 }, - { url = "https://files.pythonhosted.org/packages/5e/94/7d5ee059dfb92ca9e62f4057dcdec9ac08a9e42679644854dc01177f8145/PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d", size = 732244 }, - { url = "https://files.pythonhosted.org/packages/06/92/e0224aa6ebf9dc54a06a4609da37da40bb08d126f5535d81bff6b417b2ae/PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", size = 752871 }, - { url = "https://files.pythonhosted.org/packages/7b/5e/efd033ab7199a0b2044dab3b9f7a4f6670e6a52c089de572e928d2873b06/PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673", size = 757729 }, - { url = "https://files.pythonhosted.org/packages/03/5c/c4671451b2f1d76ebe352c0945d4cd13500adb5d05f5a51ee296d80152f7/PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b", size = 748528 }, - { url = "https://files.pythonhosted.org/packages/73/9c/766e78d1efc0d1fca637a6b62cea1b4510a7fb93617eb805223294fef681/PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741", size = 130286 }, - { url = "https://files.pythonhosted.org/packages/b3/34/65bb4b2d7908044963ebf614fe0fdb080773fc7030d7e39c8d3eddcd4257/PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34", size = 144699 }, + { url = "https://files.pythonhosted.org/packages/ec/0d/26fb23e8863e0aeaac0c64e03fd27367ad2ae3f3cccf3798ee98ce160368/PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007", size = 187867, upload-time = "2023-07-17T23:57:34.35Z" }, + { url = "https://files.pythonhosted.org/packages/28/09/55f715ddbf95a054b764b547f617e22f1d5e45d83905660e9a088078fe67/PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab", size = 167530, upload-time = "2023-07-17T23:57:36.975Z" }, + { url = "https://files.pythonhosted.org/packages/5e/94/7d5ee059dfb92ca9e62f4057dcdec9ac08a9e42679644854dc01177f8145/PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d", size = 732244, upload-time = "2023-07-17T23:57:43.774Z" }, + { url = "https://files.pythonhosted.org/packages/06/92/e0224aa6ebf9dc54a06a4609da37da40bb08d126f5535d81bff6b417b2ae/PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", size = 752871, upload-time = "2023-07-17T23:57:51.921Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5e/efd033ab7199a0b2044dab3b9f7a4f6670e6a52c089de572e928d2873b06/PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673", size = 757729, upload-time = "2023-07-17T23:57:59.865Z" }, + { url = "https://files.pythonhosted.org/packages/03/5c/c4671451b2f1d76ebe352c0945d4cd13500adb5d05f5a51ee296d80152f7/PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b", size = 748528, upload-time = "2023-08-28T18:43:23.207Z" }, + { url = "https://files.pythonhosted.org/packages/73/9c/766e78d1efc0d1fca637a6b62cea1b4510a7fb93617eb805223294fef681/PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741", size = 130286, upload-time = "2023-07-17T23:58:02.964Z" }, + { url = "https://files.pythonhosted.org/packages/b3/34/65bb4b2d7908044963ebf614fe0fdb080773fc7030d7e39c8d3eddcd4257/PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34", size = 144699, upload-time = "2023-07-17T23:58:05.586Z" }, ] [[package]] @@ -661,32 +808,32 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/8e/da1c6c58f751b70f8ceb1eb25bc25d524e8f14fe16edcce3f4e3ba08629c/pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb", size = 5631 } +sdist = { url = "https://files.pythonhosted.org/packages/fb/8e/da1c6c58f751b70f8ceb1eb25bc25d524e8f14fe16edcce3f4e3ba08629c/pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb", size = 5631, upload-time = "2020-11-12T02:38:26.239Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/66/bbb1dd374f5c870f59c5bb1db0e18cbe7fa739415a24cbd95b2d1f5ae0c4/pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069", size = 3911 }, + { url = "https://files.pythonhosted.org/packages/5a/66/bbb1dd374f5c870f59c5bb1db0e18cbe7fa739415a24cbd95b2d1f5ae0c4/pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069", size = 3911, upload-time = "2020-11-12T02:38:24.638Z" }, ] [[package]] name = "regex" version = "2024.7.24" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3f/51/64256d0dc72816a4fe3779449627c69ec8fee5a5625fd60ba048f53b3478/regex-2024.7.24.tar.gz", hash = "sha256:9cfd009eed1a46b27c14039ad5bbc5e71b6367c5b2e6d5f5da0ea91600817506", size = 393485 } +sdist = { url = "https://files.pythonhosted.org/packages/3f/51/64256d0dc72816a4fe3779449627c69ec8fee5a5625fd60ba048f53b3478/regex-2024.7.24.tar.gz", hash = "sha256:9cfd009eed1a46b27c14039ad5bbc5e71b6367c5b2e6d5f5da0ea91600817506", size = 393485, upload-time = "2024-07-24T21:51:16.135Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/ec/261f8434a47685d61e59a4ef3d9ce7902af521219f3ebd2194c7adb171a6/regex-2024.7.24-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:382281306e3adaaa7b8b9ebbb3ffb43358a7bbf585fa93821300a418bb975281", size = 470810 }, - { url = "https://files.pythonhosted.org/packages/f0/47/f33b1cac88841f95fff862476a9e875d9a10dae6912a675c6f13c128e5d9/regex-2024.7.24-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4fdd1384619f406ad9037fe6b6eaa3de2749e2e12084abc80169e8e075377d3b", size = 282126 }, - { url = "https://files.pythonhosted.org/packages/fc/1b/256ca4e2d5041c0aa2f1dc222f04412b796346ab9ce2aa5147405a9457b4/regex-2024.7.24-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3d974d24edb231446f708c455fd08f94c41c1ff4f04bcf06e5f36df5ef50b95a", size = 278920 }, - { url = "https://files.pythonhosted.org/packages/91/03/4603ec057c0bafd2f6f50b0bdda4b12a0ff81022decf1de007b485c356a6/regex-2024.7.24-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2ec4419a3fe6cf8a4795752596dfe0adb4aea40d3683a132bae9c30b81e8d73", size = 785420 }, - { url = "https://files.pythonhosted.org/packages/75/f8/13b111fab93e6273e26de2926345e5ecf6ddad1e44c4d419d7b0924f9c52/regex-2024.7.24-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb563dd3aea54c797adf513eeec819c4213d7dbfc311874eb4fd28d10f2ff0f2", size = 828164 }, - { url = "https://files.pythonhosted.org/packages/4a/80/bc3b9d31bd47ff578758af929af0ac1d6169b247e26fa6e87764007f3d93/regex-2024.7.24-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:45104baae8b9f67569f0f1dca5e1f1ed77a54ae1cd8b0b07aba89272710db61e", size = 812621 }, - { url = "https://files.pythonhosted.org/packages/8b/77/92d4a14530900d46dddc57b728eea65d723cc9fcfd07b96c2c141dabba84/regex-2024.7.24-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:994448ee01864501912abf2bad9203bffc34158e80fe8bfb5b031f4f8e16da51", size = 786609 }, - { url = "https://files.pythonhosted.org/packages/35/58/06695fd8afad4c8ed0a53ec5e222156398b9fe5afd58887ab94ea68e4d16/regex-2024.7.24-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fac296f99283ac232d8125be932c5cd7644084a30748fda013028c815ba3364", size = 775290 }, - { url = "https://files.pythonhosted.org/packages/1b/0f/50b97ee1fc6965744b9e943b5c0f3740792ab54792df73d984510964ef29/regex-2024.7.24-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7e37e809b9303ec3a179085415cb5f418ecf65ec98cdfe34f6a078b46ef823ee", size = 772849 }, - { url = "https://files.pythonhosted.org/packages/8f/64/565ff6cf241586ab7ae76bb4138c4d29bc1d1780973b457c2db30b21809a/regex-2024.7.24-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:01b689e887f612610c869421241e075c02f2e3d1ae93a037cb14f88ab6a8934c", size = 778428 }, - { url = "https://files.pythonhosted.org/packages/e5/fe/4ceabf4382e44e1e096ac46fd5e3bca490738b24157116a48270fd542e88/regex-2024.7.24-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f6442f0f0ff81775eaa5b05af8a0ffa1dda36e9cf6ec1e0d3d245e8564b684ce", size = 849436 }, - { url = "https://files.pythonhosted.org/packages/68/23/1868e40d6b594843fd1a3498ffe75d58674edfc90d95e18dd87865b93bf2/regex-2024.7.24-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:871e3ab2838fbcb4e0865a6e01233975df3a15e6fce93b6f99d75cacbd9862d1", size = 849484 }, - { url = "https://files.pythonhosted.org/packages/f3/52/bff76de2f6e2bc05edce3abeb7e98e6309aa022fc06071100a0216fbeb50/regex-2024.7.24-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c918b7a1e26b4ab40409820ddccc5d49871a82329640f5005f73572d5eaa9b5e", size = 776712 }, - { url = "https://files.pythonhosted.org/packages/f2/72/70ade7b0b5fe5c6df38fdfa2a5a8273e3ea6a10b772aa671b7e889e78bae/regex-2024.7.24-cp311-cp311-win32.whl", hash = "sha256:2dfbb8baf8ba2c2b9aa2807f44ed272f0913eeeba002478c4577b8d29cde215c", size = 257716 }, - { url = "https://files.pythonhosted.org/packages/04/4d/80e04f4e27ab0cbc9096e2d10696da6d9c26a39b60db52670fd57614fea5/regex-2024.7.24-cp311-cp311-win_amd64.whl", hash = "sha256:538d30cd96ed7d1416d3956f94d54e426a8daf7c14527f6e0d6d425fcb4cca52", size = 269662 }, + { url = "https://files.pythonhosted.org/packages/cb/ec/261f8434a47685d61e59a4ef3d9ce7902af521219f3ebd2194c7adb171a6/regex-2024.7.24-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:382281306e3adaaa7b8b9ebbb3ffb43358a7bbf585fa93821300a418bb975281", size = 470810, upload-time = "2024-07-24T21:47:22.913Z" }, + { url = "https://files.pythonhosted.org/packages/f0/47/f33b1cac88841f95fff862476a9e875d9a10dae6912a675c6f13c128e5d9/regex-2024.7.24-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4fdd1384619f406ad9037fe6b6eaa3de2749e2e12084abc80169e8e075377d3b", size = 282126, upload-time = "2024-07-24T21:47:25.792Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1b/256ca4e2d5041c0aa2f1dc222f04412b796346ab9ce2aa5147405a9457b4/regex-2024.7.24-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3d974d24edb231446f708c455fd08f94c41c1ff4f04bcf06e5f36df5ef50b95a", size = 278920, upload-time = "2024-07-24T21:47:28.47Z" }, + { url = "https://files.pythonhosted.org/packages/91/03/4603ec057c0bafd2f6f50b0bdda4b12a0ff81022decf1de007b485c356a6/regex-2024.7.24-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2ec4419a3fe6cf8a4795752596dfe0adb4aea40d3683a132bae9c30b81e8d73", size = 785420, upload-time = "2024-07-24T21:47:31.075Z" }, + { url = "https://files.pythonhosted.org/packages/75/f8/13b111fab93e6273e26de2926345e5ecf6ddad1e44c4d419d7b0924f9c52/regex-2024.7.24-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb563dd3aea54c797adf513eeec819c4213d7dbfc311874eb4fd28d10f2ff0f2", size = 828164, upload-time = "2024-07-24T21:47:33.857Z" }, + { url = "https://files.pythonhosted.org/packages/4a/80/bc3b9d31bd47ff578758af929af0ac1d6169b247e26fa6e87764007f3d93/regex-2024.7.24-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:45104baae8b9f67569f0f1dca5e1f1ed77a54ae1cd8b0b07aba89272710db61e", size = 812621, upload-time = "2024-07-24T21:47:37.658Z" }, + { url = "https://files.pythonhosted.org/packages/8b/77/92d4a14530900d46dddc57b728eea65d723cc9fcfd07b96c2c141dabba84/regex-2024.7.24-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:994448ee01864501912abf2bad9203bffc34158e80fe8bfb5b031f4f8e16da51", size = 786609, upload-time = "2024-07-24T21:47:40.483Z" }, + { url = "https://files.pythonhosted.org/packages/35/58/06695fd8afad4c8ed0a53ec5e222156398b9fe5afd58887ab94ea68e4d16/regex-2024.7.24-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fac296f99283ac232d8125be932c5cd7644084a30748fda013028c815ba3364", size = 775290, upload-time = "2024-07-24T21:47:43.792Z" }, + { url = "https://files.pythonhosted.org/packages/1b/0f/50b97ee1fc6965744b9e943b5c0f3740792ab54792df73d984510964ef29/regex-2024.7.24-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7e37e809b9303ec3a179085415cb5f418ecf65ec98cdfe34f6a078b46ef823ee", size = 772849, upload-time = "2024-07-24T21:47:47.04Z" }, + { url = "https://files.pythonhosted.org/packages/8f/64/565ff6cf241586ab7ae76bb4138c4d29bc1d1780973b457c2db30b21809a/regex-2024.7.24-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:01b689e887f612610c869421241e075c02f2e3d1ae93a037cb14f88ab6a8934c", size = 778428, upload-time = "2024-07-24T21:47:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/e5/fe/4ceabf4382e44e1e096ac46fd5e3bca490738b24157116a48270fd542e88/regex-2024.7.24-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f6442f0f0ff81775eaa5b05af8a0ffa1dda36e9cf6ec1e0d3d245e8564b684ce", size = 849436, upload-time = "2024-07-24T21:47:53.446Z" }, + { url = "https://files.pythonhosted.org/packages/68/23/1868e40d6b594843fd1a3498ffe75d58674edfc90d95e18dd87865b93bf2/regex-2024.7.24-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:871e3ab2838fbcb4e0865a6e01233975df3a15e6fce93b6f99d75cacbd9862d1", size = 849484, upload-time = "2024-07-24T21:47:56.969Z" }, + { url = "https://files.pythonhosted.org/packages/f3/52/bff76de2f6e2bc05edce3abeb7e98e6309aa022fc06071100a0216fbeb50/regex-2024.7.24-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c918b7a1e26b4ab40409820ddccc5d49871a82329640f5005f73572d5eaa9b5e", size = 776712, upload-time = "2024-07-24T21:47:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/f2/72/70ade7b0b5fe5c6df38fdfa2a5a8273e3ea6a10b772aa671b7e889e78bae/regex-2024.7.24-cp311-cp311-win32.whl", hash = "sha256:2dfbb8baf8ba2c2b9aa2807f44ed272f0913eeeba002478c4577b8d29cde215c", size = 257716, upload-time = "2024-07-24T21:48:03.094Z" }, + { url = "https://files.pythonhosted.org/packages/04/4d/80e04f4e27ab0cbc9096e2d10696da6d9c26a39b60db52670fd57614fea5/regex-2024.7.24-cp311-cp311-win_amd64.whl", hash = "sha256:538d30cd96ed7d1416d3956f94d54e426a8daf7c14527f6e0d6d425fcb4cca52", size = 269662, upload-time = "2024-07-24T21:48:06.441Z" }, ] [[package]] @@ -699,18 +846,18 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, ] [[package]] name = "setuptools" version = "72.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/11/487b18cc768e2ae25a919f230417983c8d5afa1b6ee0abd8b6db0b89fa1d/setuptools-72.1.0.tar.gz", hash = "sha256:8d243eff56d095e5817f796ede6ae32941278f542e0f941867cc05ae52b162ec", size = 2419487 } +sdist = { url = "https://files.pythonhosted.org/packages/5e/11/487b18cc768e2ae25a919f230417983c8d5afa1b6ee0abd8b6db0b89fa1d/setuptools-72.1.0.tar.gz", hash = "sha256:8d243eff56d095e5817f796ede6ae32941278f542e0f941867cc05ae52b162ec", size = 2419487, upload-time = "2024-07-29T15:11:43.458Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/58/e0ef3b9974a04ce9cde2a7a33881ddcb2d68450803745804545cdd8d258f/setuptools-72.1.0-py3-none-any.whl", hash = "sha256:5a03e1860cf56bb6ef48ce186b0e557fdba433237481a9a625176c2831be15d1", size = 2337965 }, + { url = "https://files.pythonhosted.org/packages/e1/58/e0ef3b9974a04ce9cde2a7a33881ddcb2d68450803745804545cdd8d258f/setuptools-72.1.0-py3-none-any.whl", hash = "sha256:5a03e1860cf56bb6ef48ce186b0e557fdba433237481a9a625176c2831be15d1", size = 2337965, upload-time = "2024-07-29T15:11:37.999Z" }, ] [[package]] @@ -718,20 +865,20 @@ name = "simpleitk" version = "2.3.1" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/3a/bb0f2e759f288c89923f9cf099daf81a1350af3035fd992288943ac49709/SimpleITK-2.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:df43581c6984af5353730834a95116cdd8dcaef1dc13e4e9a326f608f8fba74a", size = 44888897 }, - { url = "https://files.pythonhosted.org/packages/06/09/b7a2ad87128d7da4989467b368c6afb17c6d78448a7da90f6ab27a6e2af9/SimpleITK-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bd1c3d7f65bf5855013121bd9f2a683f4c429b746f5cc41f84af08dd28c92573", size = 29856558 }, - { url = "https://files.pythonhosted.org/packages/e6/45/41a228e7c54edb52397cf4b0b3922f04dd15d247d32cdce08badd410e520/SimpleITK-2.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d088fbfbbd639aebe99aed0d9cf69364e2e8e7f4771fa2acc1017f1126a497c", size = 47951076 }, - { url = "https://files.pythonhosted.org/packages/60/6c/466f3f44070844b5e83a43310c2ec464c0bcd346244615d8c734c527490e/SimpleITK-2.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cd45cf257e1a4469576e90fd0c476685b3b66d1f471d34677dd7f3876601b6", size = 52655126 }, - { url = "https://files.pythonhosted.org/packages/b3/e6/8510b79a8ab93248fb897c0ca3bc2c65a6cfca478ad7d5a53e950dd9b17b/SimpleITK-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:2aec554a4656dc84195711a8ebfe89477aa66dce2d8c2fd81890ea96ecb725fb", size = 18074454 }, + { url = "https://files.pythonhosted.org/packages/af/3a/bb0f2e759f288c89923f9cf099daf81a1350af3035fd992288943ac49709/SimpleITK-2.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:df43581c6984af5353730834a95116cdd8dcaef1dc13e4e9a326f608f8fba74a", size = 44888897, upload-time = "2023-11-06T14:56:03.652Z" }, + { url = "https://files.pythonhosted.org/packages/06/09/b7a2ad87128d7da4989467b368c6afb17c6d78448a7da90f6ab27a6e2af9/SimpleITK-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bd1c3d7f65bf5855013121bd9f2a683f4c429b746f5cc41f84af08dd28c92573", size = 29856558, upload-time = "2023-11-06T14:58:39.382Z" }, + { url = "https://files.pythonhosted.org/packages/e6/45/41a228e7c54edb52397cf4b0b3922f04dd15d247d32cdce08badd410e520/SimpleITK-2.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d088fbfbbd639aebe99aed0d9cf69364e2e8e7f4771fa2acc1017f1126a497c", size = 47951076, upload-time = "2023-11-06T15:01:25.611Z" }, + { url = "https://files.pythonhosted.org/packages/60/6c/466f3f44070844b5e83a43310c2ec464c0bcd346244615d8c734c527490e/SimpleITK-2.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cd45cf257e1a4469576e90fd0c476685b3b66d1f471d34677dd7f3876601b6", size = 52655126, upload-time = "2023-11-06T14:56:07.352Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e6/8510b79a8ab93248fb897c0ca3bc2c65a6cfca478ad7d5a53e950dd9b17b/SimpleITK-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:2aec554a4656dc84195711a8ebfe89477aa66dce2d8c2fd81890ea96ecb725fb", size = 18074454, upload-time = "2023-11-06T14:56:10.795Z" }, ] [[package]] name = "six" version = "1.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041 } +sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041, upload-time = "2021-05-05T14:18:18.379Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 }, + { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053, upload-time = "2021-05-05T14:18:17.237Z" }, ] [[package]] @@ -741,37 +888,37 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/a0/8f93f5281f46763b147ce2c7b37fa1cc675fb09c5ef347a8f5a36997c93d/tifffile-2024.7.24.tar.gz", hash = "sha256:723456ebf2b4918878ae05a7b50fa366ff3b3a686293317eb7a0f294c3eea050", size = 362806 } +sdist = { url = "https://files.pythonhosted.org/packages/a3/a0/8f93f5281f46763b147ce2c7b37fa1cc675fb09c5ef347a8f5a36997c93d/tifffile-2024.7.24.tar.gz", hash = "sha256:723456ebf2b4918878ae05a7b50fa366ff3b3a686293317eb7a0f294c3eea050", size = 362806, upload-time = "2024-07-24T04:11:14.201Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/a9/f7b3fd6c73e0ac29e7f9c9c86c3b7367b182a3dd60495da7b8129e6df681/tifffile-2024.7.24-py3-none-any.whl", hash = "sha256:f5cce1a915c37bc44ae4a792e3b42c07a30a3fa88406f5c6060a3de076487ed1", size = 226196 }, + { url = "https://files.pythonhosted.org/packages/05/a9/f7b3fd6c73e0ac29e7f9c9c86c3b7367b182a3dd60495da7b8129e6df681/tifffile-2024.7.24-py3-none-any.whl", hash = "sha256:f5cce1a915c37bc44ae4a792e3b42c07a30a3fa88406f5c6060a3de076487ed1", size = 226196, upload-time = "2024-07-24T04:11:11.197Z" }, ] [[package]] name = "tomli" version = "2.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, - { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, - { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, - { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, - { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, - { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, - { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, - { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, - { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, - { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, - { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] [[package]] name = "tomlkit" version = "0.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4b/34/f5f4fbc6b329c948a90468dd423aaa3c3bfc1e07d5a76deec269110f2f6e/tomlkit-0.13.0.tar.gz", hash = "sha256:08ad192699734149f5b97b45f1f18dad7eb1b6d16bc72ad0c2335772650d7b72", size = 191792 } +sdist = { url = "https://files.pythonhosted.org/packages/4b/34/f5f4fbc6b329c948a90468dd423aaa3c3bfc1e07d5a76deec269110f2f6e/tomlkit-0.13.0.tar.gz", hash = "sha256:08ad192699734149f5b97b45f1f18dad7eb1b6d16bc72ad0c2335772650d7b72", size = 191792, upload-time = "2024-07-10T09:25:56.381Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/7c/b753bf603852cab0a660da6e81f4ea5d2ca0f0b2b4870766d7aa9bceb7a2/tomlkit-0.13.0-py3-none-any.whl", hash = "sha256:7075d3042d03b80f603482d69bf0c8f345c2b30e41699fd8883227f89972b264", size = 37770 }, + { url = "https://files.pythonhosted.org/packages/fd/7c/b753bf603852cab0a660da6e81f4ea5d2ca0f0b2b4870766d7aa9bceb7a2/tomlkit-0.13.0-py3-none-any.whl", hash = "sha256:7075d3042d03b80f603482d69bf0c8f345c2b30e41699fd8883227f89972b264", size = 37770, upload-time = "2024-07-10T09:25:54.676Z" }, ] [[package]] @@ -781,57 +928,57 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/1e/626c2d87c29a35fadc8de5624f4302e1ee56cff380d282d62cb3780e6620/transforms3d-0.4.2.tar.gz", hash = "sha256:e8b5df30eaedbee556e81c6938e55aab5365894e47d0a17615d7db7fd2393680", size = 1368797 } +sdist = { url = "https://files.pythonhosted.org/packages/d5/1e/626c2d87c29a35fadc8de5624f4302e1ee56cff380d282d62cb3780e6620/transforms3d-0.4.2.tar.gz", hash = "sha256:e8b5df30eaedbee556e81c6938e55aab5365894e47d0a17615d7db7fd2393680", size = 1368797, upload-time = "2024-06-17T11:43:33.231Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/7a/f38385f1b2d5f54221baf1db3d6371dc6eef8041d95abff39576c694e9d9/transforms3d-0.4.2-py3-none-any.whl", hash = "sha256:1c70399d9e9473ecc23311fd947f727f7c69ed0b063244828c383aa1aefa5941", size = 1376759 }, + { url = "https://files.pythonhosted.org/packages/61/7a/f38385f1b2d5f54221baf1db3d6371dc6eef8041d95abff39576c694e9d9/transforms3d-0.4.2-py3-none-any.whl", hash = "sha256:1c70399d9e9473ecc23311fd947f727f7c69ed0b063244828c383aa1aefa5941", size = 1376759, upload-time = "2024-06-20T11:09:19.43Z" }, ] [[package]] name = "urllib3" version = "2.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/6d/fa469ae21497ddc8bc93e5877702dca7cb8f911e337aca7452b5724f1bb6/urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168", size = 292266 } +sdist = { url = "https://files.pythonhosted.org/packages/43/6d/fa469ae21497ddc8bc93e5877702dca7cb8f911e337aca7452b5724f1bb6/urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168", size = 292266, upload-time = "2024-06-17T13:40:11.401Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/1c/89ffc63a9605b583d5df2be791a27bc1a42b7c32bab68d3c8f2f73a98cd4/urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472", size = 121444 }, + { url = "https://files.pythonhosted.org/packages/ca/1c/89ffc63a9605b583d5df2be791a27bc1a42b7c32bab68d3c8f2f73a98cd4/urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472", size = 121444, upload-time = "2024-06-17T13:40:07.795Z" }, ] [[package]] name = "verspec" version = "0.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/44/8126f9f0c44319b2efc65feaad589cadef4d77ece200ae3c9133d58464d0/verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e", size = 27123 } +sdist = { url = "https://files.pythonhosted.org/packages/e7/44/8126f9f0c44319b2efc65feaad589cadef4d77ece200ae3c9133d58464d0/verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e", size = 27123, upload-time = "2020-11-30T02:24:09.646Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31", size = 19640 }, + { url = "https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31", size = 19640, upload-time = "2020-11-30T02:24:08.387Z" }, ] [[package]] name = "watchdog" version = "4.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/f9/b01e4632aed9a6ecc2b3e501feffd3af5aa0eb4e3b0283fc9525bf503c38/watchdog-4.0.1.tar.gz", hash = "sha256:eebaacf674fa25511e8867028d281e602ee6500045b57f43b08778082f7f8b44", size = 126583 } +sdist = { url = "https://files.pythonhosted.org/packages/1b/f9/b01e4632aed9a6ecc2b3e501feffd3af5aa0eb4e3b0283fc9525bf503c38/watchdog-4.0.1.tar.gz", hash = "sha256:eebaacf674fa25511e8867028d281e602ee6500045b57f43b08778082f7f8b44", size = 126583, upload-time = "2024-05-23T16:12:28.625Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/bc/a1ce8b77eede5a2f4fbcdc923079eb85b7c6e0f5e366ad06661b4dd807e1/watchdog-4.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:17e32f147d8bf9657e0922c0940bcde863b894cd871dbb694beb6704cfbd2fb5", size = 101627 }, - { url = "https://files.pythonhosted.org/packages/c2/84/9c66fb603bb683fe559ceeba8f3d5dbea3293b631b2eba319d7d47a2d7fb/watchdog-4.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:03e70d2df2258fb6cb0e95bbdbe06c16e608af94a3ffbd2b90c3f1e83eb10767", size = 92464 }, - { url = "https://files.pythonhosted.org/packages/5a/a5/72b9557e77ac3e6c41816fb16f643069b17cf21f745d26e2931cb1bf136c/watchdog-4.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:123587af84260c991dc5f62a6e7ef3d1c57dfddc99faacee508c71d287248459", size = 92953 }, - { url = "https://files.pythonhosted.org/packages/3a/36/28ce38b960f2bf1e1be573d85e8127c9ac66b4de63a7bef3f61b3f77ce57/watchdog-4.0.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:dddba7ca1c807045323b6af4ff80f5ddc4d654c8bce8317dde1bd96b128ed253", size = 83011 }, - { url = "https://files.pythonhosted.org/packages/05/7b/efc5b4134c97f08b161faa703327cde3fe647c5c48c156fde0c343471095/watchdog-4.0.1-py3-none-manylinux2014_armv7l.whl", hash = "sha256:4513ec234c68b14d4161440e07f995f231be21a09329051e67a2118a7a612d2d", size = 83009 }, - { url = "https://files.pythonhosted.org/packages/c3/bb/1fac328ba90ea091ef04e7bdefe638a933076530d802c1b1cf1f03fe7e89/watchdog-4.0.1-py3-none-manylinux2014_i686.whl", hash = "sha256:4107ac5ab936a63952dea2a46a734a23230aa2f6f9db1291bf171dac3ebd53c6", size = 83011 }, - { url = "https://files.pythonhosted.org/packages/ce/df/c8719022af772d9f75f1c49af453a48a785a45295bca1ce4f3f55b9923af/watchdog-4.0.1-py3-none-manylinux2014_ppc64.whl", hash = "sha256:6e8c70d2cd745daec2a08734d9f63092b793ad97612470a0ee4cbb8f5f705c57", size = 83012 }, - { url = "https://files.pythonhosted.org/packages/b0/d5/7285d52e7a7ffce2ae0b21a98dbbed345bcb227672a4268eb26d046d8d41/watchdog-4.0.1-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:f27279d060e2ab24c0aa98363ff906d2386aa6c4dc2f1a374655d4e02a6c5e5e", size = 83011 }, - { url = "https://files.pythonhosted.org/packages/2a/09/4b07dc8dd1a9f67a7acfbc084f26fc35ee8a2e4feeb0a2c98fe9a1ef196c/watchdog-4.0.1-py3-none-manylinux2014_s390x.whl", hash = "sha256:f8affdf3c0f0466e69f5b3917cdd042f89c8c63aebdb9f7c078996f607cdb0f5", size = 83009 }, - { url = "https://files.pythonhosted.org/packages/24/01/a4034a94a5f1828eb050230e7cf13af3ac23cf763512b6afe008d3def97c/watchdog-4.0.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:ac7041b385f04c047fcc2951dc001671dee1b7e0615cde772e84b01fbf68ee84", size = 83012 }, - { url = "https://files.pythonhosted.org/packages/8f/5e/c0d7dad506adedd584188578901871fe923abf6c0c5dc9e79d9be5c7c24e/watchdog-4.0.1-py3-none-win32.whl", hash = "sha256:206afc3d964f9a233e6ad34618ec60b9837d0582b500b63687e34011e15bb429", size = 82996 }, - { url = "https://files.pythonhosted.org/packages/85/e0/2a9f43008902427b5f074c497705d6ef8f815c85d4bc25fbf83f720a6159/watchdog-4.0.1-py3-none-win_amd64.whl", hash = "sha256:7577b3c43e5909623149f76b099ac49a1a01ca4e167d1785c76eb52fa585745a", size = 83002 }, - { url = "https://files.pythonhosted.org/packages/db/54/23e5845ef68e1817b3792b2a11fb2088d7422814d41af8186d9058c4ff07/watchdog-4.0.1-py3-none-win_ia64.whl", hash = "sha256:d7b9f5f3299e8dd230880b6c55504a1f69cf1e4316275d1b215ebdd8187ec88d", size = 83002 }, + { url = "https://files.pythonhosted.org/packages/1c/bc/a1ce8b77eede5a2f4fbcdc923079eb85b7c6e0f5e366ad06661b4dd807e1/watchdog-4.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:17e32f147d8bf9657e0922c0940bcde863b894cd871dbb694beb6704cfbd2fb5", size = 101627, upload-time = "2024-05-23T16:11:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/c2/84/9c66fb603bb683fe559ceeba8f3d5dbea3293b631b2eba319d7d47a2d7fb/watchdog-4.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:03e70d2df2258fb6cb0e95bbdbe06c16e608af94a3ffbd2b90c3f1e83eb10767", size = 92464, upload-time = "2024-05-23T16:11:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a5/72b9557e77ac3e6c41816fb16f643069b17cf21f745d26e2931cb1bf136c/watchdog-4.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:123587af84260c991dc5f62a6e7ef3d1c57dfddc99faacee508c71d287248459", size = 92953, upload-time = "2024-05-23T16:11:40.151Z" }, + { url = "https://files.pythonhosted.org/packages/3a/36/28ce38b960f2bf1e1be573d85e8127c9ac66b4de63a7bef3f61b3f77ce57/watchdog-4.0.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:dddba7ca1c807045323b6af4ff80f5ddc4d654c8bce8317dde1bd96b128ed253", size = 83011, upload-time = "2024-05-23T16:12:10.641Z" }, + { url = "https://files.pythonhosted.org/packages/05/7b/efc5b4134c97f08b161faa703327cde3fe647c5c48c156fde0c343471095/watchdog-4.0.1-py3-none-manylinux2014_armv7l.whl", hash = "sha256:4513ec234c68b14d4161440e07f995f231be21a09329051e67a2118a7a612d2d", size = 83009, upload-time = "2024-05-23T16:12:12.692Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bb/1fac328ba90ea091ef04e7bdefe638a933076530d802c1b1cf1f03fe7e89/watchdog-4.0.1-py3-none-manylinux2014_i686.whl", hash = "sha256:4107ac5ab936a63952dea2a46a734a23230aa2f6f9db1291bf171dac3ebd53c6", size = 83011, upload-time = "2024-05-23T16:12:14.093Z" }, + { url = "https://files.pythonhosted.org/packages/ce/df/c8719022af772d9f75f1c49af453a48a785a45295bca1ce4f3f55b9923af/watchdog-4.0.1-py3-none-manylinux2014_ppc64.whl", hash = "sha256:6e8c70d2cd745daec2a08734d9f63092b793ad97612470a0ee4cbb8f5f705c57", size = 83012, upload-time = "2024-05-23T16:12:15.893Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d5/7285d52e7a7ffce2ae0b21a98dbbed345bcb227672a4268eb26d046d8d41/watchdog-4.0.1-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:f27279d060e2ab24c0aa98363ff906d2386aa6c4dc2f1a374655d4e02a6c5e5e", size = 83011, upload-time = "2024-05-23T16:12:17.545Z" }, + { url = "https://files.pythonhosted.org/packages/2a/09/4b07dc8dd1a9f67a7acfbc084f26fc35ee8a2e4feeb0a2c98fe9a1ef196c/watchdog-4.0.1-py3-none-manylinux2014_s390x.whl", hash = "sha256:f8affdf3c0f0466e69f5b3917cdd042f89c8c63aebdb9f7c078996f607cdb0f5", size = 83009, upload-time = "2024-05-23T16:12:19.496Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/a4034a94a5f1828eb050230e7cf13af3ac23cf763512b6afe008d3def97c/watchdog-4.0.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:ac7041b385f04c047fcc2951dc001671dee1b7e0615cde772e84b01fbf68ee84", size = 83012, upload-time = "2024-05-23T16:12:21.754Z" }, + { url = "https://files.pythonhosted.org/packages/8f/5e/c0d7dad506adedd584188578901871fe923abf6c0c5dc9e79d9be5c7c24e/watchdog-4.0.1-py3-none-win32.whl", hash = "sha256:206afc3d964f9a233e6ad34618ec60b9837d0582b500b63687e34011e15bb429", size = 82996, upload-time = "2024-05-23T16:12:23.167Z" }, + { url = "https://files.pythonhosted.org/packages/85/e0/2a9f43008902427b5f074c497705d6ef8f815c85d4bc25fbf83f720a6159/watchdog-4.0.1-py3-none-win_amd64.whl", hash = "sha256:7577b3c43e5909623149f76b099ac49a1a01ca4e167d1785c76eb52fa585745a", size = 83002, upload-time = "2024-05-23T16:12:24.981Z" }, + { url = "https://files.pythonhosted.org/packages/db/54/23e5845ef68e1817b3792b2a11fb2088d7422814d41af8186d9058c4ff07/watchdog-4.0.1-py3-none-win_ia64.whl", hash = "sha256:d7b9f5f3299e8dd230880b6c55504a1f69cf1e4316275d1b215ebdd8187ec88d", size = 83002, upload-time = "2024-05-23T16:12:27.101Z" }, ] [[package]] name = "zipp" version = "3.19.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/20/b48f58857d98dcb78f9e30ed2cfe533025e2e9827bbd36ea0a64cc00cbc1/zipp-3.19.2.tar.gz", hash = "sha256:bf1dcf6450f873a13e952a29504887c89e6de7506209e5b1bcc3460135d4de19", size = 22922 } +sdist = { url = "https://files.pythonhosted.org/packages/d3/20/b48f58857d98dcb78f9e30ed2cfe533025e2e9827bbd36ea0a64cc00cbc1/zipp-3.19.2.tar.gz", hash = "sha256:bf1dcf6450f873a13e952a29504887c89e6de7506209e5b1bcc3460135d4de19", size = 22922, upload-time = "2024-06-04T17:21:09.042Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/38/f5c473fe9b90c8debdd29ea68d5add0289f1936d6f923b6b9cc0b931194c/zipp-3.19.2-py3-none-any.whl", hash = "sha256:f091755f667055f2d02b32c53771a7a6c8b47e1fdbc4b72a8b9072b3eef8015c", size = 9039 }, + { url = "https://files.pythonhosted.org/packages/20/38/f5c473fe9b90c8debdd29ea68d5add0289f1936d6f923b6b9cc0b931194c/zipp-3.19.2-py3-none-any.whl", hash = "sha256:f091755f667055f2d02b32c53771a7a6c8b47e1fdbc4b72a8b9072b3eef8015c", size = 9039, upload-time = "2024-06-04T17:21:07.146Z" }, ] [[package]] @@ -841,22 +988,22 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation == 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ed/f6/2ac0287b442160a89d726b17a9184a4c615bb5237db763791a7fd16d9df1/zstandard-0.23.0.tar.gz", hash = "sha256:b2d8c62d08e7255f68f7a740bae85b3c9b8e5466baa9cbf7f57f1cde0ac6bc09", size = 681701 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/40/f67e7d2c25a0e2dc1744dd781110b0b60306657f8696cafb7ad7579469bd/zstandard-0.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:34895a41273ad33347b2fc70e1bff4240556de3c46c6ea430a7ed91f9042aa4e", size = 788699 }, - { url = "https://files.pythonhosted.org/packages/e8/46/66d5b55f4d737dd6ab75851b224abf0afe5774976fe511a54d2eb9063a41/zstandard-0.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:77ea385f7dd5b5676d7fd943292ffa18fbf5c72ba98f7d09fc1fb9e819b34c23", size = 633681 }, - { url = "https://files.pythonhosted.org/packages/63/b6/677e65c095d8e12b66b8f862b069bcf1f1d781b9c9c6f12eb55000d57583/zstandard-0.23.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:983b6efd649723474f29ed42e1467f90a35a74793437d0bc64a5bf482bedfa0a", size = 4944328 }, - { url = "https://files.pythonhosted.org/packages/59/cc/e76acb4c42afa05a9d20827116d1f9287e9c32b7ad58cc3af0721ce2b481/zstandard-0.23.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80a539906390591dd39ebb8d773771dc4db82ace6372c4d41e2d293f8e32b8db", size = 5311955 }, - { url = "https://files.pythonhosted.org/packages/78/e4/644b8075f18fc7f632130c32e8f36f6dc1b93065bf2dd87f03223b187f26/zstandard-0.23.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:445e4cb5048b04e90ce96a79b4b63140e3f4ab5f662321975679b5f6360b90e2", size = 5344944 }, - { url = "https://files.pythonhosted.org/packages/76/3f/dbafccf19cfeca25bbabf6f2dd81796b7218f768ec400f043edc767015a6/zstandard-0.23.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd30d9c67d13d891f2360b2a120186729c111238ac63b43dbd37a5a40670b8ca", size = 5442927 }, - { url = "https://files.pythonhosted.org/packages/0c/c3/d24a01a19b6733b9f218e94d1a87c477d523237e07f94899e1c10f6fd06c/zstandard-0.23.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d20fd853fbb5807c8e84c136c278827b6167ded66c72ec6f9a14b863d809211c", size = 4864910 }, - { url = "https://files.pythonhosted.org/packages/1c/a9/cf8f78ead4597264f7618d0875be01f9bc23c9d1d11afb6d225b867cb423/zstandard-0.23.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ed1708dbf4d2e3a1c5c69110ba2b4eb6678262028afd6c6fbcc5a8dac9cda68e", size = 4935544 }, - { url = "https://files.pythonhosted.org/packages/2c/96/8af1e3731b67965fb995a940c04a2c20997a7b3b14826b9d1301cf160879/zstandard-0.23.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:be9b5b8659dff1f913039c2feee1aca499cfbc19e98fa12bc85e037c17ec6ca5", size = 5467094 }, - { url = "https://files.pythonhosted.org/packages/ff/57/43ea9df642c636cb79f88a13ab07d92d88d3bfe3e550b55a25a07a26d878/zstandard-0.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:65308f4b4890aa12d9b6ad9f2844b7ee42c7f7a4fd3390425b242ffc57498f48", size = 4860440 }, - { url = "https://files.pythonhosted.org/packages/46/37/edb78f33c7f44f806525f27baa300341918fd4c4af9472fbc2c3094be2e8/zstandard-0.23.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:98da17ce9cbf3bfe4617e836d561e433f871129e3a7ac16d6ef4c680f13a839c", size = 4700091 }, - { url = "https://files.pythonhosted.org/packages/c1/f1/454ac3962671a754f3cb49242472df5c2cced4eb959ae203a377b45b1a3c/zstandard-0.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8ed7d27cb56b3e058d3cf684d7200703bcae623e1dcc06ed1e18ecda39fee003", size = 5208682 }, - { url = "https://files.pythonhosted.org/packages/85/b2/1734b0fff1634390b1b887202d557d2dd542de84a4c155c258cf75da4773/zstandard-0.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:b69bb4f51daf461b15e7b3db033160937d3ff88303a7bc808c67bbc1eaf98c78", size = 5669707 }, - { url = "https://files.pythonhosted.org/packages/52/5a/87d6971f0997c4b9b09c495bf92189fb63de86a83cadc4977dc19735f652/zstandard-0.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:034b88913ecc1b097f528e42b539453fa82c3557e414b3de9d5632c80439a473", size = 5201792 }, - { url = "https://files.pythonhosted.org/packages/79/02/6f6a42cc84459d399bd1a4e1adfc78d4dfe45e56d05b072008d10040e13b/zstandard-0.23.0-cp311-cp311-win32.whl", hash = "sha256:f2d4380bf5f62daabd7b751ea2339c1a21d1c9463f1feb7fc2bdcea2c29c3160", size = 430586 }, - { url = "https://files.pythonhosted.org/packages/be/a2/4272175d47c623ff78196f3c10e9dc7045c1b9caf3735bf041e65271eca4/zstandard-0.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:62136da96a973bd2557f06ddd4e8e807f9e13cbb0bfb9cc06cfe6d98ea90dfe0", size = 495420 }, +sdist = { url = "https://files.pythonhosted.org/packages/ed/f6/2ac0287b442160a89d726b17a9184a4c615bb5237db763791a7fd16d9df1/zstandard-0.23.0.tar.gz", hash = "sha256:b2d8c62d08e7255f68f7a740bae85b3c9b8e5466baa9cbf7f57f1cde0ac6bc09", size = 681701, upload-time = "2024-07-15T00:18:06.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/40/f67e7d2c25a0e2dc1744dd781110b0b60306657f8696cafb7ad7579469bd/zstandard-0.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:34895a41273ad33347b2fc70e1bff4240556de3c46c6ea430a7ed91f9042aa4e", size = 788699, upload-time = "2024-07-15T00:14:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/e8/46/66d5b55f4d737dd6ab75851b224abf0afe5774976fe511a54d2eb9063a41/zstandard-0.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:77ea385f7dd5b5676d7fd943292ffa18fbf5c72ba98f7d09fc1fb9e819b34c23", size = 633681, upload-time = "2024-07-15T00:14:13.99Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/677e65c095d8e12b66b8f862b069bcf1f1d781b9c9c6f12eb55000d57583/zstandard-0.23.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:983b6efd649723474f29ed42e1467f90a35a74793437d0bc64a5bf482bedfa0a", size = 4944328, upload-time = "2024-07-15T00:14:16.588Z" }, + { url = "https://files.pythonhosted.org/packages/59/cc/e76acb4c42afa05a9d20827116d1f9287e9c32b7ad58cc3af0721ce2b481/zstandard-0.23.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80a539906390591dd39ebb8d773771dc4db82ace6372c4d41e2d293f8e32b8db", size = 5311955, upload-time = "2024-07-15T00:14:19.389Z" }, + { url = "https://files.pythonhosted.org/packages/78/e4/644b8075f18fc7f632130c32e8f36f6dc1b93065bf2dd87f03223b187f26/zstandard-0.23.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:445e4cb5048b04e90ce96a79b4b63140e3f4ab5f662321975679b5f6360b90e2", size = 5344944, upload-time = "2024-07-15T00:14:22.173Z" }, + { url = "https://files.pythonhosted.org/packages/76/3f/dbafccf19cfeca25bbabf6f2dd81796b7218f768ec400f043edc767015a6/zstandard-0.23.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd30d9c67d13d891f2360b2a120186729c111238ac63b43dbd37a5a40670b8ca", size = 5442927, upload-time = "2024-07-15T00:14:24.825Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/d24a01a19b6733b9f218e94d1a87c477d523237e07f94899e1c10f6fd06c/zstandard-0.23.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d20fd853fbb5807c8e84c136c278827b6167ded66c72ec6f9a14b863d809211c", size = 4864910, upload-time = "2024-07-15T00:14:26.982Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a9/cf8f78ead4597264f7618d0875be01f9bc23c9d1d11afb6d225b867cb423/zstandard-0.23.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ed1708dbf4d2e3a1c5c69110ba2b4eb6678262028afd6c6fbcc5a8dac9cda68e", size = 4935544, upload-time = "2024-07-15T00:14:29.582Z" }, + { url = "https://files.pythonhosted.org/packages/2c/96/8af1e3731b67965fb995a940c04a2c20997a7b3b14826b9d1301cf160879/zstandard-0.23.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:be9b5b8659dff1f913039c2feee1aca499cfbc19e98fa12bc85e037c17ec6ca5", size = 5467094, upload-time = "2024-07-15T00:14:40.126Z" }, + { url = "https://files.pythonhosted.org/packages/ff/57/43ea9df642c636cb79f88a13ab07d92d88d3bfe3e550b55a25a07a26d878/zstandard-0.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:65308f4b4890aa12d9b6ad9f2844b7ee42c7f7a4fd3390425b242ffc57498f48", size = 4860440, upload-time = "2024-07-15T00:14:42.786Z" }, + { url = "https://files.pythonhosted.org/packages/46/37/edb78f33c7f44f806525f27baa300341918fd4c4af9472fbc2c3094be2e8/zstandard-0.23.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:98da17ce9cbf3bfe4617e836d561e433f871129e3a7ac16d6ef4c680f13a839c", size = 4700091, upload-time = "2024-07-15T00:14:45.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f1/454ac3962671a754f3cb49242472df5c2cced4eb959ae203a377b45b1a3c/zstandard-0.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8ed7d27cb56b3e058d3cf684d7200703bcae623e1dcc06ed1e18ecda39fee003", size = 5208682, upload-time = "2024-07-15T00:14:47.407Z" }, + { url = "https://files.pythonhosted.org/packages/85/b2/1734b0fff1634390b1b887202d557d2dd542de84a4c155c258cf75da4773/zstandard-0.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:b69bb4f51daf461b15e7b3db033160937d3ff88303a7bc808c67bbc1eaf98c78", size = 5669707, upload-time = "2024-07-15T00:15:03.529Z" }, + { url = "https://files.pythonhosted.org/packages/52/5a/87d6971f0997c4b9b09c495bf92189fb63de86a83cadc4977dc19735f652/zstandard-0.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:034b88913ecc1b097f528e42b539453fa82c3557e414b3de9d5632c80439a473", size = 5201792, upload-time = "2024-07-15T00:15:28.372Z" }, + { url = "https://files.pythonhosted.org/packages/79/02/6f6a42cc84459d399bd1a4e1adfc78d4dfe45e56d05b072008d10040e13b/zstandard-0.23.0-cp311-cp311-win32.whl", hash = "sha256:f2d4380bf5f62daabd7b751ea2339c1a21d1c9463f1feb7fc2bdcea2c29c3160", size = 430586, upload-time = "2024-07-15T00:15:32.26Z" }, + { url = "https://files.pythonhosted.org/packages/be/a2/4272175d47c623ff78196f3c10e9dc7045c1b9caf3735bf041e65271eca4/zstandard-0.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:62136da96a973bd2557f06ddd4e8e807f9e13cbb0bfb9cc06cfe6d98ea90dfe0", size = 495420, upload-time = "2024-07-15T00:15:34.004Z" }, ] From ced0898f847f6c8d5724fc6716be07935ec83c5f Mon Sep 17 00:00:00 2001 From: MaNan Date: Thu, 22 Jan 2026 09:38:07 +0800 Subject: [PATCH 02/14] Add unit tests for bioxel module and documentation - Created README.md for test instructions and categories. - Implemented comprehensive unit tests for the Data, Layer, and utility functions in the bioxel module. - Added tests for reading NRRD and DICOM formats, data properties, and edge cases. - Included tests for real data files to ensure functionality with actual datasets. --- .gitignore | 4 +- AGENTS.md | 5 + src/bioxelnodes/bioxel/__init__.py | 34 ++ src/bioxelnodes/bioxel/data.py | 831 +++++++++++++++++++++++++++++ src/bioxelnodes/bioxel/layer.py | 6 + src/bioxelnodes/layer.py | 15 +- src/bioxelnodes/node.py | 2 +- src/bioxelnodes/operators/io.py | 280 +--------- src/bioxelnodes/operators/layer.py | 18 +- tests/README.md | 43 ++ tests/test_bioxel.py | 539 +++++++++++++++++++ 11 files changed, 1502 insertions(+), 275 deletions(-) create mode 100644 src/bioxelnodes/bioxel/data.py create mode 100644 tests/README.md create mode 100644 tests/test_bioxel.py diff --git a/.gitignore b/.gitignore index 30b51f1..960f98e 100644 --- a/.gitignore +++ b/.gitignore @@ -135,4 +135,6 @@ pythonlib* # Blender *.blend1 blendcache_* -*.cats.txt~ \ No newline at end of file +*.cats.txt~ + +tests/data \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index d409372..661944b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,11 @@ uv sync uv run build.py # platforms: windows-x64, linux-x64, macos-arm64, macos-x64 ``` +### Unit Test +```bash +uv run pytest tests/ +``` + ### Code Quality ```bash # Format code (autopep8) diff --git a/src/bioxelnodes/bioxel/__init__.py b/src/bioxelnodes/bioxel/__init__.py index e69de29..039ca35 100644 --- a/src/bioxelnodes/bioxel/__init__.py +++ b/src/bioxelnodes/bioxel/__init__.py @@ -0,0 +1,34 @@ +from .layer import Layer +from .container import Container +from .io import load_container, save_container +from .data import ( + Data, + read, + read_meta, + calc_layer_shape, + calc_layer_size, + get_ext, + SUPPORT_EXTS, + DICOM_EXTS, + OME_EXTS, + MRC_EXTS, + SEQUENCE_EXTS +) + +__all__ = [ + 'Layer', + 'Container', + 'load_container', + 'save_container', + 'Data', + 'read', + 'read_meta', + 'calc_layer_shape', + 'calc_layer_size', + 'get_ext', + 'SUPPORT_EXTS', + 'DICOM_EXTS', + 'OME_EXTS', + 'MRC_EXTS', + 'SEQUENCE_EXTS' +] diff --git a/src/bioxelnodes/bioxel/data.py b/src/bioxelnodes/bioxel/data.py new file mode 100644 index 0000000..a9c199d --- /dev/null +++ b/src/bioxelnodes/bioxel/data.py @@ -0,0 +1,831 @@ +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional, Callable, List +import numpy as np +import SimpleITK as sitk +from pyometiff import OMETIFFReader +import mrcfile +import transforms3d + +from .layer import Layer + +# 类型定义 +ProgressCallback = Optional[Callable[[float, str], None]] + + +SUPPORT_EXTS = [ + "", + ".dcm", + ".DCM", + ".DICOM", + ".ima", + ".IMA", + ".ome.tiff", + ".ome.tif", + ".tif", + ".TIF", + ".tiff", + ".TIFF", + ".mrc", + ".mrc.gz", + ".map", + ".map.gz", + ".bmp", + ".BMP", + ".png", + ".PNG", + ".jpg", + ".JPG", + ".jpeg", + ".JPEG", + ".PIC", + ".pic", + ".gipl", + ".gipl.gz", + ".lsm", + ".LSM", + ".mnc", + ".MNC", + ".mrc", + ".rec", + ".mha", + ".mhd", + ".hdf", + ".h4", + ".hdf4", + ".he2", + ".h5", + ".hdf5", + ".he5", + ".nia", + ".nii", + ".nii.gz", + ".hdr", + ".img", + ".img.gz", + ".nrrd", + ".nhdr", + ".vtk", + ".gz", +] + +OME_EXTS = [".ome.tiff", ".ome.tif", ".tif", ".TIF", ".tiff", ".TIFF"] + +MRC_EXTS = [".mrc", ".mrc.gz", ".map", ".map.gz"] + +DICOM_EXTS = ["", ".dcm", ".DCM", ".DICOM", ".ima", ".IMA"] + +SEQUENCE_EXTS = [ + ".bmp", + ".BMP", + ".jpg", + ".JPG", + ".jpeg", + ".JPEG", + ".tif", + ".TIF", + ".tiff", + ".TIFF", + ".png", + ".PNG", + ".mrc", +] + + +@dataclass +class Data: + filepath: str + series_id: str + _data: Optional[np.ndarray] = field(default=None, repr=False) + _meta: Optional[dict] = field(default=None, repr=False) + + @property + def data(self) -> np.ndarray: + if self._data is None: + self.load_data() + return self._data + + @property + def meta(self) -> dict: + if self._meta is None: + self.load_meta() + return self._meta + + @property + def name(self) -> str: + return self.meta["name"] + + @property + def description(self) -> str: + return self.meta["description"] + + @property + def shape(self) -> tuple: + return ( + self.meta["frame_count"], + *self.meta["xyz_shape"], + self.meta["channel_count"], + ) + + @property + def xyz_shape(self) -> tuple: + return self.meta["xyz_shape"] + + @property + def spacing(self) -> tuple: + return self.meta["spacing"] + + @property + def affine(self) -> np.ndarray: + return self.meta["affine"] + + @property + def frame_count(self) -> int: + return self.meta["frame_count"] + + @property + def channel_count(self) -> int: + return self.meta["channel_count"] + + @property + def dtype(self) -> np.dtype: + return self.meta.get("dtype", np.float32) + + def load_meta(self, progress_callback: ProgressCallback = None): + data, meta = self._parse_file(progress_callback) + self._meta = meta + del data + + def load_data(self, progress_callback: ProgressCallback = None): + if not self.is_loaded(): + data, meta = self._parse_file(progress_callback) + self._data = data + self._meta = meta + + def is_loaded(self) -> bool: + return self._data is not None + + def to_layers( + self, + kind: str = "scalar", + layer_name: str = "", + bioxel_size: float = 1.0, + smooth: int = 0, + remap: bool = False, + split_channel: bool = False, + frame_source: str = "-1", + progress_callback: ProgressCallback = None, + ) -> List[Layer]: + from .layer import Layer + + data = self.data.copy() + + data, layer_shape = self._transform_shape(data, frame_source) + + data = self._preprocess(data, kind, remap) + + mat_scale = transforms3d.zooms.zfdir2aff(bioxel_size) + affine = np.dot(self.meta["affine"], mat_scale) + + base_name = layer_name or kind.capitalize() + + if kind == "label": + return self._create_label_layers( + data, base_name, layer_shape, affine, smooth, progress_callback + ) + elif kind == "color": + return self._create_color_layers( + data, base_name, layer_shape, affine, progress_callback + ) + elif kind == "scalar": + return self._create_scalar_layers( + data, + base_name, + layer_shape, + affine, + smooth, + split_channel, + progress_callback, + ) + + return [] + + def _parse_file(self, progress_callback: ProgressCallback = None): + data_path = Path(self.filepath).resolve() + ext = self._get_ext(data_path) + + if progress_callback: + progress_callback(0.0, "Reading Data...") + + is_sequence = False + sequence = None + if ext in SEQUENCE_EXTS: + sequence = self._collect_sequence(data_path) + if sequence and len(sequence) > 1: + is_sequence = True + + data = None + name = "" + description = "" + affine = np.identity(4) + spacing = (1, 1, 1) + origin = (0, 0, 0) + direction = (1, 0, 0, 0, 1, 0, 0, 0, 1) + + if data is None and ext in MRC_EXTS and not is_sequence: + data, name, spacing = self._parse_mrc(data_path) + + if data is None and ext in OME_EXTS and not is_sequence: + data, name, spacing = self._parse_ome_tiff(data_path, progress_callback) + + if data is None: + data, name, description, spacing, origin, direction = self._parse_sitk( + data_path, ext, is_sequence, sequence, progress_callback + ) + + t = origin + r = np.array(direction).reshape((3, 3)) + affine = np.dot(affine, transforms3d.affines.compose(t, r, [1, 1, 1])) + + meta = { + "name": name, + "description": description, + "spacing": spacing, + "affine": affine, + "xyz_shape": data.shape[1:4], + "frame_count": data.shape[0], + "channel_count": data.shape[-1], + "dtype": data.dtype, + } + + return data, meta + + def _parse_mrc(self, data_path: Path): + print("Parsing with mrcfile...") + with mrcfile.open(data_path, "r") as mrc: + mrc_data = mrc.data + + if mrc.is_single_image(): + data = np.expand_dims(np.asarray(mrc_data), axis=0) + data = np.expand_dims(data, axis=-1) + data = np.expand_dims(data, axis=-1) + elif mrc.is_image_stack(): + data = np.expand_dims(np.asarray(mrc_data), axis=-1) + data = np.expand_dims(data, axis=-1) + elif mrc.is_volume(): + data = np.expand_dims(np.asarray(mrc_data), axis=0) + data = np.expand_dims(data, axis=-1) + elif mrc.is_volume_stack(): + data = np.expand_dims(np.asarray(mrc_data), axis=-1) + + name = self._get_file_no_digits_name(data_path) + spacing = (mrc.voxel_size.x, mrc.voxel_size.y, mrc.voxel_size.z) + + return data, name, spacing + + def _parse_ome_tiff(self, data_path: Path, progress_callback=None): + print("Parsing with OMETIFFReader...") + reader = OMETIFFReader(fpath=data_path) + ome_image, metadata, xml_metadata = reader.read() + + if progress_callback: + progress_callback(0.5, "Transpose to 'TXYZC'...") + + try: + ome_order = metadata["DimOrder BF Array"] + data = self._transpose_ome_image(ome_image, ome_order) + + try: + spacing = ( + metadata["PhysicalSizeX"], + metadata["PhysicalSizeY"], + metadata["PhysicalSizeZ"], + ) + except: + spacing = (1, 1, 1) + + name = self._get_file_no_digits_name(data_path) + except Exception as e: + print(f"Error parsing OME-TIFF: {e}") + data = np.zeros((1, 1, 1, 1, 1), dtype=np.float32) + name = "" + spacing = (1, 1, 1) + + return data, name, spacing + + def _parse_sitk( + self, + data_path: Path, + ext: str, + is_sequence: bool, + sequence: Optional[list], + progress_callback=None, + ): + print("Parsing with SimpleITK...") + + if ext in DICOM_EXTS: + data_dirpath = data_path.parent + reader = sitk.ImageSeriesReader() + reader.MetaDataDictionaryArrayUpdateOn() + reader.LoadPrivateTagsOn() + series_files = reader.GetGDCMSeriesFileNames( + str(data_dirpath), self.series_id + ) + reader.SetFileNames(series_files) + + itk_image = reader.Execute() + name, description = self._extract_dicom_meta(reader, data_dirpath) + + elif ext in SEQUENCE_EXTS and is_sequence and sequence: + sequence_list = sequence if sequence else [] + itk_image = sitk.ReadImage(sequence_list) + name = self._get_file_no_digits_name(data_path) + description = "" + + else: + itk_image = sitk.ReadImage(data_path) + name = self._get_filename(data_path) + description = "" + + if progress_callback: + progress_callback(0.5, "Transpose to 'TXYZC'...") + + data = self._convert_sitk_to_numpy(itk_image, ext, is_sequence) + + if itk_image.GetDimension() >= 3 and ext not in SEQUENCE_EXTS: + spacing = tuple(itk_image.GetSpacing()) + origin = tuple(itk_image.GetOrigin()) + direction = tuple(itk_image.GetDirection()) + else: + spacing = (1, 1, 1) + origin = (0, 0, 0) + direction = (1, 0, 0, 0, 1, 0, 0, 0, 1) + + return data, name, description, spacing, origin, direction + + def _transpose_ome_image(self, ome_image, ome_order: str): + if ome_image.ndim == 2: + ome_order = ome_order.replace("T", "").replace("C", "").replace("Z", "") + bioxel_order = (ome_order.index("X"), ome_order.index("Y")) + data = np.transpose(ome_image, bioxel_order) + data = np.expand_dims(data, axis=0) + data = np.expand_dims(data, axis=-1) + data = np.expand_dims(data, axis=-1) + + elif ome_image.ndim == 3: + ome_order = ome_order.replace("T", "").replace("C", "") + bioxel_order = ( + ome_order.index("X"), + ome_order.index("Y"), + ome_order.index("Z"), + ) + data = np.transpose(ome_image, bioxel_order) + data = np.expand_dims(data, axis=0) + data = np.expand_dims(data, axis=-1) + + elif ome_image.ndim == 4: + ome_order = ome_order.replace("T", "") + bioxel_order = ( + ome_order.index("X"), + ome_order.index("Y"), + ome_order.index("Z"), + ome_order.index("C"), + ) + data = np.transpose(ome_image, bioxel_order) + data = np.expand_dims(data, axis=0) + + elif ome_image.ndim == 5: + bioxel_order = ( + ome_order.index("T"), + ome_order.index("X"), + ome_order.index("Y"), + ome_order.index("Z"), + ome_order.index("C"), + ) + data = np.transpose(ome_image, bioxel_order) + + return data + + def _convert_sitk_to_numpy(self, itk_image, ext: str, is_sequence: bool): + if itk_image.GetDimension() == 2: + data = sitk.GetArrayFromImage(itk_image) + + if data.ndim == 3: + data = np.transpose(data, (1, 0, 2)) + data = np.expand_dims(data, axis=-2) + else: + data = np.transpose(data) + data = np.expand_dims(data, axis=-1) + data = np.expand_dims(data, axis=-1) + + data = np.expand_dims(data, axis=0) + + elif itk_image.GetDimension() == 3: + if ext not in SEQUENCE_EXTS: + itk_image = sitk.DICOMOrient(itk_image, "RAS") + + data = sitk.GetArrayFromImage(itk_image) + + if data.ndim == 4: + data = np.transpose(data, (2, 1, 0, 3)) + else: + data = np.transpose(data) + data = np.expand_dims(data, axis=-1) + + data = np.expand_dims(data, axis=0) + + elif itk_image.GetDimension() == 4: + data = sitk.GetArrayFromImage(itk_image) + + if data.ndim == 5: + data = np.transpose(data, (0, 3, 2, 1, 4)) + else: + data = np.transpose(data, (0, 3, 2, 1)) + data = np.expand_dims(data, axis=-1) + + return data + + def _extract_dicom_meta(self, reader, data_dirpath: Path): + def get_meta(key): + try: + string = reader.GetMetaData(0, key).removesuffix(" ") + string.encode("utf-8") + if string in ["No study description", "No series description", ""]: + return None + else: + return string + except: + return None + + study_description = get_meta("0008|1030") or data_dirpath.name + series_description = get_meta("0008|103e") + series_modality = get_meta("0008|0060") + + if series_description and series_modality: + description = f"{series_description}-{series_modality}" + elif series_description: + description = series_description + elif series_modality: + description = series_modality + else: + description = "" + + name = study_description.replace(" ", "-") + description = description.replace(" ", "-") + + return name, description + + @staticmethod + def _get_ext(filepath: Path) -> str: + if filepath.name.endswith(".nii.gz"): + return ".nii.gz" + elif filepath.name.endswith(".img.gz"): + return ".img.gz" + elif filepath.name.endswith(".gipl.gz"): + return ".gipl.gz" + elif filepath.name.endswith(".ome.tiff"): + return ".ome.tiff" + elif filepath.name.endswith(".ome.tif"): + return ".ome.tif" + elif filepath.name.endswith(".mrc.gz"): + return ".mrc.gz" + elif filepath.name.endswith(".map.gz"): + return ".map.gz" + else: + suffix = filepath.suffix + return "" if len(suffix) > 5 else suffix + + @staticmethod + def _get_filename(filepath: Path) -> str: + ext = Data._get_ext(filepath) + return filepath.name.removesuffix(ext) + + @staticmethod + def _get_file_no_digits_name(filepath: Path) -> str: + prefix, digits, suffix = Data._get_filename_parts(filepath) + prefix = Data._remove_end_str(prefix, "_") + prefix = Data._remove_end_str(prefix, ".") + prefix = Data._remove_end_str(prefix, "-") + prefix = Data._remove_end_str(prefix, " ") + return prefix + suffix + + @staticmethod + def _get_filename_parts(filepath: Path) -> tuple: + def has_digits(s): + return any(char.isdigit() for char in s) + + name = Data._get_filename(filepath) + parts = name.replace(".", " ").replace("_", " ").split(" ") + skip_prefixs = ["CH", "ch", "channel"] + number_part = None + number_part_i = None + + for i, part in enumerate(parts[::-1]): + if has_digits(part): + if not any([part.startswith(prefix) for prefix in skip_prefixs]): + number_part = part + number_part_i = len(parts) - i + break + + if number_part is None: + return name, "", "" + + prefix_parts = parts[: number_part_i - 1] + prefix_parts_count = sum([len(part) + 1 for part in prefix_parts]) + + digits = "" + suffix = "" + + started = False + for char in number_part[::-1]: + if char.isdigit(): + started = True + digits += char + else: + if started: + break + else: + suffix += char + + digits = digits[::-1] + + prefix_parts_count += len(number_part) - len(digits) - len(suffix) + + prefix = name[:prefix_parts_count] + suffix = name[prefix_parts_count + len(digits) :] + + return prefix, digits, suffix + + @staticmethod + def _get_file_index(filepath: Path) -> int: + prefix, digits, suffix = Data._get_filename_parts(filepath) + return int(digits) if digits != "" else 0 + + @staticmethod + def _collect_sequence(filepath: Path): + file_dict = {} + for f in filepath.parent.iterdir(): + if ( + f.is_file() + and Data._get_ext(filepath) == Data._get_ext(f) + and Data._get_file_no_digits_name(filepath) + == Data._get_file_no_digits_name(f) + ): + index = Data._get_file_index(f) + file_dict[index] = f + + for key in list(file_dict.keys()): + if not file_dict.get(key + 1) and not file_dict.get(key - 1): + del file_dict[key] + + file_dict = dict(sorted(file_dict.items())) + sequence = [str(f) for f in file_dict.values()] + + if len(sequence) == 0: + sequence = [str(filepath)] + + return sequence + + @staticmethod + def _remove_end_str(string: str, end: str) -> str: + while string.endswith(end) and len(string) > 0: + string = string.removesuffix(end) + return string + + def _transform_shape(self, data, frame_source: str) -> tuple: + orig_shape = self.xyz_shape + + if frame_source == "-1": + data = data[0:1, :, :, :, :] + new_shape = orig_shape + elif frame_source == "0": + new_shape = orig_shape + elif frame_source == "1": + data = data.transpose(1, 0, 2, 3, 4) + new_shape = (1, orig_shape[1], orig_shape[2]) + elif frame_source == "2": + data = data.transpose(2, 1, 0, 3, 4) + new_shape = (orig_shape[0], 1, orig_shape[2]) + elif frame_source == "3": + data = data.transpose(3, 1, 2, 0, 4) + new_shape = (orig_shape[0], orig_shape[1], 1) + else: + data = data.transpose(4, 1, 2, 3, 0) + new_shape = orig_shape + + return data, new_shape + + def _preprocess(self, data, kind: str, remap: bool) -> np.ndarray: + if kind == "color": + if np.issubdtype(data.dtype, np.uint8): + data = np.multiply(data, 1.0 / 256, dtype=np.float32) + elif data.dtype.kind in ["u", "i"]: + data = data.astype(np.float32) + min_val, max_val = data.min(), data.max() + if max_val != min_val: + data = (data - min_val) / (max_val - min_val) + else: + data = np.zeros_like(data, dtype=np.float32) + else: + data = data.astype(np.float32) + + if data.shape[4] == 1: + data = np.repeat(data, repeats=3, axis=4) + elif data.shape[4] == 2: + zeros_shape = list(data.shape[:4]) + [1] + zeros = np.zeros(zeros_shape, dtype=np.float32) + data = np.concatenate((data, zeros), axis=-1) + elif data.shape[4] > 3: + data = data[:, :, :, :, :3] + + elif kind == "scalar": + if remap: + data = data.astype(np.float32) + min_val, max_val = data.min(), data.max() + if max_val != min_val: + data = (data - min_val) / (max_val - min_val) + else: + data = np.zeros_like(data, dtype=np.float32) + + elif kind == "label": + data = data.astype(int) + + return data + + def _create_progress_cb_factory(self, progress_callback): + def factory(name, progress, progress_step): + def callback(frame, total): + if progress_callback: + sub_progress = progress + frame * (progress_step / total) + progress_callback( + sub_progress, f"Processing {name} Frame {frame+1}..." + ) + + return callback + + return factory + + def _create_label_layers( + self, + data, + base_name, + layer_shape, + affine, + smooth, + progress_callback: ProgressCallback, + ): + from .layer import Layer + + layers = [] + label_count = int(np.max(data)) + if label_count == 0: + return layers + + progress_step = 0.7 / label_count + cb_factory = self._create_progress_cb_factory(progress_callback) + + for i in range(label_count): + name_i = f"{base_name}_{i+1}" + progress = 0.2 + i * progress_step + if progress_callback: + progress_callback(progress, f"Processing {name_i}...") + + label_data = data == np.full_like(data, i + 1) + progress_cb = cb_factory(name_i, progress, progress_step) + + layer = Layer(data=label_data, name=name_i, kind="label") + layer.resize( + shape=layer_shape, smooth=smooth, progress_callback=progress_cb + ) + layer.affine = affine + layers.append(layer) + + return layers + + def _create_color_layers( + self, data, base_name, layer_shape, affine, progress_callback: ProgressCallback + ): + from .layer import Layer + + if progress_callback: + progress_callback(0.2, f"Processing {base_name}...") + + cb_factory = self._create_progress_cb_factory(progress_callback) + progress_cb = cb_factory(base_name, 0.2, 0.7) + + layer = Layer(data=data, name=base_name, kind="color") + layer.resize(shape=layer_shape, progress_callback=progress_cb) + layer.affine = affine + + return [layer] + + def _create_scalar_layers( + self, + data, + base_name, + layer_shape, + affine, + smooth, + split_channel, + progress_callback: ProgressCallback, + ): + from .layer import Layer + + layers = [] + cb_factory = self._create_progress_cb_factory(progress_callback) + + if split_channel: + channel_count = data.shape[-1] + progress_step = 0.7 / channel_count + + for i in range(channel_count): + name_i = f"{base_name}_{i+1}" + progress = 0.2 + i * progress_step + if progress_callback: + progress_callback(progress, f"Processing {name_i}...") + + progress_cb = cb_factory(name_i, progress, progress_step) + layer = Layer( + data=data[:, :, :, :, i : i + 1], name=name_i, kind="scalar" + ) + layer.resize( + shape=layer_shape, smooth=smooth, progress_callback=progress_cb + ) + layer.affine = affine + layers.append(layer) + else: + if progress_callback: + progress_callback(0.2, f"Processing {base_name}...") + + progress_cb = cb_factory(base_name, 0.2, 0.7) + layer = Layer(data=data, name=base_name, kind="scalar") + layer.resize( + shape=layer_shape, smooth=smooth, progress_callback=progress_cb + ) + layer.affine = affine + layers.append(layer) + + return layers + + +# 模块级函数签名 +def read( + filepath: str, series_id: str = "", progress_callback: ProgressCallback = None +) -> Data: + filepath = Path(filepath).resolve() + if not filepath.exists(): + raise FileNotFoundError(f"File not found: {filepath}") + + data_obj = Data(filepath=filepath, series_id=series_id) + data_obj.load_data(progress_callback) + return data_obj + + +def read_meta(filepath: str, series_id: str = "") -> Data: + data_obj = Data(filepath=filepath, series_id=series_id) + data_obj.load_meta() + return data_obj + + +def calc_layer_shape( + bioxel_size: float, orig_shape: tuple, orig_spacing: tuple +) -> tuple: + shape = ( + int(orig_shape[0] / bioxel_size * orig_spacing[0]), + int(orig_shape[1] / bioxel_size * orig_spacing[1]), + int(orig_shape[2] / bioxel_size * orig_spacing[2]), + ) + return ( + shape[0] if shape[0] > 0 else 1, + shape[1] if shape[1] > 0 else 1, + shape[2] if shape[2] > 0 else 1, + ) + + +def calc_layer_size(shape: tuple, bioxel_size: float, scale: float = 1.0) -> tuple: + size = ( + float(shape[0] * bioxel_size * scale), + float(shape[1] * bioxel_size * scale), + float(shape[2] * bioxel_size * scale), + ) + return size + + +def get_ext(filepath: Path) -> str: + if filepath.name.endswith(".nii.gz"): + return ".nii.gz" + elif filepath.name.endswith(".img.gz"): + return ".img.gz" + elif filepath.name.endswith(".gipl.gz"): + return ".gipl.gz" + elif filepath.name.endswith(".ome.tiff"): + return ".ome.tiff" + elif filepath.name.endswith(".ome.tif"): + return ".ome.tif" + elif filepath.name.endswith(".mrc.gz"): + return ".mrc.gz" + elif filepath.name.endswith(".map.gz"): + return ".map.gz" + else: + suffix = filepath.suffix + return "" if len(suffix) > 5 else suffix diff --git a/src/bioxelnodes/bioxel/layer.py b/src/bioxelnodes/bioxel/layer.py index d9449df..5dd9a32 100644 --- a/src/bioxelnodes/bioxel/layer.py +++ b/src/bioxelnodes/bioxel/layer.py @@ -1,6 +1,12 @@ import copy import numpy as np +# 条件导入 openvdb(只在 Blender 环境中存在) +try: + import openvdb as vdb +except ImportError: + vdb = None + from . import scipy from . import scipy as ndi diff --git a/src/bioxelnodes/layer.py b/src/bioxelnodes/layer.py index c6fb53d..a3926a0 100644 --- a/src/bioxelnodes/layer.py +++ b/src/bioxelnodes/layer.py @@ -6,7 +6,11 @@ import bpy import numpy as np -import openvdb as vdb + +try: + import openvdb as vdb +except ImportError: + vdb = None from .bioxel.layer import Layer from .utils import ndarray_to_png @@ -57,12 +61,10 @@ def cache_layer_data(layer: Layer, cache_path: str): grid.copyFromArray(data[f, :, :, :].copy().astype(np.float32)) else: # 颜色类型 grid = vdb.Vec3SGrid() - grid.copyFromArray( - data[f, :, :, :, :].copy().astype(np.float32)) + grid.copyFromArray(data[f, :, :, :, :].copy().astype(np.float32)) # 仅设置transform,不存储metadata - grid.transform = vdb.createLinearTransform( - layer.affine.transpose()) + grid.transform = vdb.createLinearTransform(layer.affine.transpose()) grid.name = layer.kind # 保存序列帧VDB @@ -272,6 +274,5 @@ def save_layers_to_json(layers: List[Layer], cache_dir: str) -> List[int]: added_ids.append(cache_id) set_layer_caches(existing_data) - print( - f"Successfully added {len(added_ids)} layers to the internal text datablock") + print(f"Successfully added {len(added_ids)} layers to the internal text datablock") return added_ids diff --git a/src/bioxelnodes/node.py b/src/bioxelnodes/node.py index 566bee6..b09b66b 100644 --- a/src/bioxelnodes/node.py +++ b/src/bioxelnodes/node.py @@ -105,7 +105,7 @@ def add_bioxel_node(name: str): bpy.ops.object.modifier_add_node_group( asset_library_type="CUSTOM", asset_library_identifier="O Bioxel", - relative_asset_identifier=f"Nodes.blend\\NodeTree\\{name}", + relative_asset_identifier=f"BioxelNodes.blend\\NodeTree\\{name}", ) bpy.ops.object.modifier_remove(modifier=name) try: diff --git a/src/bioxelnodes/operators/io.py b/src/bioxelnodes/operators/io.py index 96e1b5f..a94fdd2 100644 --- a/src/bioxelnodes/operators/io.py +++ b/src/bioxelnodes/operators/io.py @@ -12,37 +12,20 @@ from ..props import BIOXEL_Series from ..utils import get_layer_obj, wrapped_label -from ..bioxel.layer import Layer -from ..bioxel.parse import DICOM_EXTS, SUPPORT_EXTS, get_ext, parse_volumetric_data +from ..bioxel import ( + DICOM_EXTS, + SUPPORT_EXTS, + read, + read_meta, + calc_layer_shape, + calc_layer_size, + get_ext, +) from ..utils import get_cache_dir, progress_update, progress_bar from ..layer import get_layer_caches, save_layers_to_json -def get_layer_shape(bioxel_size: float, orig_shape: tuple, orig_spacing: tuple): - shape = ( - int(orig_shape[0] / bioxel_size * orig_spacing[0]), - int(orig_shape[1] / bioxel_size * orig_spacing[1]), - int(orig_shape[2] / bioxel_size * orig_spacing[2]), - ) - - return ( - shape[0] if shape[0] > 0 else 1, - shape[1] if shape[1] > 0 else 1, - shape[2] if shape[2] > 0 else 1, - ) - - -def get_layer_size(shape: tuple, bioxel_size: float, scale: float = 1.0): - size = ( - float(shape[0] * bioxel_size * scale), - float(shape[1] * bioxel_size * scale), - float(shape[2] * bioxel_size * scale), - ) - - return size - - """ ImportData -> ParseVolumetricData -> ImportDataDialog start import parse data execute import @@ -211,11 +194,7 @@ def progress_callback(factor, text): try: series_id = self.series_id if self.series_id != "empty" else "" - data, meta = parse_volumetric_data( - data_file=self.filepath, - series_id=series_id, - progress_callback=progress_callback, - ) + data = read_meta(self.filepath, series_id=series_id) except KeyboardInterrupt: return except Exception as e: @@ -225,8 +204,8 @@ def progress_callback(factor, text): if cancel(): return - self.meta = meta - self.label_count = int(np.max(data)) + self.meta = data.meta + self.label_count = int(np.max(data.data)) self.dtype = data.dtype # Init cancel flag @@ -334,8 +313,8 @@ def modal(self, context, event): bioxel_size = max(min(*orig_spacing), 1.0) - layer_shape = get_layer_shape(bioxel_size, orig_shape, orig_spacing) - layer_size = get_layer_size(layer_shape, bioxel_size, 0.01) + layer_shape = calc_layer_shape(bioxel_size, orig_shape, orig_spacing) + layer_size = calc_layer_size(layer_shape, bioxel_size, 0.01) min_log10 = math.floor(math.log10(min(*layer_size))) max_log10 = math.floor(math.log10(max(*layer_size))) @@ -547,33 +526,23 @@ class ImportDataDialog(bpy.types.Operator): def execute(self, context): def import_volumetric_data_func(self, context, cancel): - progress_update(context, 0.0, "Parsing Volumetirc Data...") - def progress_callback(factor, text): if cancel(): raise KeyboardInterrupt("Cancelled by user") progress_update(context, factor * 0.2, text) - def progress_callback_factory(layer_name, progress, progress_step): - def progress_callback(frame, total): - if cancel(): - raise KeyboardInterrupt("Cancelled by user") - sub_progress_step = progress_step / total - sub_progress = progress + frame * sub_progress_step - progress_update( - context, - sub_progress, - f"Processing {layer_name} Frame {frame+1}...", - ) - print(f"Processing {layer_name} Frame {frame+1}...") - - return progress_callback - try: - data, meta = parse_volumetric_data( - data_file=self.filepath, - series_id=self.series_id, - progress_callback=progress_callback, + data = read(self.filepath, self.series_id, progress_callback) + + self.layers = data.to_layers( + kind=self.read_as.lower(), + layer_name=self.layer_name, + bioxel_size=self.bioxel_size, + smooth=self.smooth, + remap=self.remap, + split_channel=self.split_channel, + frame_source=self.frame_source, + progress_callback=lambda f, t: progress_callback(0.2 + f * 0.7, t), ) except KeyboardInterrupt: @@ -585,203 +554,6 @@ def progress_callback(frame, total): if cancel(): return - shape = get_layer_shape( - self.bioxel_size, self.orig_shape, self.orig_spacing - ) - - mat_scale = transforms3d.zooms.zfdir2aff(self.bioxel_size) - affine = np.dot(meta["affine"], mat_scale) - kind = self.read_as.lower() - - if cancel(): - return - - # change shape as sequence or not - if self.frame_source == "-1": - data = data[0:1, :, :, :, :] - elif self.frame_source == "0": - # frame as frame - pass - elif self.frame_source == "1": - # X as frame - data = data.transpose(1, 0, 2, 3, 4) - shape = (1, shape[1], shape[2]) - elif self.frame_source == "2": - # Y as frame - data = data.transpose(2, 1, 0, 3, 4) - shape = (shape[0], 1, shape[2]) - elif self.frame_source == "3": - # Z as frame - data = data.transpose(3, 1, 2, 0, 4) - shape = (shape[0], shape[1], 1) - else: - # channel as frame - data = data.transpose(4, 1, 2, 3, 0) - - layers = [] - if kind == "label": - name = self.layer_name or "Label" - data = data.astype(int) - label_count = int(np.max(data)) - progress_step = 0.7 / label_count - - for i in range(label_count): - if cancel(): - return - - name_i = f"{name}_{i+1}" - progress = 0.2 + i * progress_step - progress_update(context, progress, f"Processing {name_i}...") - - progress_callback = progress_callback_factory( - name_i, progress, progress_step - ) - label_data = data == np.full_like(data, i + 1) - # label_data = label_data.astype(np.float32) - try: - layer = Layer(data=label_data, name=name_i, kind=kind) - - layer.resize( - shape=shape, - smooth=self.smooth, - progress_callback=progress_callback, - ) - - layer.affine = affine - - layers.append(layer) - except KeyboardInterrupt: - return - except Exception as e: - self.has_error = e - return - - if kind == "color": - if np.issubdtype(np.uint8, data.dtype): - data = np.multiply(data, 1.0 / 256, dtype=np.float32) - elif data.dtype.kind in ["u", "i"]: - # Convert the normalized array to float dtype - data = data.astype(np.float32) - - min_val = data.min() - max_val = data.max() - # Avoid division by zero if all values are the same - if max_val != min_val: - # Normalize the array to the range (0,1) - data = (data - min_val) / (max_val - min_val) - else: - # If all values are the same, the normalized array will be all zeros - data = np.zeros_like(data, dtype=np.float32) - - else: - data = data.astype(np.float32) - - # Gamma Correct - # data = data ** 2.2 - - name = self.layer_name or "Color" - if data.shape[4] == 1: - data = np.repeat(data, repeats=3, axis=4) - elif data.shape[4] == 2: - d_shape = list(data.shape) - d_shape = d_shape[:4] + [1] - zore = np.zeros(tuple(d_shape), dtype=np.float32) - data = np.concatenate((data, zore), axis=-1) - elif data.shape[4] > 3: - data = data[:, :, :, :, :3] - - if cancel(): - return - - progress_update(context, 0.2, f"Processing {name}...") - progress_callback = progress_callback_factory(name, 0.2, 0.7) - - try: - layer = Layer(data=data, name=name, kind=kind) - - layer.resize(shape=shape, progress_callback=progress_callback) - - layer.affine = affine - - layers.append(layer) - except KeyboardInterrupt: - return - except Exception as e: - self.has_error = e - return - - elif kind == "scalar": - name = self.layer_name or "Scalar" - - if self.remap: - # Convert the normalized array to float dtype - data = data.astype(np.float32) - - min_val = data.min() - max_val = data.max() - # Avoid division by zero if all values are the same - if max_val != min_val: - # Normalize the array to the range (0,1) - data = (data - min_val) / (max_val - min_val) - else: - # If all values are the same, the normalized array will be all zeros - data = np.zeros_like(data, dtype=np.float32) - - if self.split_channel: - progress_step = 0.7 / self.channel_count - - for i in range(self.channel_count): - if cancel(): - return - - name_i = f"{name}_{i+1}" - progress = 0.2 + i * progress_step - progress_update(context, progress, f"Processing {name_i}...") - progress_callback = progress_callback_factory( - name_i, progress, progress_step - ) - try: - layer = Layer( - data=data[:, :, :, :, i : i + 1], name=name_i, kind=kind - ) - - layer.resize( - shape=shape, progress_callback=progress_callback - ) - - layer.affine = affine - - layers.append(layer) - except KeyboardInterrupt: - return - except Exception as e: - self.has_error = e - return - else: - if cancel(): - return - - progress_update(context, 0.2, f"Processing {name}...") - progress_callback = progress_callback_factory(name, 0.2, 0.7) - - try: - layer = Layer(data=data, name=name, kind=kind) - - layer.resize(shape=shape, progress_callback=progress_callback) - - layer.affine = affine - - layers.append(layer) - except KeyboardInterrupt: - return - except Exception as e: - self.has_error = e - return - - if cancel(): - return - - self.layers = layers progress_update(context, 0.9, "Creating Layers...") self.is_cancelled = False @@ -851,7 +623,7 @@ def invoke(self, context, event): return {"RUNNING_MODAL"} def draw(self, context): - layer_shape = get_layer_shape( + layer_shape = calc_layer_shape( self.bioxel_size, self.orig_shape, self.orig_spacing ) diff --git a/src/bioxelnodes/operators/layer.py b/src/bioxelnodes/operators/layer.py index 6825012..cc95bdc 100644 --- a/src/bioxelnodes/operators/layer.py +++ b/src/bioxelnodes/operators/layer.py @@ -16,13 +16,11 @@ class RenameLayer(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} cache_id: bpy.props.StringProperty(options={"HIDDEN"}) # type: ignore - new_name: bpy.props.StringProperty( - name="New name", default="") # type: ignore + new_name: bpy.props.StringProperty(name="New name", default="") # type: ignore 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) + 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) @@ -127,8 +125,7 @@ def execute(self, context): layer_node.inputs["Frame Count"].default_value = entry["frame_count"] layer_node.inputs["Animation"].default_value = True else: - hidden_sockets = hidden_sockets + \ - ["Frame Count", "Frame Offset", "Cycle"] + hidden_sockets = hidden_sockets + ["Frame Count", "Frame Offset", "Cycle"] # 将特定属性隐藏到hidden面板 for socket_name in hidden_sockets: @@ -177,8 +174,7 @@ def execute(self, context): return {"CANCELLED"} caches = get_layer_caches() - entry = next((c for c in caches if str( - c.get("id", "")) == self.cache_id), None) + 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"} @@ -228,8 +224,7 @@ class SaveLayer(bpy.types.Operator): def invoke(self, context, event): # Default to Blender file directory if possible - blend_dir = Path( - bpy.data.filepath).parent if bpy.data.filepath else Path.cwd() + 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) @@ -244,8 +239,7 @@ def execute(self, context): return {"CANCELLED"} caches = get_layer_caches() - entry = next((c for c in caches if str( - c.get("id")) == self.cache_id), None) + entry = next((c for c in caches if str(c.get("id")) == self.cache_id), None) cache_id = entry.get("id") if not entry: diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..db4d862 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,43 @@ +# BioxelNodes Unit Tests + +This directory contains unit tests for the bioxel module. + +## Test Data + +Test files should be placed in `tests/data/`: + +## Running Tests + +Run all tests: +```bash +uv run pytest +``` + +Run specific test file: +```bash +uv run pytest tests/test_bioxel.py +``` + +Run specific test class: +```bash +uv run pytest tests/test_bioxel.py::TestDataClass +``` + +Run with verbose output: +```bash +uv run pytest -v +``` + +Run with coverage: +```bash +uv run pytest --cov=bioxelnodes.bioxel --cov-report=html +``` + +## Test Categories + +- **TestDataClass**: Data class initialization and properties +- **TestDataToLayers**: Data.to_layers() method +- **TestUtilityFunctions**: Utility functions +- **TestLayerClass**: Layer class functionality +- **TestConstants**: Module constants +- **TestEdgeCases**: Edge cases and error handling diff --git a/tests/test_bioxel.py b/tests/test_bioxel.py new file mode 100644 index 0000000..43cbe6c --- /dev/null +++ b/tests/test_bioxel.py @@ -0,0 +1,539 @@ +""" +Unit tests for bioxel module. + +Tests cover: +- Data class functionality +- Parsing NRRD and DICOM formats +- Layer creation and transformation +- Utility functions +""" + +from bioxelnodes.bioxel import ( + Data, + read, + read_meta, + calc_layer_shape, + calc_layer_size, + Layer, +) +import pytest +import numpy as np +from pathlib import Path + +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + + +# Test data files +NRRD_FILE = Path(__file__).parent / "data/VHP_M.nrrd" +DICOM_FILE = Path(__file__).parent / "data/VHP_M_CT_Head/IM000001.dcm" +DICOM_DIR = Path(__file__).parent / "data/VHP_M_CT_Head/" + + +class TestDataClass: + """Test Data class initialization and properties""" + + def test_data_initialization(self): + """Test basic Data initialization with NRRD""" + data = Data(filepath=str(NRRD_FILE), series_id="") + + assert data.filepath is not None + assert data.series_id == "" + assert data._data is None + assert data._meta is None + + def test_read_meta_nrrd(self): + """Test reading only meta from NRRD file""" + data_obj = read_meta(str(NRRD_FILE)) + + assert data_obj._data is None + assert data_obj._meta is not None + assert "name" in data_obj.meta + assert "spacing" in data_obj.meta + assert "affine" in data_obj.meta + assert "xyz_shape" in data_obj.meta + assert "frame_count" in data_obj.meta + assert "channel_count" in data_obj.meta + assert "dtype" in data_obj.meta + + def test_read_meta_dicom(self): + """Test reading only meta from DICOM file""" + data_obj = read_meta(str(DICOM_FILE), series_id="") + + assert data_obj._data is None + assert data_obj._meta is not None + assert "name" in data_obj.meta + + def test_read_full_data_nrrd(self): + """Test reading full NRRD data""" + data_obj = read(str(NRRD_FILE)) + + assert data_obj._data is not None + assert data_obj._meta is not None + assert isinstance(data_obj.data, np.ndarray) + assert data_obj.data.ndim == 5 + + def test_read_full_data_dicom(self): + """Test reading full DICOM data""" + data_obj = read(str(DICOM_FILE), series_id="") + + assert data_obj._data is not None + assert data_obj._meta is not None + assert isinstance(data_obj.data, np.ndarray) + + def test_data_properties_nrrd(self): + """Test Data property accessors""" + data_obj = read(str(NRRD_FILE)) + + assert hasattr(data_obj, "name") + assert hasattr(data_obj, "description") + assert hasattr(data_obj, "shape") + assert hasattr(data_obj, "xyz_shape") + assert hasattr(data_obj, "spacing") + assert hasattr(data_obj, "affine") + assert hasattr(data_obj, "frame_count") + assert hasattr(data_obj, "channel_count") + assert hasattr(data_obj, "dtype") + + assert isinstance(data_obj.shape, tuple) + assert isinstance(data_obj.xyz_shape, tuple) + assert isinstance(data_obj.spacing, tuple) + assert isinstance(data_obj.affine, np.ndarray) + assert isinstance(data_obj.dtype, np.dtype) + + def test_data_lazy_loading(self): + """Test lazy loading behavior""" + data_obj = Data(filepath=str(NRRD_FILE), series_id="") + + assert data_obj.is_loaded() is False + + data_obj.load_meta() + assert data_obj._data is None + assert data_obj._meta is not None + + data_obj.load_data() + assert data_obj._data is not None + assert data_obj.is_loaded() is True + + def test_shape_property(self): + """Test shape property returns TXYZC format""" + data_obj = read(str(NRRD_FILE)) + + shape = data_obj.shape + assert len(shape) == 5 + assert shape[0] == data_obj.meta["frame_count"] + assert shape[1:4] == data_obj.meta["xyz_shape"] + assert shape[4] == data_obj.meta["channel_count"] + + def test_load_meta_method(self): + """Test load_meta() method exists and works""" + data_obj = Data(filepath=str(NRRD_FILE), series_id="") + + assert hasattr(data_obj, "load_meta") + + data_obj.load_meta() + assert data_obj._meta is not None + assert data_obj._data is None + + +class TestDataToLayers: + """Test Data.to_layers() method""" + + def test_to_layers_dicom_scalar(self): + """Test converting DICOM data to scalar layers""" + data_obj = read(str(DICOM_FILE), series_id="") + + layers = data_obj.to_layers(kind="scalar", bioxel_size=2.0) + + assert len(layers) == 1 + assert layers[0].kind == "scalar" + assert isinstance(layers[0], Layer) + + def test_to_layers_nrrd_scalar(self): + """Test converting NRRD data to scalar layers""" + data_obj = read(str(NRRD_FILE)) + + layers = data_obj.to_layers(kind="scalar", bioxel_size=2.0) + + assert len(layers) == 1 + assert layers[0].kind == "scalar" + assert isinstance(layers[0], Layer) + + def test_to_layers_with_remap(self): + """Test data remapping""" + data_obj = read(str(NRRD_FILE)) + + layers = data_obj.to_layers(kind="scalar", bioxel_size=2.0, remap=True) + + assert len(layers) == 1 + assert layers[0].data.dtype == np.float32 + assert np.all(layers[0].data >= 0) + assert np.all(layers[0].data <= 1) + + def test_to_layers_with_bioxel_size(self): + """Test with different bioxel sizes""" + data_obj = read(str(NRRD_FILE)) + + layers_1x = data_obj.to_layers(kind="scalar", bioxel_size=1.0) + layers_2x = data_obj.to_layers(kind="scalar", bioxel_size=2.0) + + assert len(layers_1x) == 1 + assert len(layers_2x) == 1 + + # Larger bioxel size means smaller layer + shape_1x = layers_1x[0].shape + shape_2x = layers_2x[0].shape + assert shape_2x[0] <= shape_1x[0] + assert shape_2x[1] <= shape_1x[1] + assert shape_2x[2] <= shape_1x[2] + + def test_to_layers_with_smooth(self): + """Test with smoothing parameter""" + data_obj = read(str(NRRD_FILE)) + + layers = data_obj.to_layers(kind="scalar", bioxel_size=2.0, smooth=1) + + assert len(layers) == 1 + assert isinstance(layers[0], Layer) + + def test_to_layers_frame_source_first_frame(self): + """Test frame_source = '-1' (first frame only)""" + data_obj = read(str(NRRD_FILE)) + + layers = data_obj.to_layers(kind="scalar", bioxel_size=2.0, frame_source="-1") + + assert len(layers) == 1 + assert layers[0].frame_count == 1 + + +class TestUtilityFunctions: + """Test utility functions""" + + def test_calc_layer_shape(self): + """Test layer shape calculation""" + shape = calc_layer_shape( + bioxel_size=1.0, orig_shape=(100, 100, 100), orig_spacing=(1.0, 1.0, 1.0) + ) + + assert len(shape) == 3 + assert shape[0] == 100 + assert shape[1] == 100 + assert shape[2] == 100 + + def test_calc_layer_shape_small_bioxel(self): + """Test layer shape with small bioxel size""" + shape = calc_layer_shape( + bioxel_size=2.0, orig_shape=(100, 100, 100), orig_spacing=(1.0, 1.0, 1.0) + ) + + assert len(shape) == 3 + assert shape[0] == 50 + assert shape[1] == 50 + assert shape[2] == 50 + + def test_calc_layer_shape_minimum(self): + """Test layer shape doesn't go below 1""" + shape = calc_layer_shape( + bioxel_size=100.0, orig_shape=(100, 100, 100), orig_spacing=(1.0, 1.0, 1.0) + ) + + assert shape == (1, 1, 1) + + def test_calc_layer_size(self): + """Test layer size calculation""" + size = calc_layer_size(shape=(100, 100, 100), bioxel_size=1.0, scale=1.0) + + assert len(size) == 3 + assert size[0] == 100.0 + assert size[1] == 100.0 + assert size[2] == 100.0 + + def test_calc_layer_size_with_scale(self): + """Test layer size with scale factor""" + size = calc_layer_size(shape=(100, 100, 100), bioxel_size=1.0, scale=0.01) + + assert len(size) == 3 + assert size[0] == 1.0 + assert size[1] == 1.0 + assert size[2] == 1.0 + + +class TestLayerClass: + """Test Layer class functionality""" + + def test_layer_initialization(self): + """Test Layer initialization""" + data = np.random.rand(1, 8, 8, 8, 1).astype(np.float32) + layer = Layer(data=data, name="test_layer", kind="scalar") + + assert layer.name == "test_layer" + assert layer.kind == "scalar" + assert np.array_equal(layer.data, data) + + def test_layer_properties(self): + """Test Layer property accessors""" + data = np.random.rand(1, 8, 8, 8, 1).astype(np.float32) + layer = Layer( + data=data, name="test_layer", kind="scalar", affine=np.identity(4) + ) + + assert hasattr(layer, "bioxel_size") + assert hasattr(layer, "shape") + assert hasattr(layer, "dtype") + assert hasattr(layer, "origin") + assert hasattr(layer, "euler") + assert hasattr(layer, "frame_count") + assert hasattr(layer, "channel_count") + assert hasattr(layer, "min") + assert hasattr(layer, "max") + + def test_layer_shape_property(self): + """Test layer shape returns XYZ (not TXYZC)""" + data = np.random.rand(1, 8, 8, 8, 1).astype(np.float32) + layer = Layer(data=data, name="test", kind="scalar") + + shape = layer.shape + assert len(shape) == 3 + assert shape[0] == 8 + assert shape[1] == 8 + assert shape[2] == 8 + + def test_layer_affine_property(self): + """Test affine transformation matrix""" + data = np.random.rand(1, 8, 8, 8, 1).astype(np.float32) + affine = np.array( + [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + ) + layer = Layer(data=data, name="test", kind="scalar", affine=affine) + + result_affine = layer.affine + assert result_affine.shape == (4, 4) + assert np.allclose(result_affine, affine) + + def test_layer_min_max(self): + """Test layer min/max properties""" + data = np.random.rand(1, 8, 8, 8, 1).astype(np.float32) + layer = Layer(data=data, name="test", kind="scalar") + + assert isinstance(layer.min, float) + assert isinstance(layer.max, float) + assert layer.min <= layer.max + + def test_layer_copy(self): + """Test layer deep copy""" + data = np.random.rand(1, 8, 8, 8, 1).astype(np.float32) + layer = Layer(data=data, name="test", kind="scalar") + layer_copy = layer.copy() + + assert layer_copy.name == layer.name + assert layer_copy.kind == layer.kind + assert np.array_equal(layer_copy.data, layer.data) + + assert layer_copy is not layer + layer_copy.data[0, 0, 0, 0, 0] = 999.0 + assert layer.data[0, 0, 0, 0, 0] != 999.0 + + def test_layer_invalid_data_shape(self): + """Test Layer raises error for invalid data shape""" + data = np.random.rand(8, 8, 8).astype(np.float32) + + with pytest.raises(Exception): + Layer(data=data, name="test", kind="scalar") + + def test_layer_invalid_affine_shape(self): + """Test Layer raises error for invalid affine shape""" + data = np.random.rand(1, 8, 8, 8, 1).astype(np.float32) + affine = np.identity(3) + + with pytest.raises(Exception): + Layer(data=data, name="test", kind="scalar", affine=affine) + + +class TestConstants: + """Test module constants""" + + def test_support_exts_exists(self): + """Test SUPPORT_EXTS constant""" + from bioxelnodes.bioxel import SUPPORT_EXTS + + assert isinstance(SUPPORT_EXTS, list) + assert len(SUPPORT_EXTS) > 0 + assert ".h5" in SUPPORT_EXTS + assert ".nrrd" in SUPPORT_EXTS + assert ".nii.gz" in SUPPORT_EXTS + assert ".dcm" in SUPPORT_EXTS + + def test_dicom_exts_exists(self): + """Test DICOM_EXTS constant""" + from bioxelnodes.bioxel import DICOM_EXTS + + assert isinstance(DICOM_EXTS, list) + assert "" in DICOM_EXTS + assert ".dcm" in DICOM_EXTS + assert ".DCM" in DICOM_EXTS + + def test_ome_exts_exists(self): + """Test OME_EXTS constant""" + from bioxelnodes.bioxel import OME_EXTS + + assert isinstance(OME_EXTS, list) + assert ".tiff" in OME_EXTS + assert ".ome.tiff" in OME_EXTS + + def test_mrc_exts_exists(self): + """Test MRC_EXTS constant""" + from bioxelnodes.bioxel import MRC_EXTS + + assert isinstance(MRC_EXTS, list) + assert ".mrc" in MRC_EXTS + + +class TestEdgeCases: + """Test edge cases and error handling""" + + def test_nonexistent_file(self): + """Test handling of nonexistent file""" + with pytest.raises(FileNotFoundError): + data_obj = read("/nonexistent/file.nii.gz", "") + + def test_frame_count_property_multiframe(self): + """Test frame_count property for multi-frame data""" + data_obj = read(str(NRRD_FILE)) + + assert data_obj.frame_count >= 1 + assert data_obj.frame_count == data_obj.meta["frame_count"] + + def test_channel_count_property(self): + """Test channel_count property""" + data_obj = read(str(NRRD_FILE)) + + assert data_obj.channel_count >= 1 + assert data_obj.channel_count == data_obj.meta["channel_count"] + + def test_nrrd_format_support(self): + """Test NRRD format is in supported extensions""" + from bioxelnodes.bioxel import SUPPORT_EXTS + + assert ".nrrd" in SUPPORT_EXTS + + def test_dicom_format_support(self): + """Test DICOM format is in supported extensions""" + from bioxelnodes.bioxel import SUPPORT_EXTS + + assert ".dcm" in SUPPORT_EXTS + assert "" in SUPPORT_EXTS + + +class TestRealDataFiles: + """Test with real data files""" + + def test_nrrd_file_exists(self): + """Test NRRD test file exists""" + assert NRRD_FILE.exists() + assert NRRD_FILE.is_file() + + def test_dicom_file_exists(self): + """Test DICOM test file exists""" + assert DICOM_FILE.exists() + assert DICOM_FILE.is_file() + + def test_dicom_directory_exists(self): + """Test DICOM directory exists""" + assert DICOM_DIR.exists() + assert DICOM_DIR.is_dir() + + def test_read_nrrd_data(self): + """Test reading real NRRD data""" + data_obj = read(str(NRRD_FILE)) + + assert data_obj._data is not None + assert data_obj._meta is not None + assert isinstance(data_obj.data, np.ndarray) + assert data_obj.data.size > 0 + + def test_read_dicom_data(self): + """Test reading real DICOM data""" + data_obj = read(str(DICOM_FILE), series_id="") + + assert data_obj._data is not None + assert data_obj._meta is not None + assert isinstance(data_obj.data, np.ndarray) + assert data_obj.data.size > 0 + + def test_nrrd_meta_contains_required_fields(self): + """Test NRRD meta contains all required fields""" + data_obj = read(str(NRRD_FILE)) + meta = data_obj.meta + + required_fields = [ + "name", + "description", + "spacing", + "affine", + "xyz_shape", + "frame_count", + "channel_count", + "dtype", + ] + + for field in required_fields: + assert field in meta, f"Meta missing field: {field}" + + def test_dicom_meta_contains_required_fields(self): + """Test DICOM meta contains all required fields""" + data_obj = read(str(DICOM_FILE), series_id="") + meta = data_obj.meta + + required_fields = [ + "name", + "description", + "spacing", + "affine", + "xyz_shape", + "frame_count", + "channel_count", + "dtype", + ] + + for field in required_fields: + assert field in meta, f"Meta missing field: {field}" + + def test_data_to_layers_with_real_nrrd(self): + """Test to_layers with real NRRD data""" + data_obj = read(str(NRRD_FILE)) + + layers = data_obj.to_layers(kind="scalar", bioxel_size=3.0) + + assert len(layers) > 0 + assert all(isinstance(layer, Layer) for layer in layers) + + def test_data_to_layers_with_real_dicom(self): + """Test to_layers with real DICOM data""" + data_obj = read(str(DICOM_FILE), series_id="") + + layers = data_obj.to_layers(kind="scalar", bioxel_size=3.0) + + assert len(layers) > 0 + assert all(isinstance(layer, Layer) for layer in layers) + + def test_nrrd_spacing_values(self): + """Test NRRD spacing values are reasonable""" + data_obj = read(str(NRRD_FILE)) + spacing = data_obj.spacing + + assert len(spacing) == 3 + assert all(s > 0 for s in spacing) + + def test_dicom_spacing_values(self): + """Test DICOM spacing values are reasonable""" + data_obj = read(str(DICOM_FILE), series_id="") + spacing = data_obj.spacing + + assert len(spacing) == 3 + assert all(s > 0 for s in spacing) From b83dd0acd90754cf8b216d03c5a871c928a92924 Mon Sep 17 00:00:00 2001 From: nan Date: Mon, 25 May 2026 09:15:47 +0800 Subject: [PATCH 03/14] feat: Update .gitattributes and .gitignore for improved asset management and ignore patterns chore: Add app.json and omoolab.css for Obsidian integration refactor: Clean up AGENTS.md and remove outdated test files --- .gitattributes | 132 +++++++- .gitignore | 304 +++++++++++++++++-- .obsidian/app.json | 22 ++ .obsidian/snippets/omoolab.css | 30 ++ .python-version | 1 + AGENTS.md | 236 +-------------- CLAUDE.md | 1 + tests/README.md | 43 --- tests/test_bioxel.py | 539 --------------------------------- 9 files changed, 477 insertions(+), 831 deletions(-) create mode 100644 .obsidian/app.json create mode 100644 .obsidian/snippets/omoolab.css create mode 100644 .python-version create mode 100644 CLAUDE.md delete mode 100644 tests/README.md delete mode 100644 tests/test_bioxel.py diff --git a/.gitattributes b/.gitattributes index 80a1944..f471a1f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,131 @@ -*.blend filter=lfs diff=lfs merge=lfs -text +# Define macros (only works in top-level gitattributes files) +[attr]lfs filter=lfs diff=lfs merge=lfs -text + +# 3D models +*.collada lfs +*.dae lfs +*.dxf lfs +*.FBX lfs +*.fbx lfs +*.jas lfs +*.lws lfs +*.lxo lfs +*.obj lfs +*.ply lfs +*.skp lfs +*.stl lfs +*.stp lfs +*.usd lfs +*.usda lfs +*.usdc lfs +*.usdz lfs +*.glb lfs +*.gltf lfs + +# Audio +*.aif lfs +*.aiff lfs +*.it lfs +*.mod lfs +*.mp3 lfs +*.ogg lfs +*.s3m lfs +*.wav lfs +*.xm lfs + +# Video +*.asf lfs +*.avi lfs +*.flv lfs +*.mov lfs +*.mp4 lfs +*.mpeg lfs +*.mpg lfs +*.ogv lfs +*.wmv lfs +*.mkv lfs + +# Images +*.bmp lfs +*.exr lfs +*.gif lfs +*.hdr lfs +*.iff lfs +*.jpeg lfs +*.jpg lfs +*.pict lfs +*.png lfs +*.psd lfs +*.tga lfs +*.tif lfs +*.tiff lfs +*.webp lfs + +# Compressed Archive +*.7z lfs +*.bz2 lfs +*.gz lfs +*.rar lfs +*.tar lfs +*.zip lfs + +# Compiled Dynamic Library +*.dll lfs +*.pdb lfs +*.so lfs + +# Fonts +*.otf lfs +*.ttf lfs +*.woff lfs +*.woff2 lfs + +# Executable/Installer +*.apk lfs +*.exe lfs + +# Documents +*.pdf lfs +*.doc lfs +*.xls lfs +*.ppt lfs +*.docx lfs +*.xlsx lfs +*.pptx lfs + +# Adobe +*.psd lfs +*.ai lfs +*.aep lfs +*.prproj lfs +*.spp lfs + +# Zbrush +*.zbr lfs +*.zpr lfs +*.ztl lfs +*.c4d lfs + +# Autodesk +*.max lfs +*.ma lfs +*.mb lfs +*.3dm lfs +*.3ds lfs + +# Blender +*.blend lfs +*.blend1 lfs +*.c4d lfs + +# Imaging +*.dicom lfs +*.dcm lfs +*.nii lfs +*.ome lfs +*.map lfs +*.mrc lfs + +# Others +*.abr lfs +*.tpl lfs \ No newline at end of file diff --git a/.gitignore b/.gitignore index 960f98e..9063c92 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,51 @@ +# Tempary files +temp +sandbox.* +notes/ + +# Environments +.env +.envrc +.venv +.env.* +!.env.example +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +.secrets +.secrets.* +!.secrets.example +secrets/ +SECRETS/ +secrets.bak/ + +# Claude +.claudian + +# Obsidian + +.obsidian/* +!.obsidian/snippets +!.obsidian/app.json + +*.canvas + +# Keep odox for extract to openwiki +!.obsidian/plugins +.obsidian/plugins/* +!.obsidian/plugins/odox + +# Blender +*.blend1 +blender_assets.cats.txt~ +blendcache_* + # Byte-compiled / optimized / DLL files __pycache__/ -*.py[cod] +*.py[codz] *$py.class # C extensions @@ -20,7 +65,6 @@ parts/ sdist/ var/ wheels/ -pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg @@ -28,8 +72,8 @@ share/python-wheels/ MANIFEST # PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec @@ -47,8 +91,10 @@ htmlcov/ nosetests.xml coverage.xml *.cover +*.py.cover .hypothesis/ .pytest_cache/ +cover/ # Translations *.mo @@ -71,6 +117,7 @@ instance/ docs/_build/ # PyBuilder +.pybuilder/ target/ # Jupyter Notebook @@ -81,30 +128,70 @@ profile_default/ ipython_config.py # pyenv -.python-version +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. -#Pipfile.lock +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock -# celery beat schedule file +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ # SageMath parsed files *.sage.py -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - # Spyder project settings .spyderproject .spyproject @@ -123,18 +210,183 @@ dmypy.json # Pyre type checker .pyre/ -pythonlib* +# pytype static type analyzer +.pytype/ -*.ipynb +# Cython debug symbols +cython_debug/ -.secrets -.vdb +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ -!scipy_ndimage/*/** +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ -# Blender -*.blend1 -blendcache_* -*.cats.txt~ +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist +.output + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Sveltekit cache directory +.svelte-kit/ + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Firebase cache directory +.firebase/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# pnpm +.pnpm-store + +# yarn v3 +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions -tests/data \ No newline at end of file +# Vite files +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +.vite/ \ No newline at end of file diff --git a/.obsidian/app.json b/.obsidian/app.json new file mode 100644 index 0000000..ac3bb1b --- /dev/null +++ b/.obsidian/app.json @@ -0,0 +1,22 @@ +{ + "showInlineTitle": false, + "useMarkdownLinks": true, + "pdfExportSettings": { + "pageSize": "Letter", + "landscape": false, + "margin": "0", + "downscalePercent": 100 + }, + "userIgnoreFilters": [ + "notes/assets/" + ], + "showUnsupportedFiles": false, + "attachmentFolderPath": "notes/assets", + "livePreview": true, + "showIndentGuide": false, + "alwaysUpdateLinks": true, + "openBehavior": "file:README", + "newFileLocation": "folder", + "newFileFolderPath": "notes", + "newLinkFormat": "relative" +} \ No newline at end of file diff --git a/.obsidian/snippets/omoolab.css b/.obsidian/snippets/omoolab.css new file mode 100644 index 0000000..92c8a0d --- /dev/null +++ b/.obsidian/snippets/omoolab.css @@ -0,0 +1,30 @@ +/* not show label in canvas */ +.canvas-node-label { + display: none; +} + +.canvas-node-container:hover + .canvas-node-label { + display: inherit; +} + +/* image caption */ +.image-embed { + flex-wrap: wrap; +} + +.image-embed[alt]:after { + content: attr(alt); + display: block; + padding-bottom: 12px; + line-height: 2.0; + font-style: italic; + color: var(--text-faint); + text-align: center; + width: 100%; +} + + +/* fix image in canvas */ +.image-embed { + padding: 0; +} \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/AGENTS.md b/AGENTS.md index 661944b..870aad8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,227 +1,19 @@ -# BioxelNodes - Agent Development Guide +## 总规范 -This file contains essential information for agentic coding agents working on the BioxelNodes codebase. +- 说明性内容(例如提交说明、文档、注释、mermaid),以中文为主,专有名词、约定俗称可用英文 -## Project Overview +## Markdown 规范 -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. +- 不使用`---`分割器 +- `# Heading` 一级标题仅用于开头 +- mermaid 节点名用英文字符 -**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 # platforms: windows-x64, linux-x64, macos-arm64, macos-x64 -``` - -### Unit Test -```bash -uv run pytest tests/ -``` - -### 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 \ No newline at end of file +- 避免嵌套结构 +- 特殊的、非常规的需要写注释 +- 变量、函数名、Docstring 用全英文 +- 同一事物用统一表达 +- 使用的依赖越少越好 +- 选用简单直接的方式实现 +- 代码、执行方式改变,调整已有的文档、计划,而非新建 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eef4bd2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md \ No newline at end of file diff --git a/tests/README.md b/tests/README.md deleted file mode 100644 index db4d862..0000000 --- a/tests/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# BioxelNodes Unit Tests - -This directory contains unit tests for the bioxel module. - -## Test Data - -Test files should be placed in `tests/data/`: - -## Running Tests - -Run all tests: -```bash -uv run pytest -``` - -Run specific test file: -```bash -uv run pytest tests/test_bioxel.py -``` - -Run specific test class: -```bash -uv run pytest tests/test_bioxel.py::TestDataClass -``` - -Run with verbose output: -```bash -uv run pytest -v -``` - -Run with coverage: -```bash -uv run pytest --cov=bioxelnodes.bioxel --cov-report=html -``` - -## Test Categories - -- **TestDataClass**: Data class initialization and properties -- **TestDataToLayers**: Data.to_layers() method -- **TestUtilityFunctions**: Utility functions -- **TestLayerClass**: Layer class functionality -- **TestConstants**: Module constants -- **TestEdgeCases**: Edge cases and error handling diff --git a/tests/test_bioxel.py b/tests/test_bioxel.py deleted file mode 100644 index 43cbe6c..0000000 --- a/tests/test_bioxel.py +++ /dev/null @@ -1,539 +0,0 @@ -""" -Unit tests for bioxel module. - -Tests cover: -- Data class functionality -- Parsing NRRD and DICOM formats -- Layer creation and transformation -- Utility functions -""" - -from bioxelnodes.bioxel import ( - Data, - read, - read_meta, - calc_layer_shape, - calc_layer_size, - Layer, -) -import pytest -import numpy as np -from pathlib import Path - -import sys - -sys.path.insert(0, str(Path(__file__).parent.parent / "src")) - - -# Test data files -NRRD_FILE = Path(__file__).parent / "data/VHP_M.nrrd" -DICOM_FILE = Path(__file__).parent / "data/VHP_M_CT_Head/IM000001.dcm" -DICOM_DIR = Path(__file__).parent / "data/VHP_M_CT_Head/" - - -class TestDataClass: - """Test Data class initialization and properties""" - - def test_data_initialization(self): - """Test basic Data initialization with NRRD""" - data = Data(filepath=str(NRRD_FILE), series_id="") - - assert data.filepath is not None - assert data.series_id == "" - assert data._data is None - assert data._meta is None - - def test_read_meta_nrrd(self): - """Test reading only meta from NRRD file""" - data_obj = read_meta(str(NRRD_FILE)) - - assert data_obj._data is None - assert data_obj._meta is not None - assert "name" in data_obj.meta - assert "spacing" in data_obj.meta - assert "affine" in data_obj.meta - assert "xyz_shape" in data_obj.meta - assert "frame_count" in data_obj.meta - assert "channel_count" in data_obj.meta - assert "dtype" in data_obj.meta - - def test_read_meta_dicom(self): - """Test reading only meta from DICOM file""" - data_obj = read_meta(str(DICOM_FILE), series_id="") - - assert data_obj._data is None - assert data_obj._meta is not None - assert "name" in data_obj.meta - - def test_read_full_data_nrrd(self): - """Test reading full NRRD data""" - data_obj = read(str(NRRD_FILE)) - - assert data_obj._data is not None - assert data_obj._meta is not None - assert isinstance(data_obj.data, np.ndarray) - assert data_obj.data.ndim == 5 - - def test_read_full_data_dicom(self): - """Test reading full DICOM data""" - data_obj = read(str(DICOM_FILE), series_id="") - - assert data_obj._data is not None - assert data_obj._meta is not None - assert isinstance(data_obj.data, np.ndarray) - - def test_data_properties_nrrd(self): - """Test Data property accessors""" - data_obj = read(str(NRRD_FILE)) - - assert hasattr(data_obj, "name") - assert hasattr(data_obj, "description") - assert hasattr(data_obj, "shape") - assert hasattr(data_obj, "xyz_shape") - assert hasattr(data_obj, "spacing") - assert hasattr(data_obj, "affine") - assert hasattr(data_obj, "frame_count") - assert hasattr(data_obj, "channel_count") - assert hasattr(data_obj, "dtype") - - assert isinstance(data_obj.shape, tuple) - assert isinstance(data_obj.xyz_shape, tuple) - assert isinstance(data_obj.spacing, tuple) - assert isinstance(data_obj.affine, np.ndarray) - assert isinstance(data_obj.dtype, np.dtype) - - def test_data_lazy_loading(self): - """Test lazy loading behavior""" - data_obj = Data(filepath=str(NRRD_FILE), series_id="") - - assert data_obj.is_loaded() is False - - data_obj.load_meta() - assert data_obj._data is None - assert data_obj._meta is not None - - data_obj.load_data() - assert data_obj._data is not None - assert data_obj.is_loaded() is True - - def test_shape_property(self): - """Test shape property returns TXYZC format""" - data_obj = read(str(NRRD_FILE)) - - shape = data_obj.shape - assert len(shape) == 5 - assert shape[0] == data_obj.meta["frame_count"] - assert shape[1:4] == data_obj.meta["xyz_shape"] - assert shape[4] == data_obj.meta["channel_count"] - - def test_load_meta_method(self): - """Test load_meta() method exists and works""" - data_obj = Data(filepath=str(NRRD_FILE), series_id="") - - assert hasattr(data_obj, "load_meta") - - data_obj.load_meta() - assert data_obj._meta is not None - assert data_obj._data is None - - -class TestDataToLayers: - """Test Data.to_layers() method""" - - def test_to_layers_dicom_scalar(self): - """Test converting DICOM data to scalar layers""" - data_obj = read(str(DICOM_FILE), series_id="") - - layers = data_obj.to_layers(kind="scalar", bioxel_size=2.0) - - assert len(layers) == 1 - assert layers[0].kind == "scalar" - assert isinstance(layers[0], Layer) - - def test_to_layers_nrrd_scalar(self): - """Test converting NRRD data to scalar layers""" - data_obj = read(str(NRRD_FILE)) - - layers = data_obj.to_layers(kind="scalar", bioxel_size=2.0) - - assert len(layers) == 1 - assert layers[0].kind == "scalar" - assert isinstance(layers[0], Layer) - - def test_to_layers_with_remap(self): - """Test data remapping""" - data_obj = read(str(NRRD_FILE)) - - layers = data_obj.to_layers(kind="scalar", bioxel_size=2.0, remap=True) - - assert len(layers) == 1 - assert layers[0].data.dtype == np.float32 - assert np.all(layers[0].data >= 0) - assert np.all(layers[0].data <= 1) - - def test_to_layers_with_bioxel_size(self): - """Test with different bioxel sizes""" - data_obj = read(str(NRRD_FILE)) - - layers_1x = data_obj.to_layers(kind="scalar", bioxel_size=1.0) - layers_2x = data_obj.to_layers(kind="scalar", bioxel_size=2.0) - - assert len(layers_1x) == 1 - assert len(layers_2x) == 1 - - # Larger bioxel size means smaller layer - shape_1x = layers_1x[0].shape - shape_2x = layers_2x[0].shape - assert shape_2x[0] <= shape_1x[0] - assert shape_2x[1] <= shape_1x[1] - assert shape_2x[2] <= shape_1x[2] - - def test_to_layers_with_smooth(self): - """Test with smoothing parameter""" - data_obj = read(str(NRRD_FILE)) - - layers = data_obj.to_layers(kind="scalar", bioxel_size=2.0, smooth=1) - - assert len(layers) == 1 - assert isinstance(layers[0], Layer) - - def test_to_layers_frame_source_first_frame(self): - """Test frame_source = '-1' (first frame only)""" - data_obj = read(str(NRRD_FILE)) - - layers = data_obj.to_layers(kind="scalar", bioxel_size=2.0, frame_source="-1") - - assert len(layers) == 1 - assert layers[0].frame_count == 1 - - -class TestUtilityFunctions: - """Test utility functions""" - - def test_calc_layer_shape(self): - """Test layer shape calculation""" - shape = calc_layer_shape( - bioxel_size=1.0, orig_shape=(100, 100, 100), orig_spacing=(1.0, 1.0, 1.0) - ) - - assert len(shape) == 3 - assert shape[0] == 100 - assert shape[1] == 100 - assert shape[2] == 100 - - def test_calc_layer_shape_small_bioxel(self): - """Test layer shape with small bioxel size""" - shape = calc_layer_shape( - bioxel_size=2.0, orig_shape=(100, 100, 100), orig_spacing=(1.0, 1.0, 1.0) - ) - - assert len(shape) == 3 - assert shape[0] == 50 - assert shape[1] == 50 - assert shape[2] == 50 - - def test_calc_layer_shape_minimum(self): - """Test layer shape doesn't go below 1""" - shape = calc_layer_shape( - bioxel_size=100.0, orig_shape=(100, 100, 100), orig_spacing=(1.0, 1.0, 1.0) - ) - - assert shape == (1, 1, 1) - - def test_calc_layer_size(self): - """Test layer size calculation""" - size = calc_layer_size(shape=(100, 100, 100), bioxel_size=1.0, scale=1.0) - - assert len(size) == 3 - assert size[0] == 100.0 - assert size[1] == 100.0 - assert size[2] == 100.0 - - def test_calc_layer_size_with_scale(self): - """Test layer size with scale factor""" - size = calc_layer_size(shape=(100, 100, 100), bioxel_size=1.0, scale=0.01) - - assert len(size) == 3 - assert size[0] == 1.0 - assert size[1] == 1.0 - assert size[2] == 1.0 - - -class TestLayerClass: - """Test Layer class functionality""" - - def test_layer_initialization(self): - """Test Layer initialization""" - data = np.random.rand(1, 8, 8, 8, 1).astype(np.float32) - layer = Layer(data=data, name="test_layer", kind="scalar") - - assert layer.name == "test_layer" - assert layer.kind == "scalar" - assert np.array_equal(layer.data, data) - - def test_layer_properties(self): - """Test Layer property accessors""" - data = np.random.rand(1, 8, 8, 8, 1).astype(np.float32) - layer = Layer( - data=data, name="test_layer", kind="scalar", affine=np.identity(4) - ) - - assert hasattr(layer, "bioxel_size") - assert hasattr(layer, "shape") - assert hasattr(layer, "dtype") - assert hasattr(layer, "origin") - assert hasattr(layer, "euler") - assert hasattr(layer, "frame_count") - assert hasattr(layer, "channel_count") - assert hasattr(layer, "min") - assert hasattr(layer, "max") - - def test_layer_shape_property(self): - """Test layer shape returns XYZ (not TXYZC)""" - data = np.random.rand(1, 8, 8, 8, 1).astype(np.float32) - layer = Layer(data=data, name="test", kind="scalar") - - shape = layer.shape - assert len(shape) == 3 - assert shape[0] == 8 - assert shape[1] == 8 - assert shape[2] == 8 - - def test_layer_affine_property(self): - """Test affine transformation matrix""" - data = np.random.rand(1, 8, 8, 8, 1).astype(np.float32) - affine = np.array( - [ - [1.0, 0.0, 0.0, 0.0], - [0.0, 1.0, 0.0, 0.0], - [0.0, 0.0, 1.0, 0.0], - [0.0, 0.0, 0.0, 1.0], - ] - ) - layer = Layer(data=data, name="test", kind="scalar", affine=affine) - - result_affine = layer.affine - assert result_affine.shape == (4, 4) - assert np.allclose(result_affine, affine) - - def test_layer_min_max(self): - """Test layer min/max properties""" - data = np.random.rand(1, 8, 8, 8, 1).astype(np.float32) - layer = Layer(data=data, name="test", kind="scalar") - - assert isinstance(layer.min, float) - assert isinstance(layer.max, float) - assert layer.min <= layer.max - - def test_layer_copy(self): - """Test layer deep copy""" - data = np.random.rand(1, 8, 8, 8, 1).astype(np.float32) - layer = Layer(data=data, name="test", kind="scalar") - layer_copy = layer.copy() - - assert layer_copy.name == layer.name - assert layer_copy.kind == layer.kind - assert np.array_equal(layer_copy.data, layer.data) - - assert layer_copy is not layer - layer_copy.data[0, 0, 0, 0, 0] = 999.0 - assert layer.data[0, 0, 0, 0, 0] != 999.0 - - def test_layer_invalid_data_shape(self): - """Test Layer raises error for invalid data shape""" - data = np.random.rand(8, 8, 8).astype(np.float32) - - with pytest.raises(Exception): - Layer(data=data, name="test", kind="scalar") - - def test_layer_invalid_affine_shape(self): - """Test Layer raises error for invalid affine shape""" - data = np.random.rand(1, 8, 8, 8, 1).astype(np.float32) - affine = np.identity(3) - - with pytest.raises(Exception): - Layer(data=data, name="test", kind="scalar", affine=affine) - - -class TestConstants: - """Test module constants""" - - def test_support_exts_exists(self): - """Test SUPPORT_EXTS constant""" - from bioxelnodes.bioxel import SUPPORT_EXTS - - assert isinstance(SUPPORT_EXTS, list) - assert len(SUPPORT_EXTS) > 0 - assert ".h5" in SUPPORT_EXTS - assert ".nrrd" in SUPPORT_EXTS - assert ".nii.gz" in SUPPORT_EXTS - assert ".dcm" in SUPPORT_EXTS - - def test_dicom_exts_exists(self): - """Test DICOM_EXTS constant""" - from bioxelnodes.bioxel import DICOM_EXTS - - assert isinstance(DICOM_EXTS, list) - assert "" in DICOM_EXTS - assert ".dcm" in DICOM_EXTS - assert ".DCM" in DICOM_EXTS - - def test_ome_exts_exists(self): - """Test OME_EXTS constant""" - from bioxelnodes.bioxel import OME_EXTS - - assert isinstance(OME_EXTS, list) - assert ".tiff" in OME_EXTS - assert ".ome.tiff" in OME_EXTS - - def test_mrc_exts_exists(self): - """Test MRC_EXTS constant""" - from bioxelnodes.bioxel import MRC_EXTS - - assert isinstance(MRC_EXTS, list) - assert ".mrc" in MRC_EXTS - - -class TestEdgeCases: - """Test edge cases and error handling""" - - def test_nonexistent_file(self): - """Test handling of nonexistent file""" - with pytest.raises(FileNotFoundError): - data_obj = read("/nonexistent/file.nii.gz", "") - - def test_frame_count_property_multiframe(self): - """Test frame_count property for multi-frame data""" - data_obj = read(str(NRRD_FILE)) - - assert data_obj.frame_count >= 1 - assert data_obj.frame_count == data_obj.meta["frame_count"] - - def test_channel_count_property(self): - """Test channel_count property""" - data_obj = read(str(NRRD_FILE)) - - assert data_obj.channel_count >= 1 - assert data_obj.channel_count == data_obj.meta["channel_count"] - - def test_nrrd_format_support(self): - """Test NRRD format is in supported extensions""" - from bioxelnodes.bioxel import SUPPORT_EXTS - - assert ".nrrd" in SUPPORT_EXTS - - def test_dicom_format_support(self): - """Test DICOM format is in supported extensions""" - from bioxelnodes.bioxel import SUPPORT_EXTS - - assert ".dcm" in SUPPORT_EXTS - assert "" in SUPPORT_EXTS - - -class TestRealDataFiles: - """Test with real data files""" - - def test_nrrd_file_exists(self): - """Test NRRD test file exists""" - assert NRRD_FILE.exists() - assert NRRD_FILE.is_file() - - def test_dicom_file_exists(self): - """Test DICOM test file exists""" - assert DICOM_FILE.exists() - assert DICOM_FILE.is_file() - - def test_dicom_directory_exists(self): - """Test DICOM directory exists""" - assert DICOM_DIR.exists() - assert DICOM_DIR.is_dir() - - def test_read_nrrd_data(self): - """Test reading real NRRD data""" - data_obj = read(str(NRRD_FILE)) - - assert data_obj._data is not None - assert data_obj._meta is not None - assert isinstance(data_obj.data, np.ndarray) - assert data_obj.data.size > 0 - - def test_read_dicom_data(self): - """Test reading real DICOM data""" - data_obj = read(str(DICOM_FILE), series_id="") - - assert data_obj._data is not None - assert data_obj._meta is not None - assert isinstance(data_obj.data, np.ndarray) - assert data_obj.data.size > 0 - - def test_nrrd_meta_contains_required_fields(self): - """Test NRRD meta contains all required fields""" - data_obj = read(str(NRRD_FILE)) - meta = data_obj.meta - - required_fields = [ - "name", - "description", - "spacing", - "affine", - "xyz_shape", - "frame_count", - "channel_count", - "dtype", - ] - - for field in required_fields: - assert field in meta, f"Meta missing field: {field}" - - def test_dicom_meta_contains_required_fields(self): - """Test DICOM meta contains all required fields""" - data_obj = read(str(DICOM_FILE), series_id="") - meta = data_obj.meta - - required_fields = [ - "name", - "description", - "spacing", - "affine", - "xyz_shape", - "frame_count", - "channel_count", - "dtype", - ] - - for field in required_fields: - assert field in meta, f"Meta missing field: {field}" - - def test_data_to_layers_with_real_nrrd(self): - """Test to_layers with real NRRD data""" - data_obj = read(str(NRRD_FILE)) - - layers = data_obj.to_layers(kind="scalar", bioxel_size=3.0) - - assert len(layers) > 0 - assert all(isinstance(layer, Layer) for layer in layers) - - def test_data_to_layers_with_real_dicom(self): - """Test to_layers with real DICOM data""" - data_obj = read(str(DICOM_FILE), series_id="") - - layers = data_obj.to_layers(kind="scalar", bioxel_size=3.0) - - assert len(layers) > 0 - assert all(isinstance(layer, Layer) for layer in layers) - - def test_nrrd_spacing_values(self): - """Test NRRD spacing values are reasonable""" - data_obj = read(str(NRRD_FILE)) - spacing = data_obj.spacing - - assert len(spacing) == 3 - assert all(s > 0 for s in spacing) - - def test_dicom_spacing_values(self): - """Test DICOM spacing values are reasonable""" - data_obj = read(str(DICOM_FILE), series_id="") - spacing = data_obj.spacing - - assert len(spacing) == 3 - assert all(s > 0 for s in spacing) From e06cadc67e565dea64d81192d14e3fa40f4c5f17 Mon Sep 17 00:00:00 2001 From: nan Date: Mon, 25 May 2026 14:08:38 +0800 Subject: [PATCH 04/14] feat: Update .gitignore to include scipy_ndimage directory --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 9063c92..b7b41bc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Scipy +!scipy_ndimage/*/** + # Tempary files temp sandbox.* From cd00c2bfef24f6b1d512f4fc6fde3df36df2530d Mon Sep 17 00:00:00 2001 From: nan Date: Mon, 25 May 2026 14:09:44 +0800 Subject: [PATCH 05/14] feat: Update release workflow and manifest for improved dependency management and layer handling --- .github/workflows/release.yml | 18 +- build.py | 27 +-- src/bioxelnodes/blender_manifest.toml | 20 +- src/bioxelnodes/constants.py | 2 +- src/bioxelnodes/operators/io.py | 280 +++++++++++++++++++++++--- 5 files changed, 282 insertions(+), 65 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8553984..13adde0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,7 @@ name: Release Version on: push: tags: + # Publish on any tag starting with a `v`, e.g., v0.1.0 - 'v*' jobs: @@ -42,14 +43,17 @@ jobs: uses: actions/checkout@v4 with: lfs: "true" - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: 3.11 + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Install Dependencies + run: | + uv python install 3.11 + uv python pin 3.11 + uv sync - name: Build Extension run: | # build.py need tomlkit to edit blender_manifest.toml - pip install tomlkit - python build.py ${{ matrix.platform }} + uv run build.py ${{ matrix.platform }} 3.11 + uv run build.py ${{ matrix.platform }} 3.13 cd src/bioxelnodes zip -r ../../package.zip . - name: Upload Extension @@ -60,5 +64,5 @@ jobs: with: upload_url: ${{ needs.draft_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps asset_path: ./package.zip - asset_name: BioxelNodes_${{ needs.draft_release.outputs.version }}_${{ matrix.platform }}.zip + asset_name: BioxelNodes.${{ needs.draft_release.outputs.version }}.${{ matrix.platform }}.zip asset_content_type: application/zip diff --git a/build.py b/build.py index f0d83c4..6d44946 100644 --- a/build.py +++ b/build.py @@ -12,10 +12,10 @@ class Platform: blender_tag: str -required_packages = ["SimpleITK==2.3.1", +required_packages = ["SimpleITK==2.5.5", "pyometiff==1.0.0", "mrcfile==1.5.1", - "h5py==3.11.0", + "h5py==3.16.0", "transforms3d==0.4.2", "tifffile==2024.7.24", "matplotlib==3.10.7", @@ -42,7 +42,7 @@ def run_python(args: str): subprocess.run([python] + args.split(" ")) -def build_extension(platform: Platform, python_version="3.11") -> None: +def build_extension(platform: Platform, python_version: str) -> None: wheel_dirpath = Path("./src/bioxelnodes/wheels") toml_filepath = Path("./src/bioxelnodes/blender_manifest.toml") scipy_ndimage_dirpath = Path("./scipy_ndimage", platform.blender_tag) @@ -52,26 +52,28 @@ def build_extension(platform: Platform, python_version="3.11") -> None: f"-m pip download {' '.join(required_packages)} --dest {wheel_dirpath.as_posix()} --only-binary=:all: --python-version={python_version} --platform={platform.pypi_suffix}" ) - for f in wheel_dirpath.glob('*.whl'): + for f in wheel_dirpath.glob("*.whl"): if any([package in f.name for package in packages_to_remove]): f.unlink(missing_ok=True) - elif platform.blender_tag == "macos-arm64" and \ - "lxml" in f.name and "universal2" in f.name: - f.rename(Path(f.parent, - f.name.replace("universal2", "arm64"))) + elif ( + platform.blender_tag == "macos-arm64" + and "lxml" in f.name + and "universal2" in f.name + ): + f.rename(Path(f.parent, f.name.replace("universal2", "arm64"))) for ndimage_filepath in scipy_ndimage_dirpath.iterdir(): to_filepath = Path("./src/bioxelnodes/bioxel/scipy", ndimage_filepath.name) shutil.copy(ndimage_filepath, to_filepath) - + # Load the TOML file with toml_filepath.open("r") as file: manifest = tomlkit.parse(file.read()) manifest["platforms"] = [platform.blender_tag] - manifest["wheels"] = [f"./wheels/{f.name}" - for f in wheel_dirpath.glob('*.whl')] + manifest["wheels"] = [ + f"./wheels/{f.name}" for f in wheel_dirpath.glob("*.whl")] # build.append('generated', generated) # manifest.append('build', build) @@ -84,8 +86,9 @@ def build_extension(platform: Platform, python_version="3.11") -> None: def main(): platform_name = sys.argv[1] + python_version = sys.argv[2] or "3.11" platform = platforms[platform_name] - build_extension(platform) + build_extension(platform, python_version) if __name__ == "__main__": diff --git a/src/bioxelnodes/blender_manifest.toml b/src/bioxelnodes/blender_manifest.toml index d982f0d..9fb8990 100644 --- a/src/bioxelnodes/blender_manifest.toml +++ b/src/bioxelnodes/blender_manifest.toml @@ -14,25 +14,7 @@ license = ['SPDX:GPL-3.0-or-later'] copyright = ["2024 OmooLab"] platforms = ["windows-x64"] -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", -] +wheels = ["./wheels/contourpy-1.3.3-cp311-cp311-win_amd64.whl", "./wheels/contourpy-1.3.3-cp313-cp313-win_amd64.whl", "./wheels/cycler-0.12.1-py3-none-any.whl", "./wheels/fonttools-4.63.0-cp311-cp311-win_amd64.whl", "./wheels/fonttools-4.63.0-cp313-cp313-win_amd64.whl", "./wheels/h5py-3.16.0-cp311-cp311-win_amd64.whl", "./wheels/h5py-3.16.0-cp313-cp313-win_amd64.whl", "./wheels/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", "./wheels/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", "./wheels/lxml-6.1.1-cp311-cp311-win_amd64.whl", "./wheels/lxml-6.1.1-cp313-cp313-win_amd64.whl", "./wheels/matplotlib-3.10.7-cp311-cp311-win_amd64.whl", "./wheels/matplotlib-3.10.7-cp313-cp313-win_amd64.whl", "./wheels/mrcfile-1.5.1-py2.py3-none-any.whl", "./wheels/packaging-26.2-py3-none-any.whl", "./wheels/pillow-11.2.1-cp311-cp311-win_amd64.whl", "./wheels/pillow-11.2.1-cp313-cp313-win_amd64.whl", "./wheels/pyometiff-1.0.0-py3-none-any.whl", "./wheels/pyparsing-3.3.2-py3-none-any.whl", "./wheels/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", "./wheels/simpleitk-2.5.5-cp311-abi3-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" diff --git a/src/bioxelnodes/constants.py b/src/bioxelnodes/constants.py index 322b703..1fa5afc 100644 --- a/src/bioxelnodes/constants.py +++ b/src/bioxelnodes/constants.py @@ -1,7 +1,7 @@ from pathlib import Path NODE_LIB_DIRPATH = Path(Path(__file__).parent, - "assets/O Bioxel").resolve() + "assets/O_Bioxel").resolve() LATEST_NODE_LIB_PATH = Path(NODE_LIB_DIRPATH, "BioxelNodes.blend").resolve() diff --git a/src/bioxelnodes/operators/io.py b/src/bioxelnodes/operators/io.py index a94fdd2..96e1b5f 100644 --- a/src/bioxelnodes/operators/io.py +++ b/src/bioxelnodes/operators/io.py @@ -12,20 +12,37 @@ from ..props import BIOXEL_Series from ..utils import get_layer_obj, wrapped_label -from ..bioxel import ( - DICOM_EXTS, - SUPPORT_EXTS, - read, - read_meta, - calc_layer_shape, - calc_layer_size, - get_ext, -) +from ..bioxel.layer import Layer +from ..bioxel.parse import DICOM_EXTS, SUPPORT_EXTS, get_ext, parse_volumetric_data from ..utils import get_cache_dir, progress_update, progress_bar from ..layer import get_layer_caches, save_layers_to_json +def get_layer_shape(bioxel_size: float, orig_shape: tuple, orig_spacing: tuple): + shape = ( + int(orig_shape[0] / bioxel_size * orig_spacing[0]), + int(orig_shape[1] / bioxel_size * orig_spacing[1]), + int(orig_shape[2] / bioxel_size * orig_spacing[2]), + ) + + return ( + shape[0] if shape[0] > 0 else 1, + shape[1] if shape[1] > 0 else 1, + shape[2] if shape[2] > 0 else 1, + ) + + +def get_layer_size(shape: tuple, bioxel_size: float, scale: float = 1.0): + size = ( + float(shape[0] * bioxel_size * scale), + float(shape[1] * bioxel_size * scale), + float(shape[2] * bioxel_size * scale), + ) + + return size + + """ ImportData -> ParseVolumetricData -> ImportDataDialog start import parse data execute import @@ -194,7 +211,11 @@ def progress_callback(factor, text): try: series_id = self.series_id if self.series_id != "empty" else "" - data = read_meta(self.filepath, series_id=series_id) + data, meta = parse_volumetric_data( + data_file=self.filepath, + series_id=series_id, + progress_callback=progress_callback, + ) except KeyboardInterrupt: return except Exception as e: @@ -204,8 +225,8 @@ def progress_callback(factor, text): if cancel(): return - self.meta = data.meta - self.label_count = int(np.max(data.data)) + self.meta = meta + self.label_count = int(np.max(data)) self.dtype = data.dtype # Init cancel flag @@ -313,8 +334,8 @@ def modal(self, context, event): bioxel_size = max(min(*orig_spacing), 1.0) - layer_shape = calc_layer_shape(bioxel_size, orig_shape, orig_spacing) - layer_size = calc_layer_size(layer_shape, bioxel_size, 0.01) + layer_shape = get_layer_shape(bioxel_size, orig_shape, orig_spacing) + layer_size = get_layer_size(layer_shape, bioxel_size, 0.01) min_log10 = math.floor(math.log10(min(*layer_size))) max_log10 = math.floor(math.log10(max(*layer_size))) @@ -526,23 +547,33 @@ class ImportDataDialog(bpy.types.Operator): def execute(self, context): def import_volumetric_data_func(self, context, cancel): + progress_update(context, 0.0, "Parsing Volumetirc Data...") + def progress_callback(factor, text): if cancel(): raise KeyboardInterrupt("Cancelled by user") progress_update(context, factor * 0.2, text) + def progress_callback_factory(layer_name, progress, progress_step): + def progress_callback(frame, total): + if cancel(): + raise KeyboardInterrupt("Cancelled by user") + sub_progress_step = progress_step / total + sub_progress = progress + frame * sub_progress_step + progress_update( + context, + sub_progress, + f"Processing {layer_name} Frame {frame+1}...", + ) + print(f"Processing {layer_name} Frame {frame+1}...") + + return progress_callback + try: - data = read(self.filepath, self.series_id, progress_callback) - - self.layers = data.to_layers( - kind=self.read_as.lower(), - layer_name=self.layer_name, - bioxel_size=self.bioxel_size, - smooth=self.smooth, - remap=self.remap, - split_channel=self.split_channel, - frame_source=self.frame_source, - progress_callback=lambda f, t: progress_callback(0.2 + f * 0.7, t), + data, meta = parse_volumetric_data( + data_file=self.filepath, + series_id=self.series_id, + progress_callback=progress_callback, ) except KeyboardInterrupt: @@ -554,6 +585,203 @@ def progress_callback(factor, text): if cancel(): return + shape = get_layer_shape( + self.bioxel_size, self.orig_shape, self.orig_spacing + ) + + mat_scale = transforms3d.zooms.zfdir2aff(self.bioxel_size) + affine = np.dot(meta["affine"], mat_scale) + kind = self.read_as.lower() + + if cancel(): + return + + # change shape as sequence or not + if self.frame_source == "-1": + data = data[0:1, :, :, :, :] + elif self.frame_source == "0": + # frame as frame + pass + elif self.frame_source == "1": + # X as frame + data = data.transpose(1, 0, 2, 3, 4) + shape = (1, shape[1], shape[2]) + elif self.frame_source == "2": + # Y as frame + data = data.transpose(2, 1, 0, 3, 4) + shape = (shape[0], 1, shape[2]) + elif self.frame_source == "3": + # Z as frame + data = data.transpose(3, 1, 2, 0, 4) + shape = (shape[0], shape[1], 1) + else: + # channel as frame + data = data.transpose(4, 1, 2, 3, 0) + + layers = [] + if kind == "label": + name = self.layer_name or "Label" + data = data.astype(int) + label_count = int(np.max(data)) + progress_step = 0.7 / label_count + + for i in range(label_count): + if cancel(): + return + + name_i = f"{name}_{i+1}" + progress = 0.2 + i * progress_step + progress_update(context, progress, f"Processing {name_i}...") + + progress_callback = progress_callback_factory( + name_i, progress, progress_step + ) + label_data = data == np.full_like(data, i + 1) + # label_data = label_data.astype(np.float32) + try: + layer = Layer(data=label_data, name=name_i, kind=kind) + + layer.resize( + shape=shape, + smooth=self.smooth, + progress_callback=progress_callback, + ) + + layer.affine = affine + + layers.append(layer) + except KeyboardInterrupt: + return + except Exception as e: + self.has_error = e + return + + if kind == "color": + if np.issubdtype(np.uint8, data.dtype): + data = np.multiply(data, 1.0 / 256, dtype=np.float32) + elif data.dtype.kind in ["u", "i"]: + # Convert the normalized array to float dtype + data = data.astype(np.float32) + + min_val = data.min() + max_val = data.max() + # Avoid division by zero if all values are the same + if max_val != min_val: + # Normalize the array to the range (0,1) + data = (data - min_val) / (max_val - min_val) + else: + # If all values are the same, the normalized array will be all zeros + data = np.zeros_like(data, dtype=np.float32) + + else: + data = data.astype(np.float32) + + # Gamma Correct + # data = data ** 2.2 + + name = self.layer_name or "Color" + if data.shape[4] == 1: + data = np.repeat(data, repeats=3, axis=4) + elif data.shape[4] == 2: + d_shape = list(data.shape) + d_shape = d_shape[:4] + [1] + zore = np.zeros(tuple(d_shape), dtype=np.float32) + data = np.concatenate((data, zore), axis=-1) + elif data.shape[4] > 3: + data = data[:, :, :, :, :3] + + if cancel(): + return + + progress_update(context, 0.2, f"Processing {name}...") + progress_callback = progress_callback_factory(name, 0.2, 0.7) + + try: + layer = Layer(data=data, name=name, kind=kind) + + layer.resize(shape=shape, progress_callback=progress_callback) + + layer.affine = affine + + layers.append(layer) + except KeyboardInterrupt: + return + except Exception as e: + self.has_error = e + return + + elif kind == "scalar": + name = self.layer_name or "Scalar" + + if self.remap: + # Convert the normalized array to float dtype + data = data.astype(np.float32) + + min_val = data.min() + max_val = data.max() + # Avoid division by zero if all values are the same + if max_val != min_val: + # Normalize the array to the range (0,1) + data = (data - min_val) / (max_val - min_val) + else: + # If all values are the same, the normalized array will be all zeros + data = np.zeros_like(data, dtype=np.float32) + + if self.split_channel: + progress_step = 0.7 / self.channel_count + + for i in range(self.channel_count): + if cancel(): + return + + name_i = f"{name}_{i+1}" + progress = 0.2 + i * progress_step + progress_update(context, progress, f"Processing {name_i}...") + progress_callback = progress_callback_factory( + name_i, progress, progress_step + ) + try: + layer = Layer( + data=data[:, :, :, :, i : i + 1], name=name_i, kind=kind + ) + + layer.resize( + shape=shape, progress_callback=progress_callback + ) + + layer.affine = affine + + layers.append(layer) + except KeyboardInterrupt: + return + except Exception as e: + self.has_error = e + return + else: + if cancel(): + return + + progress_update(context, 0.2, f"Processing {name}...") + progress_callback = progress_callback_factory(name, 0.2, 0.7) + + try: + layer = Layer(data=data, name=name, kind=kind) + + layer.resize(shape=shape, progress_callback=progress_callback) + + layer.affine = affine + + layers.append(layer) + except KeyboardInterrupt: + return + except Exception as e: + self.has_error = e + return + + if cancel(): + return + + self.layers = layers progress_update(context, 0.9, "Creating Layers...") self.is_cancelled = False @@ -623,7 +851,7 @@ def invoke(self, context, event): return {"RUNNING_MODAL"} def draw(self, context): - layer_shape = calc_layer_shape( + layer_shape = get_layer_shape( self.bioxel_size, self.orig_shape, self.orig_spacing ) From 9ba0c6abeb2ba7f808afd25d2eebb0dd1597a126 Mon Sep 17 00:00:00 2001 From: nan Date: Mon, 25 May 2026 14:16:56 +0800 Subject: [PATCH 06/14] feat: Update .gitignore and add binary files for scipy_ndimage across multiple platforms --- .gitignore | 9 +++++---- .../_nd_image.cpython-313-x86_64-linux-gnu.so | 3 +++ .../_nd_image.cpython-313-darwin.so | 3 +++ .../macos-x64/_nd_image.cpython-313-darwin.so | 3 +++ .../windows-x64/_nd_image.cp313-win_amd64.pyd | Bin 0 -> 177664 bytes 5 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 scipy_ndimage/linux-x64/_nd_image.cpython-313-x86_64-linux-gnu.so create mode 100644 scipy_ndimage/macos-arm64/_nd_image.cpython-313-darwin.so create mode 100644 scipy_ndimage/macos-x64/_nd_image.cpython-313-darwin.so create mode 100644 scipy_ndimage/windows-x64/_nd_image.cp313-win_amd64.pyd diff --git a/.gitignore b/.gitignore index b7b41bc..4075260 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ -# Scipy -!scipy_ndimage/*/** - # Tempary files temp sandbox.* @@ -392,4 +389,8 @@ dist # Vite files vite.config.js.timestamp-* vite.config.ts.timestamp-* -.vite/ \ No newline at end of file +.vite/ + + +# Scipy +!scipy_ndimage/*/** \ No newline at end of file diff --git a/scipy_ndimage/linux-x64/_nd_image.cpython-313-x86_64-linux-gnu.so b/scipy_ndimage/linux-x64/_nd_image.cpython-313-x86_64-linux-gnu.so new file mode 100644 index 0000000..e510baa --- /dev/null +++ b/scipy_ndimage/linux-x64/_nd_image.cpython-313-x86_64-linux-gnu.so @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:132fd2fbc28d98b7f21e33e75516def2d8ea86d2ad2547cfe25ea419e33098ea +size 175504 diff --git a/scipy_ndimage/macos-arm64/_nd_image.cpython-313-darwin.so b/scipy_ndimage/macos-arm64/_nd_image.cpython-313-darwin.so new file mode 100644 index 0000000..ac493f0 --- /dev/null +++ b/scipy_ndimage/macos-arm64/_nd_image.cpython-313-darwin.so @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd95c5ce36744532629f59bd8610ae67631b3f37916d71d0e3f869c0ab54bd7e +size 156160 diff --git a/scipy_ndimage/macos-x64/_nd_image.cpython-313-darwin.so b/scipy_ndimage/macos-x64/_nd_image.cpython-313-darwin.so new file mode 100644 index 0000000..1d303e5 --- /dev/null +++ b/scipy_ndimage/macos-x64/_nd_image.cpython-313-darwin.so @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:03487f506992a87f62e707cc18565bffa66baf6d01a36106b5bd1bc4ea26b431 +size 154824 diff --git a/scipy_ndimage/windows-x64/_nd_image.cp313-win_amd64.pyd b/scipy_ndimage/windows-x64/_nd_image.cp313-win_amd64.pyd new file mode 100644 index 0000000000000000000000000000000000000000..6d433a110872d3031f8723118764d8e7fd490d2b GIT binary patch literal 177664 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 QYLCu~I- Date: Mon, 25 May 2026 14:18:01 +0800 Subject: [PATCH 07/14] feat: Add OPSX commands for archiving, exploring, and proposing changes --- .claude/commands/opsx/apply.md | 152 +++++++++ .claude/commands/opsx/archive.md | 157 ++++++++++ .claude/commands/opsx/explore.md | 173 +++++++++++ .claude/commands/opsx/propose.md | 106 +++++++ .claude/skills/openspec-apply-change/SKILL.md | 156 ++++++++++ .../skills/openspec-archive-change/SKILL.md | 114 +++++++ .claude/skills/openspec-explore/SKILL.md | 288 ++++++++++++++++++ .claude/skills/openspec-propose/SKILL.md | 110 +++++++ openspec/config.yaml | 20 ++ 9 files changed, 1276 insertions(+) create mode 100644 .claude/commands/opsx/apply.md create mode 100644 .claude/commands/opsx/archive.md create mode 100644 .claude/commands/opsx/explore.md create mode 100644 .claude/commands/opsx/propose.md create mode 100644 .claude/skills/openspec-apply-change/SKILL.md create mode 100644 .claude/skills/openspec-archive-change/SKILL.md create mode 100644 .claude/skills/openspec-explore/SKILL.md create mode 100644 .claude/skills/openspec-propose/SKILL.md create mode 100644 openspec/config.yaml diff --git a/.claude/commands/opsx/apply.md b/.claude/commands/opsx/apply.md new file mode 100644 index 0000000..ae14f0f --- /dev/null +++ b/.claude/commands/opsx/apply.md @@ -0,0 +1,152 @@ +--- +name: "OPSX: Apply" +description: Implement tasks from an OpenSpec change (Experimental) +category: Workflow +tags: [workflow, artifacts, experimental] +--- + +Implement tasks from an OpenSpec change. + +**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select + + Always announce: "Using change: " and how to override (e.g., `/opsx:apply `). + +2. **Check status to understand the schema** + ```bash + openspec status --change "" --json + ``` + Parse the JSON to understand: + - `schemaName`: The workflow being used (e.g., "spec-driven") + - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) + +3. **Get apply instructions** + + ```bash + openspec instructions apply --change "" --json + ``` + + This returns: + - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema) + - Progress (total, complete, remaining) + - Task list with status + - Dynamic instruction based on current state + + **Handle states:** + - If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue` + - If `state: "all_done"`: congratulate, suggest archive + - Otherwise: proceed to implementation + +4. **Read context files** + + Read every file path listed under `contextFiles` from the apply instructions output. + The files depend on the schema being used: + - **spec-driven**: proposal, specs, design, tasks + - Other schemas: follow the contextFiles from CLI output + +5. **Show current progress** + + Display: + - Schema being used + - Progress: "N/M tasks complete" + - Remaining tasks overview + - Dynamic instruction from CLI + +6. **Implement tasks (loop until done or blocked)** + + For each pending task: + - Show which task is being worked on + - Make the code changes required + - Keep changes minimal and focused + - Mark task complete in the tasks file: `- [ ]` → `- [x]` + - Continue to next task + + **Pause if:** + - Task is unclear → ask for clarification + - Implementation reveals a design issue → suggest updating artifacts + - Error or blocker encountered → report and wait for guidance + - User interrupts + +7. **On completion or pause, show status** + + Display: + - Tasks completed this session + - Overall progress: "N/M tasks complete" + - If all done: suggest archive + - If paused: explain why and wait for guidance + +**Output During Implementation** + +``` +## Implementing: (schema: ) + +Working on task 3/7: +[...implementation happening...] +✓ Task complete + +Working on task 4/7: +[...implementation happening...] +✓ Task complete +``` + +**Output On Completion** + +``` +## Implementation Complete + +**Change:** +**Schema:** +**Progress:** 7/7 tasks complete ✓ + +### Completed This Session +- [x] Task 1 +- [x] Task 2 +... + +All tasks complete! You can archive this change with `/opsx:archive`. +``` + +**Output On Pause (Issue Encountered)** + +``` +## Implementation Paused + +**Change:** +**Schema:** +**Progress:** 4/7 tasks complete + +### Issue Encountered + + +**Options:** +1.