nodeslicer/core/nodeslicer/nodes/mesh.py
2026-07-29 16:28:23 +03:00

551 lines
20 KiB
Python

from __future__ import annotations
from typing import Any
import numpy as np
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
@register_class
class SLICENODE_GetMesh(SliceNode):
bl_label = "Get Mesh"
include_children: NfBoolProperty(name="Include Children", default=True) # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_input("SLICESOCK_Object")
self.add_output("SLICESOCK_Mesh")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "include_children")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection | None:
if socket_name != "Mesh": return None
obj: Any = self.resolve("Object")
if not obj: return None
family = [obj] + (get_all_children(obj) if self.include_children else [])
sc: SlicingCollection = SlicingCollection(family, obj.name)
return sc
@register_class
class SLICENODE_JoinMesh(SliceNodeJoiner):
bl_label = "Join Mesh Parts"
socket_type = "SLICESOCK_Mesh"
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection:
from ....infra.blender.blender_slice_nodes_generic import resolve_input
merged_coll = resolve_input(self.inputs[0]) or SlicingCollection([],'')
for input in self.inputs[1:]:
coll = resolve_input(input)
if coll:
merged_coll.objects += coll.objects
return merged_coll
@register_class
class SLICENODE_JoinMeshGroups(SliceNodeJoiner):
bl_label = "Join Mesh Groups"
socket_type = "SLICESOCK_Mesh"
def init(self, context):
self.add_output(self.output_socket_type or self.socket_type, display_shape='LIST')
self.add_multi_socket_group(self.socket_type)
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingGroup:
from ....infra.blender.blender_slice_nodes_generic import resolve_input
group: SlicingGroup = SlicingGroup([])
for input in self.inputs:
coll = resolve_input(input)
if coll:
group.collections[coll.objects[0].name] = coll
return group
@register_class
class SLICENODE_SeparateMesh(SliceNode):
bl_label = "Separate Mesh"
comparison: NfEnumProperty(
name="Comparison",
items=[
('EQUAL', "Equal", "=="),
('NOT_EQUAL', "Not Equal", "!="),
('GREATER_THAN', "Greater Than", ">"),
('LESS_THAN', "Less Than", "<"),
('GREATER_EQUAL', "Greater Equal", ">="),
('LESS_EQUAL', "Less Equal", "<="),
],
default='EQUAL',
update=notify_tree_changed
) # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_input("SLICESOCK_Mesh")
self.add_input("SLICESOCK_String", "Attribute")
self.add_input("SLICESOCK_Float", "Value")
self.add_output("SLICESOCK_Mesh", "Selection")
self.add_output("SLICESOCK_Mesh", "Inverted")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "comparison", text="")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection | None:
coll: SlicingCollection | None = self.resolve("Mesh")
attr_name: str = self.resolve("Attribute")
value: float = self.resolve("Value")
if not coll: return None
res_selection = SlicingCollection([], coll.name + "_selection")
res_inverted = SlicingCollection([], coll.name + "_inverted")
for so in coll.objects:
if attr_name not in so.attributes:
# If attribute missing, entire object goes to inverted
res_inverted.objects.append(so)
continue
attr = so.attributes[attr_name]
vals = attr["values"]
mask = np.zeros(len(so.mesh), dtype=bool)
if self.comparison == 'EQUAL': mask = vals == value
elif self.comparison == 'NOT_EQUAL': mask = vals != value
elif self.comparison == 'GREATER_THAN': mask = vals > value
elif self.comparison == 'LESS_THAN': mask = vals < value
elif self.comparison == 'GREATER_EQUAL': mask = vals >= value
elif self.comparison == 'LESS_EQUAL': mask = vals <= value
if np.all(mask):
res_selection.objects.append(so)
elif not np.any(mask):
res_inverted.objects.append(so)
else:
# Split the object
so_sel = SlicingObject()
so_sel.name = so.name + "_sel"
so_sel.parent = so.parent
so_sel.object_type = so.object_type
so_sel.extruder = so.extruder
so_sel.mesh = so.mesh[mask]
for k, v in so.attributes.items():
so_sel.attributes[k] = {
"domain": v["domain"],
"type": v["type"],
"values": v["values"][mask]
}
res_selection.objects.append(so_sel)
so_inv = SlicingObject()
so_inv.name = so.name + "_inv"
so_inv.parent = so.parent
so_inv.object_type = so.object_type
so_inv.extruder = so.extruder
so_inv.mesh = so.mesh[~mask]
for k, v in so.attributes.items():
so_inv.attributes[k] = {
"domain": v["domain"],
"type": v["type"],
"values": v["values"][~mask]
}
res_inverted.objects.append(so_inv)
if socket_name == "Selection":
return res_selection
return res_inverted
@register_class
class SLICENODE_SeparateMeshByRange(SliceNode):
bl_label = "Separate Mesh by Range"
start_val: NfIntProperty(name="Start", default=0, update=notify_tree_changed) # pyright: ignore[reportInvalidTypeForm]
end_val: NfIntProperty(name="End", default=1, update=notify_tree_changed) # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_input("SLICESOCK_Mesh")
self.add_input("SLICESOCK_String", "Attribute")
self._update_outputs()
def update(self):
super().update()
self._update_outputs()
def _update_outputs(self):
# Ensure end >= start
start = self.start_val
end = max(start, self.end_val)
expected_ids = [str(i) for i in range(start, end + 1)]
# Remove outputs that are no longer in the range
# We use a list to avoid modification-during-iteration issues
to_remove = [o for o in self.outputs if o.identifier not in expected_ids]
for o in to_remove:
self.outputs.remove(o)
# Add new outputs
for id_str in expected_ids:
if id_str not in self.outputs:
self.add_output("SLICESOCK_Mesh", id_str, identifier=id_str)
# Re-order to match range
for i, id_str in enumerate(expected_ids):
current_index = self.outputs.find(id_str)
if current_index != -1 and current_index != i:
self.outputs.move(current_index, i)
def draw_buttons(self, context, layout):
assert layout
col = layout.column(align=True)
col.prop(self, "start_val")
col.prop(self, "end_val")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection | None:
coll: SlicingCollection | None = self.resolve("Mesh")
attr_name: str = self.resolve("Attribute")
if not coll: return None
# Check if we can parse the socket name as an integer
try:
target_val = int(socket_name)
except ValueError:
return None
res = SlicingCollection([], f"{coll.name}_{target_val}")
for so in coll.objects:
if attr_name not in so.attributes:
continue
attr = so.attributes[attr_name]
vals = attr["values"]
# Attribute values might be floats or ints, we check for equality
mask = vals == target_val
if np.all(mask):
res.objects.append(so)
elif np.any(mask):
# Split the object
so_new = SlicingObject()
so_new.name = f"{so.name}_{target_val}"
so_new.parent = so.parent
so_new.object_type = so.object_type
so_new.extruder = so.extruder
so_new.mesh = so.mesh[mask]
for k, v in so.attributes.items():
so_new.attributes[k] = {
"domain": v["domain"],
"type": v["type"],
"values": v["values"][mask]
}
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
new_so.transforms = [dict(t) for t in getattr(so, "transforms", [])]
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]
new_sc.transforms = [dict(t) for t in getattr(sc, "transforms", [])]
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
new_sg.object_offset = sg.object_offset.copy()
new_sg.transforms = [dict(t) for t in getattr(sg, "transforms", [])]
return new_sg
@register_class
class SLICENODE_TransformGeometry(SliceNode):
bl_label = "Transform Geometry"
def init(self, context):
self.add_input("SLICESOCK_Mesh", "Mesh")
self.add_input("SLICESOCK_Vector", "Translation")
self.add_input("SLICESOCK_VectorRotation", "Rotation")
self.add_input("SLICESOCK_Vector", "Scale").value = (1.0, 1.0, 1.0)
self.add_output("SLICESOCK_Mesh", "Mesh")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection | SlicingGroup | None:
if socket_name != "Mesh":
return None
mesh_data: SlicingCollection | SlicingGroup | None = self.resolve("Mesh")
if not mesh_data:
return None
translation = self.resolve("Translation") or (0.0, 0.0, 0.0)
rotation = self.resolve("Rotation") or (0.0, 0.0, 0.0)
scale = self.resolve("Scale") or (1.0, 1.0, 1.0)
t_arr = np.array(translation, dtype=np.float64)
r_arr = np.array(rotation, dtype=np.float64)
s_arr = np.array(scale, dtype=np.float64)
if isinstance(mesh_data, SlicingGroup):
copied_data = _copy_slicing_group(mesh_data)
for col in copied_data.collections.values():
if any(s != 1.0 for s in s_arr):
col.scale(s_arr)
if any(r != 0.0 for r in r_arr):
col.rotate(r_arr)
if any(t != 0.0 for t in t_arr):
col.offset(t_arr)
# Also offset wipe tower location
if any(t != 0.0 for t in t_arr):
copied_data.wipe_tower_xy = copied_data.wipe_tower_xy + t_arr[0:2]
if any(r != 0.0 for r in r_arr):
rz = r_arr[2]
cz, sz = np.cos(rz), np.sin(rz)
rot_mat_2d = np.array([[cz, -sz], [sz, cz]])
copied_data.wipe_tower_xy = copied_data.wipe_tower_xy @ rot_mat_2d.T
import math
copied_data.wipe_tower_rotation_deg = round(copied_data.wipe_tower_rotation_deg + rz * 180 / math.pi, 5)
if any(s != 1.0 for s in s_arr):
copied_data.wipe_tower_xy *= s_arr[0:2]
else:
copied_data = _copy_slicing_collection(mesh_data)
if any(s != 1.0 for s in s_arr):
copied_data.scale(s_arr)
if any(r != 0.0 for r in r_arr):
copied_data.rotate(r_arr)
if any(t != 0.0 for t in t_arr):
copied_data.offset(t_arr)
t_rec = {
"type": "transform_geometry",
"translation": (float(t_arr[0]), float(t_arr[1]), float(t_arr[2])),
"rotation": (float(r_arr[0]), float(r_arr[1]), float(r_arr[2])),
"scale": (float(s_arr[0]), float(s_arr[1]), float(s_arr[2])),
}
if not hasattr(copied_data, "transforms"):
copied_data.transforms = []
copied_data.transforms.append(t_rec)
return copied_data
@register_class
class SLICENODE_DropGeometry(SliceNode):
bl_label = "Drop Geometry"
def init(self, context):
self.add_input("SLICESOCK_Mesh", "Mesh")
self.add_input("SLICESOCK_Float", "Z")
self.add_output("SLICESOCK_Mesh", "Mesh")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection | SlicingGroup | None:
if socket_name != "Mesh":
return None
mesh_data: SlicingCollection | SlicingGroup | None = self.resolve("Mesh")
if not mesh_data:
return None
z = self.resolve('Z')
if z is None:
z = 0.0
assert mesh_data.min_z is not None
min_z = round(mesh_data.min_z, 6)
offset_z = float(z - min_z)
if isinstance(mesh_data, SlicingGroup):
copied_data = _copy_slicing_group(mesh_data)
else:
copied_data = _copy_slicing_collection(mesh_data)
copied_data.offset(np.array([0.0, 0.0, offset_z], dtype=np.float64))
drop_rec = {
"type": "drop_geometry",
"target_z": float(z),
"offset_z": float(offset_z),
"translation": (0.0, 0.0, float(offset_z)),
"rotation": (0.0, 0.0, 0.0),
"scale": (1.0, 1.0, 1.0),
}
if not hasattr(copied_data, "transforms"):
copied_data.transforms = []
copied_data.transforms.append(drop_rec)
return copied_data