From 935191c1473f15466f0f1659bec6082794d2d620 Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Sat, 26 Sep 2026 16:21:17 +0300 Subject: [PATCH] up --- blender_manifest.toml | 1 + build.sh | 4 + core/cudaslicer_loader.py | 21 ++ core/nodeslicer/nodes/path.py | 212 ++++++++++++++ core/nodeslicer/tree.py | 1 + doc/MODULES.md | 3 + doc/NODES.md | 12 + infra/blender/blender_slice_nodes_generic.py | 14 +- tests/test_cudaslicer.py | 288 +++++++++++++++++++ 9 files changed, 554 insertions(+), 2 deletions(-) create mode 100644 core/cudaslicer_loader.py create mode 100644 tests/test_cudaslicer.py diff --git a/blender_manifest.toml b/blender_manifest.toml index 861811b..49c0141 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -59,6 +59,7 @@ wheels = [ "./wheels/python_libnest2d-0.1.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", "./wheels/python_libnest2d-0.1.3-cp313-cp313-win_amd64.whl", + "./wheels/cudaslicer-1.0.0-cp313-cp313-linux_x86_64.whl", "./wheels/shapely_polyskel-0.1.2-py3-none-any.whl", "./wheels/euclid3-0.1-py3-none-any.whl", "./wheels/pyransac3d-0.6.0-py3-none-any.whl", diff --git a/build.sh b/build.sh index c36da19..9761f68 100755 --- a/build.sh +++ b/build.sh @@ -12,6 +12,10 @@ mapfile -t FILES < <(git ls-files -co --exclude-standard \ | grep -v -xE '(\.gitignore|build\.sh)') tar -cf - "${FILES[@]}" | tar -xf - -C "$TMPDIR" +if [ -d wheels ]; then + mkdir -p "$TMPDIR/wheels" + cp -r wheels/* "$TMPDIR/wheels/" 2>/dev/null || true +fi cd "$TMPDIR" /home/nicolas/Applications/blender-5.1.0-linux-x64/blender \ diff --git a/core/cudaslicer_loader.py b/core/cudaslicer_loader.py new file mode 100644 index 0000000..0ec0311 --- /dev/null +++ b/core/cudaslicer_loader.py @@ -0,0 +1,21 @@ +from __future__ import annotations +import sys +from pathlib import Path +from typing import Any + +def import_cudaslicer() -> Any | None: + # Check local development path first to prioritize live compiled binaries during development + dev_paths = [ + Path("/home/nicolas/Workspace/Development/cudaslicer/python"), + Path(__file__).resolve().parent.parent / "deps" / "cudaslicer" / "python", + ] + for p in dev_paths: + if p.exists() and str(p) not in sys.path: + sys.path.insert(0, str(p)) + + try: + import cudaslicer + return cudaslicer + except ImportError: + return None + diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index 2bc8b80..60180a7 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -4,6 +4,7 @@ import numpy as np from ..bpy_ns_types import NfEnumProperty, NfBoolProperty, notify_tree_changed from ....registry import register_class from .base import SliceNode, SlicePaths, SlicingObject, SlicingCollection, slice_at_range, SliceNodeJoiner, SlicingGroup +from ...slice import align_path_seam_sharpest @register_class class SLICENODE_GetPaths(SliceNode): @@ -168,6 +169,217 @@ class SLICENODE_SliceShells(SliceNode): return slices +@register_class +class SLICENODE_CudaSliceMesh(SliceNode): + bl_label = "CUDA Slice Mesh" + + backend: str = NfEnumProperty( + name="Backend", + description="Execution backend: AUTO uses CUDA GPU if available, CPU uses multi-threaded CPU fallback", + items=[ + ('AUTO', "Auto", "Automatically use CUDA GPU if available, fallback to multi-threaded CPU"), + ('CUDA', "CUDA", "Force CUDA GPU execution (requires NVIDIA GPU)"), + ('CPU', "CPU", "Multi-threaded CPU execution"), + ], + default='AUTO', + update=notify_tree_changed + ) + + def init(self, context): + self.add_input("SLICESOCK_Mesh", "Mesh", description="3D mesh geometry to slice") + self.add_input("NodeSocketFloat", "Layer Height", description="Layer slicing height in millimeters (Z distance between consecutive layers)").value = 0.2 + self.add_input("NodeSocketFloat", "First Layer Height", description="Initial first layer height in millimeters (defaults to Layer Height if 0 or unlinked)").value = 0.2 + self.add_input("NodeSocketInt", "Perimeters Count", description="Number of concentric outer perimeter wall passes to generate").value = 2 + self.add_input("NodeSocketFloat", "Layer Width", description="Extrusion line width in millimeters").value = 0.45 + self.add_input("NodeSocketFloat", "Pitch", description="GPU distance transform grid pixel resolution in millimeters (lower = higher accuracy)").value = 0.10 + self.add_input("NodeSocketFloat", "Pad", description="Bounding box margin padding in millimeters around the sliced model").value = 1.5 + self.add_input("NodeSocketRotation", "Rotation", description="Optional 3D Euler rotation angles to orient the mesh before slicing") + self.add_output("SLICESOCK_Paths", "Perimeters", description="Generated perimeter wall toolpaths for each layer") + self.add_output("SLICESOCK_Paths", "Infill Contours", description="Boundary contour loops defining the infill region") + + def draw_buttons(self, context, layout): + assert layout + layout.prop(self, "backend", text="") + + def evaluate_output(self, socket_name: str, **kwargs) -> SlicePaths: + from ...cudaslicer_loader import import_cudaslicer + cudaslicer = import_cudaslicer() + if not cudaslicer or not cudaslicer.is_available(): + print("Warning: cudaslicer backend is not available.") + return SlicePaths() + + cache = kwargs.get("cache") + node_ptr = self.as_pointer() if hasattr(self, "as_pointer") else id(self) + cache_key = (node_ptr, "cudaslicer_layers_eval") + + if cache is not None and cache_key in cache: + perim_sp, infill_sp = cache[cache_key] + else: + coll: SlicingCollection | SlicingGroup | None = self.resolve("Mesh") + if not coll: + return SlicePaths() + + layer_height: float = float(self.resolve("Layer Height") or 0.2) + fl_height: float | None = None + if hasattr(self, "inputs") and "First Layer Height" in self.inputs: + try: + fl_height = self.resolve("First Layer Height") + except Exception: + fl_height = None + else: + try: + fl_height = self.resolve("First Layer Height") + except Exception: + fl_height = None + if fl_height is not None and fl_height <= 0: + fl_height = None + + perimeters_count: int = int(self.resolve("Perimeters Count") or 2) + layer_width: float = float(self.resolve("Layer Width") or 0.45) + pitch_mm: float = float(self.resolve("Pitch") or 0.10) + pad_mm: float = float(self.resolve("Pad") or 1.5) + rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0) + + backend_choice = getattr(self, "backend", "AUTO").upper() + + if isinstance(coll, SlicingGroup): + objects = [] + for c in coll.collections.values(): + objects.extend(c.objects) + elif isinstance(coll, SlicingCollection): + objects = coll.objects + elif isinstance(coll, SlicingObject): + objects = [coll] + elif isinstance(coll, (list, tuple)): + objects = list(coll) + else: + objects = [] + + merged_object = SlicingObject.merge(objects) + if merged_object.mesh is None or merged_object.mesh.size == 0: + return SlicePaths() + + # Copy mesh for rotation + mesh_data = merged_object.mesh.copy() + mat_inv = None + if rotation and any(rotation): + try: + from mathutils import Euler + mat = np.array(Euler(rotation).to_matrix().inverted()) + mat_inv = np.array(Euler(rotation).to_matrix()) + except ImportError: + from ...geometry import euler_rotation_matrix + mat_inv = euler_rotation_matrix(float(rotation[0]), float(rotation[1]), float(rotation[2])) + mat = mat_inv.T + mesh_data[:, :3, :] = mesh_data[:, :3, :] @ mat.T + mesh_data[:, 3:4, :] = mesh_data[:, 3:4, :] @ mat.T + + tris_3d = mesh_data[:, :3, :].astype(np.float32) + + perim_paths: list[np.ndarray] = [] + perim_groups: list[int] = [] + infill_paths: list[np.ndarray] = [] + infill_groups: list[int] = [] + first_layer_z = None + + layers = cudaslicer.slice_mesh_and_offset( + mesh_or_triangles=tris_3d, + layer_height=float(layer_height), + first_layer_height=float(fl_height) if fl_height is not None else float(layer_height), + perimeters_count=int(perimeters_count), + layer_width=float(layer_width), + pitch_mm=float(pitch_mm), + pad_mm=float(pad_mm), + return_infill_mask=True, + backend="cpu" if backend_choice == "CPU" else backend_choice.lower(), + ) + if len(layers) > 0: + first_layer_z = float(layers[0].z) + + for l_idx, layer in enumerate(layers): + z = float(layer.z) + for perim in getattr(layer, "perimeters", []): + for cnt in perim.get("contours", []): + if len(cnt) < 2: + continue + cnt_3d = np.column_stack([cnt, np.full(len(cnt), z)]) + if not np.allclose(cnt_3d[0], cnt_3d[-1]): + cnt_3d = np.vstack([cnt_3d, cnt_3d[0]]) + cnt_3d = align_path_seam_sharpest(cnt_3d) + perim_paths.append(cnt_3d) + perim_groups.append(l_idx) + + for cnt in getattr(layer, "infill_contours", []): + if len(cnt) < 2: + continue + cnt_3d = np.column_stack([cnt, np.full(len(cnt), z)]) + if not np.allclose(cnt_3d[0], cnt_3d[-1]): + cnt_3d = np.vstack([cnt_3d, cnt_3d[0]]) + cnt_3d = align_path_seam_sharpest(cnt_3d) + infill_paths.append(cnt_3d) + infill_groups.append(l_idx) + + names = [] + if objects: + for o in objects: + n = getattr(o, "name", "") + if n: + if n.endswith("_merged"): + n = n[:-7] + if n and n not in names: + names.append(n) + if not names and hasattr(coll, "name") and getattr(coll, "name"): + names.append(str(getattr(coll, "name"))) + + # Build Perimeters SlicePaths + perim_sp = SlicePaths(perim_paths, groups=perim_groups) + perim_sp.layer_height = layer_height + if fl_height is not None: + perim_sp.first_layer_height = fl_height + perim_sp.transforms = list(getattr(coll, "transforms", [])) or list(getattr(merged_object, "transforms", [])) + + if names: + perim_sp.name = "-".join(names) + perim_sp.objects_info = {"objects": [{"name": n, "polygon": []} for n in names]} + + if first_layer_z is not None and fl_height is not None and layer_height > 0: + mult = fl_height / layer_height + for i, p in enumerate(perim_sp): + if len(p) > 0 and abs(p[0, 2] - first_layer_z) < 1e-4: + perim_sp.extrusion_rate[i] = perim_sp.extrusion_rate[i] * mult + + if rotation and any(rotation) and mat_inv is not None: + perim_sp = perim_sp.transform(mat_inv) + + # Build Infill Contours SlicePaths + infill_sp = SlicePaths(infill_paths, groups=infill_groups) + infill_sp.layer_height = layer_height + if fl_height is not None: + infill_sp.first_layer_height = fl_height + infill_sp.transforms = list(getattr(coll, "transforms", [])) or list(getattr(merged_object, "transforms", [])) + + if names: + infill_sp.name = "-".join(names) + "_infill" + infill_sp.objects_info = {"objects": [{"name": n + "_infill", "polygon": []} for n in names]} + + if first_layer_z is not None and fl_height is not None and layer_height > 0: + mult = fl_height / layer_height + for i, p in enumerate(infill_sp): + if len(p) > 0 and abs(p[0, 2] - first_layer_z) < 1e-4: + infill_sp.extrusion_rate[i] = infill_sp.extrusion_rate[i] * mult + + if rotation and any(rotation) and mat_inv is not None: + infill_sp = infill_sp.transform(mat_inv) + + if cache is not None: + cache[cache_key] = (perim_sp, infill_sp) + + if socket_name in ("Perimeters", "Paths"): + return perim_sp + elif socket_name in ("Infill Contours", "Infill"): + return infill_sp + return perim_sp + @register_class class SLICENODE_BufferShells(SliceNode): bl_label = "Buffer Shells" diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index 20c59fa..436f266 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -505,6 +505,7 @@ class SLICENODE_MT_Geometry(Menu): layout.operator("node.add_node", text="Get Paths").type = "SLICENODE_GetPaths" layout.separator() layout.operator("node.add_node", text="Slice Shells").type = "SLICENODE_SliceShells" + layout.operator("node.add_node", text="CUDA Slice Mesh").type = "SLICENODE_CudaSliceMesh" layout.operator("node.add_node", text="Buffer Shells").type = "SLICENODE_BufferShells" layout.operator("node.add_node", text="Buffer Shells Multiple").type = "SLICENODE_BufferShellsMultiple" layout.operator("node.add_node", text="Buffer Curve").type = "SLICENODE_BufferCurve" diff --git a/doc/MODULES.md b/doc/MODULES.md index 08acf13..b818b52 100644 --- a/doc/MODULES.md +++ b/doc/MODULES.md @@ -40,6 +40,9 @@ This file provides a mapping of modules to their primary responsibilities and ke - `geometry.py`: - Geometry utility functions. +- `cudaslicer_loader.py`: + - `import_cudaslicer()`: Helper function to safely load the `cudaslicer` library. + ## `/core/pdf_exporter` (PDF Export & Technical Marking Engine) - `sa2_measures.py`: diff --git a/doc/NODES.md b/doc/NODES.md index 7dc72b2..3c4820a 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -82,6 +82,18 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Extra Attributes**: Dictionary mapping mesh attribute names to custom G-code prefixes (e.g., `opening_size` -> `S`). - **Slice Shells**: Slices a `SlicingObject` into 2D `SlicePaths` at a given layer height. Automatically aligns path seams to the sharpest concave angle (inner corner) of each layer. Supports rotation for slicing at an angle. - **Ignore Holes**: Boolean option to omit interior hole perimeters and generate only the outer shell boundaries. +- **CUDA Slice Mesh**: High-performance GPU-accelerated 3D mesh slicing and multi-pass perimeter offsetting via `cudaslicer`. Automatically aligns path seams to sharpest concave angles. + - **Mesh**: Input mesh geometry (`SlicingGroup`, `SlicingCollection`, or `SlicingObject`). + - **Layer Height**: Z layer slice height in mm (default: `0.2`). + - **First Layer Height**: First layer slice height in mm (default: `0.2`). Adjusts first layer extrusion rate accordingly. + - **Perimeters Count**: Number of concentric outer perimeter passes (default: `2`). + - **Layer Width**: Extrusion line width in mm (default: `0.45`). + - **Pitch**: Raster resolution pitch in mm (default: `0.10`). + - **Pad**: Bounding box margin padding in mm (default: `1.5`). + - **Rotation**: Optional Euler rotation for slicing at arbitrary angles. + - **Backend**: Execution backend switch: **Auto** (CUDA GPU with multi-threaded CPU fallback), **CUDA** (force CUDA GPU), or **CPU** (multi-threaded CPU execution). + - **Perimeters**: Output `SlicePaths` for perimeter wall toolpaths. + - **Infill Contours**: Output `SlicePaths` for infill boundary loops. - **Buffer Shells**: Offsets/buffers 2D `SlicePaths`. Supports rotated offsetting and multiple miter types. - **Buffer Shells Multiple**: Offsets 2D `SlicePaths` with multiple buffer passes. - **Buffer Curve**: Buffers open/closed curves using Shapely, allowing open path offsetting. diff --git a/infra/blender/blender_slice_nodes_generic.py b/infra/blender/blender_slice_nodes_generic.py index f295d2d..24de382 100644 --- a/infra/blender/blender_slice_nodes_generic.py +++ b/infra/blender/blender_slice_nodes_generic.py @@ -128,22 +128,32 @@ class SliceNode(Node, AutoBlIdName): return None return resolve_input(inputs.get(fieldname), cache=cache, visited=visited) - def add_output(self, bl_idname: str, name: str = "", identifier: str = "", hide: bool = False, display_shape: str = ""): + def add_output(self, bl_idname: str, name: str = "", identifier: str = "", hide: bool = False, display_shape: str = "", description: str = ""): bl_idname = NATIVE_SOCKET_MAP.get(bl_idname, bl_idname) eval_name = name or NATIVE_SOCKET_LABELS.get(bl_idname) or get_label_by_name(bl_idname) or bl_idname sock = self.outputs.new(bl_idname, eval_name, identifier=identifier or eval_name) sock.hide = hide if display_shape: sock.display_shape = display_shape + if description and hasattr(sock, "description"): + try: + sock.description = description + except Exception: + pass return sock - def add_input(self, bl_idname: str, name: str = "", identifier: str = "", hide: bool = False, display_shape: str = ""): + def add_input(self, bl_idname: str, name: str = "", identifier: str = "", hide: bool = False, display_shape: str = "", description: str = ""): bl_idname = NATIVE_SOCKET_MAP.get(bl_idname, bl_idname) eval_name = name or NATIVE_SOCKET_LABELS.get(bl_idname) or get_label_by_name(bl_idname) or bl_idname sock = self.inputs.new(bl_idname, eval_name, identifier=identifier or eval_name) sock.hide = hide if display_shape: sock.display_shape = display_shape + if description and hasattr(sock, "description"): + try: + sock.description = description + except Exception: + pass return sock def add_multi_socket_group(self, in_type: str, out_type: str = "", name: str = "", in_name: str = "", out_name: str = "", in_display_shape: str = "", out_display_shape: str = ""): diff --git a/tests/test_cudaslicer.py b/tests/test_cudaslicer.py new file mode 100644 index 0000000..11321a5 --- /dev/null +++ b/tests/test_cudaslicer.py @@ -0,0 +1,288 @@ +import sys +import unittest +from unittest.mock import MagicMock, patch +import numpy as np +from pathlib import Path + +# Add workspace directory to sys.path to resolve root package name (NodeSlicer) +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT.parent) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT.parent)) +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +# Define dummy classes to avoid metaclass conflicts +class DummyNodeSocket: pass +class DummyNode: pass +class DummyObject: + def __init__(self, name="TestObj"): + self.name = name +class DummyMesh: pass +class DummyNodeSocketShader: pass +class DummyNodeCustomGroup: pass +class DummyNodeTree: pass +class DummyOperator: pass +class DummyMenu: pass +class DummyPanel: pass +class DummyPropertyGroup: pass + +mock_types = MagicMock() +mock_types.NodeSocket = DummyNodeSocket +mock_types.Node = DummyNode +mock_types.Object = DummyObject +mock_types.Mesh = DummyMesh +mock_types.NodeSocketShader = DummyNodeSocketShader +mock_types.NodeCustomGroup = DummyNodeCustomGroup +mock_types.NodeTree = DummyNodeTree +mock_types.Operator = DummyOperator +mock_types.Menu = DummyMenu +mock_types.Panel = DummyPanel +mock_types.PropertyGroup = DummyPropertyGroup + +mock_bpy = MagicMock() +mock_bpy.context.scene = MagicMock() +mock_bpy.context.scene.unit_settings.scale_length = 0.001 +mock_bpy.types = mock_types + +mock_previews = MagicMock() +mock_previews.ImagePreviewCollection = MagicMock + +sys.modules['bpy'] = mock_bpy +sys.modules['bpy.types'] = mock_types +sys.modules['bpy.props'] = MagicMock() +sys.modules['bpy.utils'] = MagicMock() +sys.modules['bpy.utils.previews'] = mock_previews +sys.modules['bpy.app'] = MagicMock() +sys.modules['bpy.app.handlers'] = MagicMock() + +# Import cudaslicer loader and NodeSlicer modules +from NodeSlicer.core.cudaslicer_loader import import_cudaslicer +from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject +from NodeSlicer.core.slice import SlicePaths +from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_CudaSliceMesh + +class TestCudaSlicer(unittest.TestCase): + def setUp(self): + self.cudaslicer = import_cudaslicer() + + def test_cudaslicer_loader(self): + self.assertIsNotNone(self.cudaslicer, "cudaslicer loader should successfully import cudaslicer") + self.assertTrue(self.cudaslicer.is_available(), "cudaslicer backend should be available") + + def test_cudaslicer_mesh_slicing(self): + # Create a simple 10x10x10 cube + tris = np.array([ + [[0,0,0], [10,0,0], [10,10,0]], + [[0,0,0], [10,10,0], [0,10,0]], + [[0,0,10], [10,10,10], [10,0,10]], + [[0,0,10], [0,10,10], [10,10,10]], + [[0,0,0], [0,10,0], [0,10,10]], + [[0,0,0], [0,10,10], [0,0,10]], + [[10,0,0], [10,10,10], [10,10,0]], + [[10,0,0], [10,0,10], [10,10,10]], + [[0,0,0], [10,0,10], [10,0,0]], + [[0,0,0], [0,0,10], [10,0,10]], + [[0,10,0], [10,10,0], [10,10,10]], + [[0,10,0], [10,10,10], [0,10,10]], + ], dtype=np.float32) + + layers = self.cudaslicer.slice_mesh_and_offset( + mesh_or_triangles=tris, + layer_height=1.0, + first_layer_height=0.5, + perimeters_count=2, + layer_width=0.45, + pitch_mm=0.1, + pad_mm=1.5, + backend="auto" + ) + + self.assertGreater(len(layers), 0, "Should generate sliced layers") + self.assertAlmostEqual(layers[0].z, 0.5, places=4) + self.assertGreater(len(layers[0].perimeters), 0) + + def test_cuda_slice_mesh_node_evaluation(self): + # Create dummy SlicingObject + so = SlicingObject() + so.name = "TestCube" + + # Build (12, 4, 3) mesh representation + tris = np.array([ + [[0,0,0], [10,0,0], [10,10,0]], + [[0,0,0], [10,10,0], [0,10,0]], + [[0,0,10], [10,10,10], [10,0,10]], + [[0,0,10], [0,10,10], [10,10,10]], + [[0,0,0], [0,10,0], [0,10,10]], + [[0,0,0], [0,10,10], [0,0,10]], + [[10,0,0], [10,10,10], [10,10,0]], + [[10,0,0], [10,0,10], [10,10,10]], + [[0,0,0], [10,0,10], [10,0,0]], + [[0,0,0], [0,0,10], [10,0,10]], + [[0,10,0], [10,10,0], [10,10,10]], + [[0,10,0], [10,10,10], [0,10,10]], + ], dtype=np.float64) + + normals = np.zeros((12, 1, 3), dtype=np.float64) + so.mesh = np.concatenate([tris, normals], axis=1) + + node = SLICENODE_CudaSliceMesh() + node.name = "CUDA_Slice_Mesh" + + # Mock resolve method + resolve_map = { + "Mesh": so, + "Layer Height": 1.0, + "First Layer Height": 0.5, + "Perimeters Count": 2, + "Layer Width": 0.45, + "Pitch": 0.10, + "Pad": 1.5, + "Rotation": (0.0, 0.0, 0.0), + } + node.resolve = lambda key: resolve_map.get(key) + node.backend = "AUTO" + + cache = {} + perimeters_paths = node.evaluate_output("Perimeters", cache=cache) + self.assertIsInstance(perimeters_paths, SlicePaths) + self.assertGreater(len(perimeters_paths), 0) + self.assertEqual(perimeters_paths.layer_height, 1.0) + self.assertEqual(perimeters_paths.first_layer_height, 0.5) + + # First layer paths should have adjusted extrusion rate + z_first = 0.5 + for i, p in enumerate(perimeters_paths): + if len(p) > 0 and abs(p[0, 2] - z_first) < 1e-4: + self.assertTrue(np.allclose(perimeters_paths.extrusion_rate[i], 0.5)) + + # Test infill contours evaluation (should reuse cache) + infill_paths = node.evaluate_output("Infill Contours", cache=cache) + self.assertIsInstance(infill_paths, SlicePaths) + self.assertGreater(len(infill_paths), 0) + + def test_cuda_slice_mesh_with_rotation(self): + so = SlicingObject() + so.name = "RotatedCube" + + tris = np.array([ + [[0,0,0], [10,0,0], [10,10,0]], + [[0,0,0], [10,10,0], [0,10,0]], + [[0,0,10], [10,10,10], [10,0,10]], + [[0,0,10], [0,10,10], [10,10,10]], + [[0,0,0], [0,10,0], [0,10,10]], + [[0,0,0], [0,10,10], [0,0,10]], + [[10,0,0], [10,10,10], [10,10,0]], + [[10,0,0], [10,0,10], [10,10,10]], + [[0,0,0], [10,0,10], [10,0,0]], + [[0,0,0], [0,0,10], [10,0,10]], + [[0,10,0], [10,10,0], [10,10,10]], + [[0,10,0], [10,10,10], [0,10,10]], + ], dtype=np.float64) + + normals = np.zeros((12, 1, 3), dtype=np.float64) + so.mesh = np.concatenate([tris, normals], axis=1) + + node = SLICENODE_CudaSliceMesh() + resolve_map = { + "Mesh": so, + "Layer Height": 1.0, + "First Layer Height": 1.0, + "Perimeters Count": 1, + "Layer Width": 0.45, + "Pitch": 0.10, + "Pad": 1.5, + "Rotation": (0.0, 0.0, float(np.radians(45.0))), + } + node.resolve = lambda key: resolve_map.get(key) + node.backend = "AUTO" + + paths = node.evaluate_output("Perimeters") + self.assertIsInstance(paths, SlicePaths) + self.assertGreater(len(paths), 0) + + def test_cuda_slice_mesh_backends(self): + so = SlicingObject() + tris = np.array([ + [[0,0,0], [10,0,0], [10,10,0]], + [[0,0,0], [10,10,0], [0,10,0]], + [[0,0,10], [10,10,10], [10,0,10]], + [[0,0,10], [0,10,10], [10,10,10]], + [[0,0,0], [0,10,0], [0,10,10]], + [[0,0,0], [0,10,10], [0,0,10]], + [[10,0,0], [10,10,10], [10,10,0]], + [[10,0,0], [10,0,10], [10,10,10]], + [[0,0,0], [10,0,10], [10,0,0]], + [[0,0,0], [0,0,10], [10,0,10]], + [[0,10,0], [10,10,0], [10,10,10]], + [[0,10,0], [10,10,10], [0,10,10]], + ], dtype=np.float64) + so.mesh = np.concatenate([tris, np.zeros((12, 1, 3))], axis=1) + + node = SLICENODE_CudaSliceMesh() + resolve_map = { + "Mesh": so, + "Layer Height": 1.0, + "Perimeters Count": 1, + "Layer Width": 0.45, + "Pitch": 0.10, + "Pad": 1.5, + } + node.resolve = lambda key: resolve_map.get(key) + + for b in ["AUTO", "CPU"]: + node.backend = b + paths = node.evaluate_output("Perimeters") + self.assertGreater(len(paths), 0, f"Backend {b} should produce paths") + + def test_cuda_slice_mesh_empty(self): + node = SLICENODE_CudaSliceMesh() + node.resolve = lambda key: None + paths = node.evaluate_output("Perimeters") + self.assertIsInstance(paths, SlicePaths) + self.assertEqual(len(paths), 0) + + def test_cuda_slice_mesh_evaluation_cleaned(self): + so = SlicingObject() + so.name = "TestCube" + tris = np.array([ + [[0,0,0], [10,0,0], [10,10,0]], + [[0,0,0], [10,10,0], [0,10,0]], + [[0,0,10], [10,10,10], [10,0,10]], + [[0,0,10], [0,10,10], [10,10,10]], + [[0,0,0], [0,10,0], [0,10,10]], + [[0,0,0], [0,10,10], [0,0,10]], + [[10,0,0], [10,10,10], [10,10,0]], + [[10,0,0], [10,0,10], [10,10,10]], + [[0,0,0], [10,0,10], [10,0,0]], + [[0,0,0], [0,0,10], [10,0,10]], + [[0,10,0], [10,10,0], [10,10,10]], + [[0,10,0], [10,10,10], [0,10,10]], + ], dtype=np.float64) + so.mesh = np.concatenate([tris, np.zeros((12, 1, 3))], axis=1) + + node = SLICENODE_CudaSliceMesh() + resolve_map = { + "Mesh": so, + "Layer Height": 1.0, + "First Layer Height": 0.5, + "Perimeters Count": 2, + "Layer Width": 0.45, + "Pitch": 0.10, + "Pad": 1.5, + "Rotation": (0.0, 0.0, 0.0), + } + node.resolve = lambda key: resolve_map.get(key) + node.backend = "AUTO" + + cache = {} + perim_paths = node.evaluate_output("Perimeters", cache=cache) + infill_contours = node.evaluate_output("Infill Contours", cache=cache) + + self.assertIsInstance(perim_paths, SlicePaths) + self.assertIsInstance(infill_contours, SlicePaths) + self.assertGreater(len(perim_paths), 0) + self.assertGreater(len(infill_contours), 0) + + +if __name__ == "__main__": + unittest.main()