diff --git a/blender_manifest.toml b/blender_manifest.toml index b61725f..32b0731 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -48,6 +48,9 @@ wheels = [ "./wheels/pybgcode_jneilliii-0.2.1-cp313-cp313-macosx_11_0_arm64.whl", "./wheels/pybgcode_jneilliii-0.2.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", "./wheels/pybgcode_jneilliii-0.2.1-cp313-cp313-win_amd64.whl", + + "./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", ] # Optional: add-ons can list which resources they will require: diff --git a/core/nodeslicer/nodes/mesh.py b/core/nodeslicer/nodes/mesh.py index 04934e9..94e7064 100644 --- a/core/nodeslicer/nodes/mesh.py +++ b/core/nodeslicer/nodes/mesh.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any import numpy as np -from ..bpy_ns_types import NfBoolProperty, NfEnumProperty, NfIntProperty, notify_tree_changed +from ..bpy_ns_types import NfBoolProperty, NfEnumProperty, NfIntProperty, NfFloatProperty, notify_tree_changed from ....registry import register_class from .base import SliceNode, SlicingCollection, get_all_children, SliceNodeJoiner, SlicingGroup, SlicingObject @@ -250,3 +250,182 @@ class SLICENODE_SeparateMeshByRange(SliceNode): res.objects.append(so_new) return res + +@register_class +class SLICENODE_ArrangeMesh(SliceNode): + bl_label = "Arrange Slicing Group" + + method: NfEnumProperty( + name="Method", + items=[ + ('NFP', "NFP (Default)", "Nest using No-Fit Polygon placer + First-Fit selection"), + ('BL', "Bottom-Left", "Nest using Bottom-Left placer + First-Fit selection"), + ('DJD', "DJD Heuristic", "Nest using No-Fit Polygon placer + DJD Heuristic selection"), + ], + default='NFP', + update=notify_tree_changed + ) # pyright: ignore[reportInvalidTypeForm] + + allow_rotations: NfBoolProperty( + name="Allow Rotations", + default=True, + update=notify_tree_changed + ) # pyright: ignore[reportInvalidTypeForm] + + def init(self, context): + self.add_input("SLICESOCK_Mesh", "Slicing Group", display_shape='LIST') + self.add_input("SLICESOCK_Float", "Bed X").value = 250.0 + self.add_input("SLICESOCK_Float", "Bed Y").value = 250.0 + self.add_input("SLICESOCK_Float", "Spacing").value = 2.0 + self.add_output("SLICESOCK_Mesh", "Slicing Group", display_shape='LIST') + + def draw_buttons(self, context, layout): + assert layout + layout.prop(self, "method", text="") + layout.prop(self, "allow_rotations") + + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingGroup | None: + if socket_name != "Slicing Group": + return None + + mesh_data: SlicingCollection | SlicingGroup | None = self.resolve("Slicing Group") + if not mesh_data: + return None + + bed_x: float = self.resolve("Bed X") or 250.0 + bed_y: float = self.resolve("Bed Y") or 250.0 + spacing: float = self.resolve("Spacing") or 2.0 + + # Convert input to SlicingGroup if it's a SlicingCollection + if isinstance(mesh_data, SlicingGroup): + slicing_group = mesh_data + else: + slicing_group = SlicingGroup([]) + slicing_group.collections = {mesh_data.name: mesh_data} + + # Create a deep copy of the group to avoid mutating cached inputs + copied_group = _copy_slicing_group(slicing_group) + + # Prepare the list of items to pack + import pynest2d + + items_list = [] + collection_map = {} + for name, sc in copied_group.collections.items(): + if sc.min_x is None or sc.max_x is None or sc.min_y is None or sc.max_y is None: + continue + w_mm = sc.max_x - sc.min_x + h_mm = sc.max_y - sc.min_y + if w_mm <= 0 or h_mm <= 0: + continue + + w = pynest2d.mm(w_mm) + h = pynest2d.mm(h_mm) + item = pynest2d.Rectangle(w, h) + items_list.append(item) + collection_map[id(item)] = (name, sc) + + if not items_list: + return copied_group + + # Define the bin + bin_w = pynest2d.mm(bed_x) + bin_h = pynest2d.mm(bed_y) + bin_center = pynest2d.Point(pynest2d.mm(bed_x / 2.0), pynest2d.mm(bed_y / 2.0)) + box = pynest2d.Box(bin_w, bin_h, bin_center) + + # Spacing in internal units + spacing_units = pynest2d.mm(spacing) + + # Configure and run the selected placer + placer = getattr(self, "method", "NFP") + allow_rot = getattr(self, "allow_rotations", True) + + if placer == 'BL': + config = pynest2d.BLConfig() + config.allow_rotations = allow_rot + pynest2d.nest_blp(items_list, box, spacing_units, config) + elif placer == 'DJD': + placer_config = pynest2d.NfpConfig() + if not allow_rot: + placer_config.rotations = [0.0] + pynest2d.nest_djd(items_list, box, spacing_units, placer_config) + else: # 'NFP' + config = pynest2d.NfpConfig() + if not allow_rot: + config.rotations = [0.0] + pynest2d.nest(items_list, box, spacing_units, config) + + # Apply the resulting translations and rotations to the copied collections + for item in items_list: + bin_id = item.binId() + if bin_id == 0: + name, sc = collection_map[id(item)] + trans = item.translation() + rot = item.rotation() + + tx = trans.x() / pynest2d.MM_IN_COORDS + ty = trans.y() / pynest2d.MM_IN_COORDS + + min_x = sc.min_x + min_y = sc.min_y + + cos_val = np.cos(rot) + sin_val = np.sin(rot) + + for so in sc.objects: + if so.mesh is None or so.mesh.size == 0: + continue + vx = so.mesh[:, :3, 0] + vy = so.mesh[:, :3, 1] + + vx_new = (vx - min_x) * cos_val - (vy - min_y) * sin_val + tx + vy_new = (vx - min_x) * sin_val + (vy - min_y) * cos_val + ty + + so.mesh[:, :3, 0] = vx_new + so.mesh[:, :3, 1] = vy_new + + nx = so.mesh[:, 3, 0] + ny = so.mesh[:, 3, 1] + + nx_new = nx * cos_val - ny * sin_val + ny_new = nx * sin_val + ny * cos_val + + so.mesh[:, 3, 0] = nx_new + so.mesh[:, 3, 1] = ny_new + else: + name, sc = collection_map[id(item)] + print(f"Warning: Collection '{name}' did not fit on the build plate (binId: {bin_id})") + + return copied_group + + +def _copy_slicing_object(so: SlicingObject) -> SlicingObject: + new_so = SlicingObject() + new_so.name = so.name + new_so.parent = so.parent + new_so.mesh = so.mesh.copy() if so.mesh is not None else np.empty((0, 4, 3)) + new_so.attributes = {} + for k, v in so.attributes.items(): + new_so.attributes[k] = { + "domain": v["domain"], + "type": v["type"], + "values": v["values"].copy() if isinstance(v["values"], np.ndarray) else v["values"] + } + new_so.object_type = so.object_type + new_so.modifiers = so.modifiers.copy() if so.modifiers else [] + new_so.extruder = so.extruder + return new_so + +def _copy_slicing_collection(sc: SlicingCollection) -> SlicingCollection: + new_sc = SlicingCollection([], sc.name) + new_sc.objects = [_copy_slicing_object(so) for so in sc.objects] + return new_sc + +def _copy_slicing_group(sg: SlicingGroup) -> SlicingGroup: + new_sg = SlicingGroup([]) + new_sg.collections = {k: _copy_slicing_collection(sc) for k, sc in sg.collections.items()} + new_sg.wipe_tower_xy = sg.wipe_tower_xy.copy() + new_sg.wipe_tower_rotation_deg = sg.wipe_tower_rotation_deg + return new_sg + diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index 95d176f..bf44c4d 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -498,6 +498,7 @@ class SLICENODE_MT_Geometry(Menu): layout.operator("node.add_node", text="Get Mesh").type = "SLICENODE_GetMesh" layout.operator("node.add_node", text="Separate Mesh").type = "SLICENODE_SeparateMesh" layout.operator("node.add_node", text="Separate Mesh by Range").type = "SLICENODE_SeparateMeshByRange" + layout.operator("node.add_node", text="Arrange Slicing Group").type = "SLICENODE_ArrangeMesh" layout.operator("node.add_node", text="Get Paths").type = "SLICENODE_GetPaths" layout.separator() layout.operator("node.add_node", text="Slice Shells").type = "SLICENODE_SliceShells" diff --git a/doc/NODES.md b/doc/NODES.md index fd8548d..34c1be3 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -65,6 +65,11 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Infill Rotation**: Base rotation of the infill pattern. - **Layer Rotation**: Rotation of the entire slice to align infill with specific planes. - **Boolean Paths**: Performs 2D boolean operations (`UNION`, `INTERSECTION`, `DIFFERENCE`, `XOR`) on multiple slice sets. +- **Arrange Slicing Group**: Arranges the slicing collections in a slicing group on the build plate using the `libnest2d` bin-packing library. It nests their 2D bounding boxes to prevent collisions. + - **Method**: Switch between **NFP** (No-Fit Polygon placer + First-Fit selection), **Bottom-Left** (Bottom-Left placer + First-Fit selection), and **DJD Heuristic** (No-Fit Polygon placer + Dyckhoff's heuristic). + - **Allow Rotations**: Toggle whether to allow rotating objects (by 90-degree steps) during nesting. + - **Bed X / Y**: The build plate dimensions in mm. + - **Spacing**: The minimum distance between arranged objects in mm. ### Utility & Joiners - **Join Mesh**: Merges multiple `SlicingObject` instances into one. diff --git a/pyproject.toml b/pyproject.toml index 710e497..538e953 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,4 +9,5 @@ dependencies = [ "numpy>=2.2.6", "pyclipper>=1.4.0", "shapely>=2.1.2", + "python-libnest2d>=0.1.3", ] diff --git a/tests/test_arrange.py b/tests/test_arrange.py new file mode 100644 index 0000000..0568fc2 --- /dev/null +++ b/tests/test_arrange.py @@ -0,0 +1,175 @@ +import numpy as np +import unittest +from unittest.mock import MagicMock +import sys +from pathlib import Path + +# Add workspace directory to sys.path to resolve root package name (NodeSlicer) +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.append(str(PROJECT_ROOT.parent)) + +# Define dummy classes to avoid metaclass conflicts +class DummyNodeSocket: pass +class DummyNode: pass +class DummyObject: pass +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 and its subpackages before importing core modules +mock_bpy = MagicMock() +mock_bpy.context.scene = MagicMock() +mock_bpy.context.scene.unit_settings.scale_length = 0.001 +mock_bpy.types = mock_types + +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'] = MagicMock() +sys.modules['bpy.app'] = MagicMock() +sys.modules['bpy.app.handlers'] = MagicMock() + +# Import the actual classes to test +from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_ArrangeMesh +from NodeSlicer.infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject + +class TestArrangeMesh(unittest.TestCase): + def test_arrange_mesh_simple(self): + # Create a SlicingObject with a single triangle + so = SlicingObject() + so.name = "part1" + so.mesh = np.array([[[10.0, 10.0, 0.0], + [20.0, 10.0, 0.0], + [15.0, 20.0, 0.0], + [0.0, 0.0, 1.0]]], dtype=np.float64) + + # Create SlicingCollection + sc = SlicingCollection([], "coll1") + sc.objects = [so] + + # Create SlicingGroup + sg = SlicingGroup([]) + sg.collections = {"coll1": sc} + + # Mock node inputs resolution + node = SLICENODE_ArrangeMesh() + node.method = 'NFP' + + # We override resolve to simulate inputs + def mock_resolve(fieldname): + if fieldname == "Slicing Group": + return sg + elif fieldname == "Bed X": + return 250.0 + elif fieldname == "Bed Y": + return 250.0 + elif fieldname == "Spacing": + return 2.0 + return None + + node.resolve = mock_resolve + + # Evaluate output + result_group = node.evaluate_output("Slicing Group") + self.assertIsNotNone(result_group) + self.assertIn("coll1", result_group.collections) + + arranged_sc = result_group.collections["coll1"] + # The bounding box should now be centered on the 250x250 bed + self.assertEqual(arranged_sc.min_x, 120.0) + self.assertEqual(arranged_sc.min_y, 120.0) + self.assertEqual(arranged_sc.max_x, 130.0) + self.assertEqual(arranged_sc.max_y, 130.0) + + # Check normal remains unchanged + self.assertTrue(np.allclose(arranged_sc.objects[0].mesh[0, 3, :], [0.0, 0.0, 1.0])) + + def test_arrange_mesh_bl(self): + so = SlicingObject() + so.name = "part1" + so.mesh = np.array([[[10.0, 10.0, 0.0], + [20.0, 10.0, 0.0], + [15.0, 20.0, 0.0], + [0.0, 0.0, 1.0]]], dtype=np.float64) + + sc = SlicingCollection([], "coll1") + sc.objects = [so] + sg = SlicingGroup([]) + sg.collections = {"coll1": sc} + + node = SLICENODE_ArrangeMesh() + node.method = 'BL' + + def mock_resolve(fieldname): + if fieldname == "Slicing Group": + return sg + elif fieldname == "Bed X": + return 250.0 + elif fieldname == "Bed Y": + return 250.0 + elif fieldname == "Spacing": + return 2.0 + return None + node.resolve = mock_resolve + + result_group = node.evaluate_output("Slicing Group") + self.assertIsNotNone(result_group) + arranged_sc = result_group.collections["coll1"] + self.assertGreaterEqual(arranged_sc.min_x, 0.0) + self.assertGreaterEqual(arranged_sc.min_y, 0.0) + + def test_arrange_mesh_djd(self): + so = SlicingObject() + so.name = "part1" + so.mesh = np.array([[[10.0, 10.0, 0.0], + [20.0, 10.0, 0.0], + [15.0, 20.0, 0.0], + [0.0, 0.0, 1.0]]], dtype=np.float64) + + sc = SlicingCollection([], "coll1") + sc.objects = [so] + sg = SlicingGroup([]) + sg.collections = {"coll1": sc} + + node = SLICENODE_ArrangeMesh() + node.method = 'DJD' + + def mock_resolve(fieldname): + if fieldname == "Slicing Group": + return sg + elif fieldname == "Bed X": + return 250.0 + elif fieldname == "Bed Y": + return 250.0 + elif fieldname == "Spacing": + return 2.0 + return None + node.resolve = mock_resolve + + result_group = node.evaluate_output("Slicing Group") + self.assertIsNotNone(result_group) + arranged_sc = result_group.collections["coll1"] + self.assertEqual(arranged_sc.min_x, 120.0) + self.assertEqual(arranged_sc.min_y, 120.0) + +if __name__ == '__main__': + unittest.main() diff --git a/uv.lock b/uv.lock index f49e3b4..d038379 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,7 @@ dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pyclipper" }, + { name = "python-libnest2d" }, { name = "shapely" }, ] @@ -32,6 +33,7 @@ requires-dist = [ { name = "fake-bpy-module", specifier = ">=20260317" }, { name = "numpy", specifier = ">=2.2.6" }, { name = "pyclipper", specifier = ">=1.4.0" }, + { name = "python-libnest2d", specifier = ">=0.1.3" }, { name = "shapely", specifier = ">=2.1.2" }, ] @@ -225,6 +227,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/59/81050abdc9e5b90ffc2c765738c5e40e9abd8e44864aaa737b600f16c562/pyclipper-1.4.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98b2a40f98e1fc1b29e8a6094072e7e0c7dfe901e573bf6cfc6eb7ce84a7ae87", size = 126495, upload-time = "2025-12-01T13:15:33.743Z" }, ] +[[package]] +name = "python-libnest2d" +version = "0.1.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/6b/616287af0afc6074c6ed4d40d71d6cac44c1e9ea697360cc72db9622adc2/python_libnest2d-0.1.3-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e25d406302d0080e93dcda6f5937a714944bed2062f2f63b7d1ba029f0da621", size = 1120509, upload-time = "2026-03-15T23:01:13.728Z" }, + { url = "https://files.pythonhosted.org/packages/b0/74/4aa63d421e6a745fe4a051e175f7477398b5ed3c9fa82818a1fe9381a63a/python_libnest2d-0.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:84ad58d2bde15d7ba4241897e8430b69880f222bfc3b1e34313591d124c921f8", size = 1237883, upload-time = "2026-03-15T23:01:15.547Z" }, + { url = "https://files.pythonhosted.org/packages/ae/05/f2bdcf8ee4bd07622db0c4d495a8669db5b67ea0b48f779964a9109ad845/python_libnest2d-0.1.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d452565ca44101e452529a35ff6ca58ac3015d3244c017eeb833f20a30d66e8c", size = 1121974, upload-time = "2026-03-15T23:01:17.324Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7f/b4089649e0dead34ce4473758ad922b7889d85482b1467fe057e4a5181cb/python_libnest2d-0.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:8e6c802e757cc75a4134d8160f5c2afff2f22192f7337ca90831f825d006f906", size = 1238832, upload-time = "2026-03-15T23:01:19.12Z" }, + { url = "https://files.pythonhosted.org/packages/4f/70/ff657e7be57f000262e1002498e3cc4571c73d8c0f1dc30f26593500c27a/python_libnest2d-0.1.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7de467b5afd5451a861747b56e2e0ec4e0f2e07f453fc1802f003e0947b0e30", size = 1121510, upload-time = "2026-03-15T23:01:21.122Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9d/4d128b6428d1cd2ef6f925270a74a669f831462d6b6ad5e8ac81a00fd384/python_libnest2d-0.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:0084e53383c215a5566703b953c4638aeade5ecb3250fd3ab44dab533b25f348", size = 1239001, upload-time = "2026-03-15T23:01:22.867Z" }, + { url = "https://files.pythonhosted.org/packages/d3/24/33ec7aa4b2ce557c71e41a66464eed31fcfaaea000e02dc3eee3a8b2a919/python_libnest2d-0.1.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ae839990bdd31f9b60c6e00f5d06eef055d03d3977cfe095cd68b1674a99531e", size = 1121496, upload-time = "2026-03-15T23:01:24.845Z" }, + { url = "https://files.pythonhosted.org/packages/5d/62/c42b3d5fd8cf5174144788cd5fd5a17f0cc5f2cf60d7abb79ed659ee4708/python_libnest2d-0.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:b8e864651033a7497f466c56353e6d5c6e71f3b44220cfd7598cc38b535c7319", size = 1239026, upload-time = "2026-03-15T23:01:26.461Z" }, +] + [[package]] name = "shapely" version = "2.1.2" diff --git a/wheels/python_libnest2d-0.1.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl b/wheels/python_libnest2d-0.1.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl new file mode 100644 index 0000000..e3cfca2 Binary files /dev/null and b/wheels/python_libnest2d-0.1.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl differ diff --git a/wheels/python_libnest2d-0.1.3-cp313-cp313-win_amd64.whl b/wheels/python_libnest2d-0.1.3-cp313-cp313-win_amd64.whl new file mode 100644 index 0000000..701d94b Binary files /dev/null and b/wheels/python_libnest2d-0.1.3-cp313-cp313-win_amd64.whl differ