arranging

This commit is contained in:
Nicolas Predella 2026-06-08 14:35:35 +03:00
parent 96919bfe70
commit 918df11861
9 changed files with 382 additions and 1 deletions

View file

@ -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:

View file

@ -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

View file

@ -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"

View file

@ -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.

View file

@ -9,4 +9,5 @@ dependencies = [
"numpy>=2.2.6",
"pyclipper>=1.4.0",
"shapely>=2.1.2",
"python-libnest2d>=0.1.3",
]

175
tests/test_arrange.py Normal file
View file

@ -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()

17
uv.lock generated
View file

@ -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"