nodeslicer/core/nodeslicer/nodes/path.py
2026-07-20 16:53:02 +03:00

590 lines
20 KiB
Python

from __future__ import annotations
from typing import Any
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
@register_class
class SLICENODE_GetPaths(SliceNode):
bl_label = "Get Paths"
def init(self, context):
self.add_input("SLICESOCK_Object")
self.add_input("SLICESOCK_String", "Extrusion Multiplier Attribute").value = 'e_mult'
self.add_input("SLICESOCK_String", "Feedrate Attribute").value = 'f_rate'
self.add_input("SLICESOCK_Settings", "Extra Attributes")
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
pass
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
obj: Any = self.resolve("Object")
e_attr_name: str = self.resolve("Extrusion Multiplier Attribute")
f_attr_name: str = self.resolve("Feedrate Attribute")
extra_attrs: dict[str, str] = self.resolve("Extra Attributes")
from ....infra.blender.blender_mesh_capture import capture_mesh_edges
so, e_rates, f_rates, extra_attrs_data = capture_mesh_edges(obj, e_attr_name, f_attr_name, extra_attrs)
return SlicePaths(so, extrusion_rate=e_rates, feedrate=f_rates, extra_attributes=extra_attrs_data)
@register_class
class SLICENODE_SliceShells(SliceNode):
bl_label = "Slice Shells"
def init(self, context):
self.add_input("SLICESOCK_Mesh")
self.add_input("SLICESOCK_Float", "Layer Height").value = 0.2
self.add_input("SLICESOCK_Float", "First Layer Height").value = 0.2
self.add_input("SLICESOCK_VectorRotation", "Rotation")
self.add_output("SLICESOCK_Paths")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Paths": return SlicePaths()
coll: SlicingCollection | SlicingGroup | None = self.resolve("Mesh")
layer_height: 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
rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0)
if not coll: return SlicePaths()
# Handle SlicingGroup flattening
if isinstance(coll, SlicingGroup):
objects = []
for c in coll.collections.values():
objects.extend(c.objects)
else:
objects = coll.objects
merged_object = SlicingObject.merge(objects)
if rotation and any(rotation):
from mathutils import Euler
mat = np.array(Euler(rotation).to_matrix().inverted())
merged_object.mesh[:, :3, :] = merged_object.mesh[:, :3, :] @ mat.T
merged_object.mesh[:, 3:4, :] = merged_object.mesh[:, 3:4, :] @ mat.T
min_z = merged_object.mesh[:,0:3,2:3].min()
from math import floor
min_z = floor(min_z / layer_height) * layer_height
max_z = merged_object.mesh[:,0:3,2:3].max()
h_first = fl_height if fl_height is not None else layer_height
z_first = min_z + h_first
z_rest_start = min_z + h_first + layer_height
if z_rest_start < max_z:
z_rest = np.arange(z_rest_start, max_z, layer_height)
z_values = np.concatenate(([z_first], z_rest))
elif z_first < max_z:
z_values = np.array([z_first])
else:
z_values = np.array([])
slices_group: list[list[np.ndarray]] | None = slice_at_range([merged_object.mesh], z_values)
assert slices_group
slices = SlicePaths(slices_group[0])
slices.layer_height = layer_height
if fl_height is not None:
slices.first_layer_height = fl_height
if rotation and any(rotation):
from mathutils import Euler
mat_inv = np.array(Euler(rotation).to_matrix())
slices = slices.transform(mat_inv)
return slices
@register_class
class SLICENODE_BufferShells(SliceNode):
bl_label = "Buffer Shells"
shape_option: NfEnumProperty(
name="Miter",
items=[
('round', "Round", ""),
('square', "Square", ""),
('flat', "Flat", "")
]
) # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("SLICESOCK_Float", "Distance").value = 0
self.add_input("SLICESOCK_VectorRotation", "Rotation")
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "shape_option")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = self.resolve("Paths")
distance: float = self.resolve("Distance") or 0.0
rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0)
if not paths: return SlicePaths()
return paths.buffer(distance, miter=self.shape_option, rotation=rotation)
@register_class
class SLICENODE_BufferCurve(SliceNode):
bl_label = "Buffer Curve"
cap_style: NfEnumProperty(
name="Cap Style",
items=[
('round', "Round", ""),
('flat', "Flat", ""),
('square', "Square", "")
],
default='round'
) # pyright: ignore[reportInvalidTypeForm]
join_style: NfEnumProperty(
name="Join Style",
items=[
('round', "Round", ""),
('mitre', "Mitre", ""),
('bevel', "Bevel", "")
],
default='round'
) # pyright: ignore[reportInvalidTypeForm]
single_sided: NfBoolProperty(
name="Single Sided",
description="Buffer only on one side of the curve",
default=False
) # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("SLICESOCK_Float", "Distance").value = 0.0
self.add_input("SLICESOCK_VectorRotation", "Rotation")
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "cap_style")
layout.prop(self, "join_style")
layout.prop(self, "single_sided")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = self.resolve("Paths")
distance: float = self.resolve("Distance") or 0.0
rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0)
if not paths: return SlicePaths()
return paths.buffer_curve(
distance,
cap_style=self.cap_style,
join_style=self.join_style,
single_sided=self.single_sided,
rotation=rotation
)
@register_class
class SLICENODE_OffsetCurve(SliceNode):
bl_label = "Offset Curve"
join_style: NfEnumProperty(
name="Join Style",
items=[
('round', "Round", ""),
('mitre', "Mitre", ""),
('bevel', "Bevel", "")
],
default='round'
) # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("SLICESOCK_Float", "Distance").value = 0.0
self.add_input("SLICESOCK_VectorRotation", "Rotation")
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "join_style")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = self.resolve("Paths")
distance: float = self.resolve("Distance") or 0.0
rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0)
if not paths: return SlicePaths()
return paths.offset_curve(
distance,
join_style=self.join_style,
rotation=rotation
)
@register_class
class SLICENODE_PathOffset(SliceNode):
bl_label = "Path Offset"
def init(self, context):
self.add_input("SLICESOCK_Paths", "Paths")
self.add_input("SLICESOCK_Vector", "Offset")
self.add_output("SLICESOCK_Paths", "Paths")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
paths: SlicePaths = self.resolve("Paths")
offset: Any = self.resolve("Offset") or (0.0, 0.0, 0.0)
if not paths: return SlicePaths()
return paths.offset(np.array(offset, dtype=np.float32))
@register_class
class SLICENODE_RotatePath(SliceNode):
bl_label = "Rotate Path"
def init(self, context):
self.add_input("SLICESOCK_Paths", "Paths")
self.add_input("SLICESOCK_Float", "Angle").value = 0.0
self.add_input("SLICESOCK_Vector", "Center")
self.add_output("SLICESOCK_Paths", "Paths")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
paths: SlicePaths = self.resolve("Paths")
angle: float = self.resolve("Angle") or 0.0
center: Any = self.resolve("Center")
if not paths: return SlicePaths()
center_vec = np.array(center, dtype=np.float32) if center and any(center) else None
return paths.rotate(angle, center=center_vec)
@register_class
class SLICENODE_BufferShellsMultiple(SliceNode):
bl_label = "Buffer Shells Multiple"
shape_option: NfEnumProperty(
name="Miter",
items=[
('round', "Round", ""),
('square', "Square", ""),
('flat', "Flat", "")
]
) # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("SLICESOCK_Float", "Distance").value = 0
self.add_input("SLICESOCK_Float", "Width Step").value = 0.4
self.add_input("SLICESOCK_Int", "Count").value = 2
self.add_input("SLICESOCK_VectorRotation", "Rotation")
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "shape_option")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = self.resolve("Paths")
base_distance: float = self.resolve("Distance") or 0.0
width_step: float = self.resolve("Width Step") or 0.0
count: int = self.resolve("Count") or 1
rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0)
if not paths: return SlicePaths()
all_buffers: list[SlicePaths] = []
for i in range(count):
dist = base_distance + (i * width_step)
all_buffers.append(paths.buffer(dist, miter=self.shape_option, rotation=rotation))
# Zip Logic: Interleave paths by group (Z-layer)
all_groups = set()
grouped_inputs = []
for p_list in all_buffers:
d = {}
for i, g in enumerate(p_list.groups):
all_groups.add(g)
d.setdefault(g, []).append(p_list[i])
grouped_inputs.append(d)
sorted_groups = sorted(list(all_groups))
merged = SlicePaths()
for g in sorted_groups:
for d in grouped_inputs:
group_paths = d.get(g, [])
for p in group_paths:
merged.append_with_group(p, g)
return paths.copy_with(merged, merged.groups)
@register_class
class SLICENODE_Infill(SliceNode):
bl_label = "Infill"
infill_type: NfEnumProperty(
name="Type",
items=[
('GRID', "Grid", "Cross-hatch grid"),
('RECTILINEAR', "Rectilinear", "Alternating parallel lines"),
],
default='GRID',
update=notify_tree_changed
) # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("SLICESOCK_Float", "Density").value = 2.0
self.add_input("SLICESOCK_Float", "Infill Rotation").value = 0.0
self.add_input("SLICESOCK_VectorRotation", "Layer Rotation")
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "infill_type", text="")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = self.resolve("Paths")
density: float = self.resolve("Density") or 2.0
infill_rotation: float = self.resolve("Infill Rotation") or 0.0
rotation: Any = self.resolve("Layer Rotation") or (0.0, 0.0, 0.0)
if not paths: return SlicePaths()
return paths.infill(
density,
rotation_deg=infill_rotation,
infill_type=self.infill_type, # pyright: ignore[reportOptionalMemberAccess]
layer_rotation=rotation
)
@register_class
class SLICENODE_GetInfillMask(SliceNode):
bl_label = "Get Infill Mask"
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
pass
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = self.resolve("Paths")
if not paths: return SlicePaths()
return paths.get_infill_mask()
@register_class
class SLICENODE_OverhangMask(SliceNode):
bl_label = "Overhang Mask"
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("SLICESOCK_Float", "Tolerance").value = 0.05
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
pass
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = self.resolve("Paths")
tolerance: float = self.resolve("Tolerance") or 0.05
if not paths: return SlicePaths()
return paths.get_overhang_mask(tolerance=tolerance)
@register_class
class SLICENODE_JoinSlices(SliceNodeJoiner):
bl_label = "Boolean Paths"
socket_type = "SLICESOCK_Paths"
operation: NfEnumProperty(
name="Operation",
items=[
('UNION', "Union", "Union of the slices"),
('INTERSECTION', "Intersection", "Intersection of the slices"),
('DIFFERENCE', "Subtract", "Clipping of the first slice"),
('XOR', "Exclusive or", "Exclusive or of the slices"),
],
default='UNION'
) # pyright: ignore[reportInvalidTypeForm]
connect_along_boundary: NfBoolProperty(
name="Connect along boundary",
description="Connect clipped segments along the boundary of the clipping polygon",
default=False
) # pyright: ignore[reportInvalidTypeForm]
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "operation", text="")
if self.operation in ['INTERSECTION', 'DIFFERENCE']:
layout.prop(self, "connect_along_boundary")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
from ....infra.blender.blender_slice_nodes_generic import resolve_input
accumulated: SlicePaths = SlicePaths()
current: SlicePaths | None = SlicePaths()
for input in self.inputs:
current = resolve_input(input)
if not current:
continue
if not accumulated:
accumulated = current
continue
# Boolean operation loses specific per-segment grouping as it merges polygons.
# We return a new SlicePaths from the result.
accumulated = accumulated.boolean(current, self.operation, connect_along_boundary=self.connect_along_boundary)
pass
return accumulated
@register_class
class SLICENODE_JoinPaths(SliceNodeJoiner):
bl_label = "Join Paths"
socket_type = "SLICESOCK_Paths"
mode: NfEnumProperty(
name="Mode",
items=[
('EXTEND', "Extend", "Append paths sequentially"),
('ZIP', "Zip", "Interleave paths from all inputs"),
],
default='EXTEND'
) # pyright: ignore[reportInvalidTypeForm]
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "mode", text="")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
from ....infra.blender.blender_slice_nodes_generic import resolve_input
inputs_data: list[SlicePaths] = []
for input in self.inputs:
paths: SlicePaths = resolve_input(input)
if paths:
# paths.normalize_groups()
inputs_data.append(paths)
if not inputs_data:
return SlicePaths()
if self.mode == 'EXTEND':
merged = SlicePaths()
for paths in inputs_data:
merged.extend_with_groups(paths)
return merged
elif self.mode == 'ZIP':
# Group-based Zip: sequentially add each group from all inputs.
# E.g. Group 1 (In1), Group 1 (In2), Group 2 (In1), Group 2 (In2)...
all_groups = set()
grouped_inputs = []
for p_list in inputs_data:
d = {}
for i, g in enumerate(p_list.groups):
all_groups.add(g)
d.setdefault(g, []).append(p_list[i])
grouped_inputs.append(d)
sorted_groups = sorted(all_groups)
merged = SlicePaths()
for g in sorted_groups:
for d in grouped_inputs:
group_paths = d.get(g, [])
for p in group_paths:
merged.append_with_group(p, g)
return merged
return SlicePaths()
@register_class
class SLICENODE_SimplifyPaths(SliceNode):
bl_label = "Simplify Paths"
preserve_topology: NfBoolProperty( # pyright: ignore[reportInvalidTypeForm]
name="Preserve Topology",
description="Prevents swapping of contained/containing geometry during simplification",
default=True
)
def init(self, context):
self.add_input("SLICESOCK_Paths")
self.add_input("SLICESOCK_Float", "Tolerance").value = 0.1
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "preserve_topology")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Paths":
return SlicePaths()
paths: SlicePaths = self.resolve("Paths")
tolerance: float = self.resolve("Tolerance") or 0.0
if not paths or tolerance <= 0:
return paths
from shapely.geometry import LineString
import numpy as np
simplified = []
for p in paths:
if len(p) < 3:
simplified.append(p)
continue
line = LineString(p)
simplified_line = line.simplify(tolerance, preserve_topology=self.preserve_topology)
if simplified_line.geom_type == 'LineString' and len(simplified_line.coords) >= 2:
simplified.append(np.array(simplified_line.coords, dtype=p.dtype))
else:
simplified.append(p)
return paths.copy_with(simplified)