diff --git a/__init__.py b/__init__.py index bce2c52..af254e8 100644 --- a/__init__.py +++ b/__init__.py @@ -1,6 +1,6 @@ from __future__ import annotations from numpy._typing._array_like import NDArray -from numpy import float64 +from numpy import float64, ndarray bl_info = { "name": "NodeSlicer", @@ -13,7 +13,6 @@ bl_info = { } from math import floor -from itertools import zip_longest from typing import TYPE_CHECKING, Any import bpy import os @@ -25,7 +24,7 @@ from bpy.props import PointerProperty, StringProperty, FloatProperty, IntPropert from .registry import register_class from .core import slice -from .core.slice import Pather, slice_at_range +from .core.slice import Pather, slice_at_range, SlicePaths from .infra.blender_mesh_capture import SlicingObject, capture_mesh_edges from .infra.blender_slice_nodes_generic import SliceNode, SliceNodeSocket, resolve_input @@ -44,6 +43,8 @@ class SliceIntrinsics: self.feedrate = feedrate self.extrusion_rate = extrusion_rate + + # ----------------------------------------------------------------------------- # Node tree # ----------------------------------------------------------------------------- @@ -125,11 +126,15 @@ class SLICESOCK_String(InputNodeSocket): color = (0.4, 0.7, 1.0, 1.0) @register_class -class SLICESOCK_Vector(InputNodeSocket): - bl_label = "Vector" +class SLICESOCK_VectorRotation_Common(InputNodeSocket): + bl_label = "VectorRotation" value: bpy.props.FloatVectorProperty(name="Vector", size=3, default=(0.0, 0.0, 0.0), subtype='EULER') # pyright: ignore[reportInvalidTypeForm] color = (0.39, 0.39, 0.78, 1.0) +@register_class +class SLICESOCK_VectorRotation(SLICESOCK_VectorRotation_Common): ... +@register_class +class SLICESOCK_VectorRotationRo(ReadonlySocket, SLICESOCK_VectorRotation_Common): ... @register_class class SLICESOCK_Mesh(LabelNodeSocket): @@ -137,16 +142,10 @@ class SLICESOCK_Mesh(LabelNodeSocket): default = None color = (0, 214/255, 163/255, 1.0) -@register_class -class SLICESOCK_Slices(LabelNodeSocket): - bl_label = "Slices" - default = [] - color = (0, 214/255, 96/255, 1.0) - @register_class class SLICESOCK_Paths(LabelNodeSocket): bl_label = "Paths" - default = [] + default = SlicePaths() color = (214/255, 0, 120/255, 1.0) @register_class @@ -192,6 +191,10 @@ class SLICENODE_InputFloat(InputNode): class SLICENODE_InputInt(InputNode): input_type = "Int" +@register_class +class SLICENODE_InputVectorRotation(InputNode): + input_type = "VectorRotation" + @register_class class SLICENODE_InputObject(InputNode): input_type = "Object" @@ -312,12 +315,12 @@ class SLICENODE_GetPaths(SliceNode): self.add_input("SLICESOCK_Object") self.add_output("SLICESOCK_Paths") - def evaluate_output(self, socket_name: str, **_kwargs) -> list[np.ndarray]: + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: obj: Any = resolve_input(self.inputs.get("Object")) - so: list[NDArray[float64]] = capture_mesh_edges(obj) + so: list[np.ndarray] = capture_mesh_edges(obj) - return so + return SlicePaths(so) @register_class class SLICENODE_SliceShells(SliceNode): @@ -326,15 +329,15 @@ class SLICENODE_SliceShells(SliceNode): def init(self, context): self.add_input("SLICESOCK_Mesh") self.add_input("SLICESOCK_Float", "Layer Height").value = 0.2 - self.add_output("SLICESOCK_Slices") + self.add_output("SLICESOCK_Paths") - def evaluate_output(self, socket_name: str, **_kwargs) -> list[np.ndarray]: - if socket_name != "Slices": return [] + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + if socket_name != "Paths": return SlicePaths() slicingmesh: SlicingObject | None = resolve_input(self.inputs.get("Mesh")) layer_height: float = resolve_input(self.inputs.get("Layer Height")) - if not slicingmesh: return [] + if not slicingmesh: return SlicePaths() min = slicingmesh.mesh[:,0:3,2:3].min() min = floor(min / layer_height) * layer_height @@ -346,7 +349,7 @@ class SLICENODE_SliceShells(SliceNode): assert slices_group slices = slices_group[0] - return slices + return SlicePaths(slices) @register_class class SLICENODE_BufferShells(SliceNode): @@ -362,54 +365,86 @@ class SLICENODE_BufferShells(SliceNode): ) # pyright: ignore[reportInvalidTypeForm] def init(self, context): - self.add_input("SLICESOCK_Slices") + self.add_input("SLICESOCK_Paths") self.add_input("SLICESOCK_Float", "Distance").value = 0 - self.add_input("SLICESOCK_Vector", "Rotation") - self.add_output("SLICESOCK_Slices") + 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) -> list[np.ndarray]: - if socket_name != "Slices": return [] + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + if socket_name != "Paths": return SlicePaths() - slices: list[np.ndarray] = resolve_input(self.inputs.get("Slices")) + paths: SlicePaths = resolve_input(self.inputs.get("Paths")) distance: float = resolve_input(self.inputs.get("Distance")) or 0.0 rotation: Any = resolve_input(self.inputs.get("Rotation")) or (0.0, 0.0, 0.0) - if not slices: return [] + if not paths: return SlicePaths() if any(rotation): # De-rotate shells to Z-perpendicular plane mat = Euler(rotation).to_matrix().inverted() mat_np = np.array(mat) - de_rotated_slices = [s @ mat_np.T for s in slices] + prepared_paths = [s @ mat_np.T for s in paths] + else: + prepared_paths = paths - # Buffer - buffered_slices = slice.buffer_slice(de_rotated_slices, distance, miter=self.shape_option) + # Buffer + res_paths, res_groups = slice.buffer_slice(prepared_paths, distance, miter=self.shape_option) + if any(rotation): # Re-rotate shells back mat_inv = Euler(rotation).to_matrix() mat_inv_np = np.array(mat_inv) - re_rotated_slices = [s @ mat_inv_np.T for s in buffered_slices] - return re_rotated_slices + final_paths = [s @ mat_inv_np.T for s in res_paths] + else: + final_paths = res_paths - buffered_slices = slice.buffer_slice(slices, distance, miter=self.shape_option) - - return buffered_slices + return SlicePaths(final_paths, res_groups) @register_class -class SLICENODE_SlicesToPaths(SliceNode): - bl_label = "Slices to Paths" +class SLICENODE_Infill(SliceNode): + bl_label = "Infill" def init(self, context): - self.add_input("SLICESOCK_Slices") + 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 evaluate_output(self, socket_name: str, **_kwargs) -> list[np.ndarray]: - slices: list[np.ndarray] = resolve_input(self.inputs.get("Slices")) - return slices + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + if socket_name != "Paths": return SlicePaths() + + paths: SlicePaths = resolve_input(self.inputs.get("Paths")) + density: float = resolve_input(self.inputs.get("Density")) or 2.0 + infill_rotation: float = resolve_input(self.inputs.get("Infill Rotation")) or 0.0 + rotation: Any = resolve_input(self.inputs.get("Layer Rotation")) or (0.0, 0.0, 0.0) + + if not paths: return SlicePaths() + + p: SlicePaths + if any(rotation): + # De-rotate shells to Z-perpendicular plane + mat = Euler(rotation).to_matrix().inverted() + mat_np = np.array(mat) + p = SlicePaths([s @ mat_np.T for s in paths], paths.groups) + else: + p = paths + + res_paths, res_groups = slice.infill_slice(p, density, rotation_deg=infill_rotation) + + if any(rotation): + # Re-rotate shells back + mat_inv = Euler(rotation).to_matrix() + mat_inv_np = np.array(mat_inv) + p = SlicePaths([s @ mat_inv_np.T for s in res_paths], res_groups) + else: + p = SlicePaths(res_paths, res_groups) + + return p @register_class class SLICENODE_PathsToGcode(SliceNode): @@ -494,7 +529,7 @@ class SLICENODE_JoinGcode(SliceNodeJoiner): @register_class class SLICENODE_JoinSlices(SliceNodeJoiner): bl_label = "Join Slices" - socket_type = "SLICESOCK_Slices" + socket_type = "SLICESOCK_Paths" operation: bpy.props.EnumProperty( name="Operation", @@ -511,22 +546,25 @@ class SLICENODE_JoinSlices(SliceNodeJoiner): assert layout layout.prop(self, "operation", text="") - def evaluate_output(self, socket_name: str, **_kwargs) -> list[np.ndarray]: - accumulated: list[np.ndarray] = [] - current: list[np.ndarray] = [] + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + accumulated: SlicePaths = SlicePaths() + current: SlicePaths = SlicePaths() for input in self.inputs: - current =resolve_input(input) + current = resolve_input(input) if not current: continue if not accumulated: accumulated = current - current = [] + current = SlicePaths() continue - accumulated = slice.boolean_slice(accumulated, current, self.operation) - current = [] + # Boolean operation loses specific per-segment grouping as it merges polygons. + # We return a new SlicePaths from the result. + res = slice.boolean_slice(accumulated, current, self.operation) + accumulated = SlicePaths(res) + current = SlicePaths() return accumulated @@ -563,31 +601,45 @@ class SLICENODE_JoinPaths(SliceNodeJoiner): assert layout layout.prop(self, "mode", text="") - def evaluate_output(self, socket_name: str, **_kwargs) -> list[np.ndarray]: - inputs_data: list[list[np.ndarray]] = [] + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + inputs_data: list[SlicePaths] = [] for input in self.inputs: paths = resolve_input(input) if paths: inputs_data.append(paths) if not inputs_data: - return [] + return SlicePaths() if self.mode == 'EXTEND': - merged_paths = [] + merged = SlicePaths() for paths in inputs_data: - merged_paths.extend(paths) - return merged_paths + merged.extend_with_groups(paths) + return merged elif self.mode == 'ZIP': - merged_paths = [] - for group in zip_longest(*inputs_data): - for path in group: - if path is not None: - merged_paths.append(path) - return merged_paths + # 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(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 merged - return [] + return SlicePaths() ### I/O @@ -778,6 +830,7 @@ class SLICENODE_MT_Menu(Menu): layout.operator("node.add_node", text="String").type = "SLICENODE_InputString" layout.operator("node.add_node", text="Float").type = "SLICENODE_InputFloat" layout.operator("node.add_node", text="Integer").type = "SLICENODE_InputInt" + layout.operator("node.add_node", text="Rotation").type = "SLICENODE_InputVectorRotation" layout.operator("node.add_node", text="Integer Math").type = "SLICENODE_IntMath" layout.operator("node.add_node", text="Object").type = "SLICENODE_InputObject" layout.operator("node.add_node", text="Printer Intrinsics").type = "SLICENODE_InputIntrinsics" @@ -787,7 +840,7 @@ class SLICENODE_MT_Menu(Menu): layout.separator() layout.operator("node.add_node", text="Slice Shells").type = "SLICENODE_SliceShells" layout.operator("node.add_node", text="Buffer Shells").type = "SLICENODE_BufferShells" - layout.operator("node.add_node", text="Slices to Paths").type = "SLICENODE_SlicesToPaths" + layout.operator("node.add_node", text="Infill").type = "SLICENODE_Infill" layout.operator("node.add_node", text="Paths to Gcode").type = "SLICENODE_PathsToGcode" layout.separator() layout.operator("node.add_node", text="Gcode Block").type = "SLICENODE_GcodeBlock" @@ -796,7 +849,7 @@ class SLICENODE_MT_Menu(Menu): layout.operator("node.add_node", text="Join Gcode").type = "SLICENODE_JoinGcode" layout.operator("node.add_node", text="Join Mesh").type = "SLICENODE_JoinMesh" layout.operator("node.add_node", text="Join Paths").type = "SLICENODE_JoinPaths" - layout.operator("node.add_node", text="Join Slices").type = "SLICENODE_JoinSlices" + layout.operator("node.add_node", text="Boolean Paths").type = "SLICENODE_JoinSlices" layout.separator() layout.operator("node.add_node", text="Gcode to File").type = "SLICENODE_GcodeToFile" layout.operator("node.add_node", text="Gcode to Mesh").type = "SLICENODE_GcodeToMesh" diff --git a/core/slice.py b/core/slice.py index 8c18476..9f4f943 100644 --- a/core/slice.py +++ b/core/slice.py @@ -1,11 +1,33 @@ +from __future__ import annotations from numpy import ndarray -from typing import Any import numpy as np import pyclipper # Scaling factor for pyclipper (it works with integers) SCALE = 1_000_000 +class SlicePaths(list[np.ndarray]): + groups: list[int] + + def __init__(self, paths: list[np.ndarray] = [], groups: list[int] = []): + super().__init__(paths) + + self.groups = [] + if groups: self.groups = list(groups) + else: self.groups = list(range(0,len(paths))) + + def extend_with_groups(self, other: SlicePaths | list[np.ndarray]): + if isinstance(other, SlicePaths): + self.extend(other) + self.groups.extend(other.groups) + else: + self.extend(other) + self.groups.extend(list(range(0,len(other)))) + + def append_with_group(self, path: np.ndarray, group: int = 0): + self.append(path) + self.groups.append(group) + def slice_at_range(models, z_values) -> list[list[np.ndarray]]: if not models: return [] @@ -146,7 +168,7 @@ def segments_to_slice(segments: np.ndarray) -> list[np.ndarray]: linked = link_segments(segments) if not linked: return [] - pc = pyclipper.Pyclipper() + pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue] for p in linked: # Pyclipper expects open paths (last point != first point) for ET_CLOSEDPOLYGON path_pts = p[:, :2] @@ -154,10 +176,10 @@ def segments_to_slice(segments: np.ndarray) -> list[np.ndarray]: path_pts = path_pts[:-1] scaled = (path_pts * SCALE).astype(np.int64).tolist() - pc.AddPath(scaled, pyclipper.PT_SUBJECT, closed = True) + pc.AddPath(scaled, pyclipper.PT_SUBJECT, closed = True) # pyright: ignore[reportAttributeAccessIssue] # Use EVENODD for robust hole detection. - solution = pc.Execute(pyclipper.CT_UNION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) + solution = pc.Execute(pyclipper.CT_UNION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue] z = segments[0, 0, 2] res = [] @@ -205,28 +227,28 @@ def boolean_slice(subject: list[np.ndarray], object: list[np.ndarray], operation z = subject_slices[0][0, 2] - pc = pyclipper.Pyclipper() + pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue] # Subject for s in subject_slices: path_pts = s[:, :2] scaled = (path_pts * SCALE).astype(np.int64).tolist() - pc.AddPath(scaled, pyclipper.PT_SUBJECT, True) + pc.AddPath(scaled, pyclipper.PT_SUBJECT, True) # pyright: ignore[reportAttributeAccessIssue] # Object for s in object_slices: path_pts = s[:, :2] scaled = (path_pts * SCALE).astype(np.int64).tolist() - pc.AddPath(scaled, pyclipper.PT_CLIP, True) + pc.AddPath(scaled, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue] # Determine operation type. For now only UNION is supported. - op_type = pyclipper.CT_UNION - if operation == 'UNION': op_type = pyclipper.CT_UNION - elif operation == 'INTERSECTION': op_type = pyclipper.CT_INTERSECTION - elif operation == 'DIFFERENCE': op_type = pyclipper.CT_DIFFERENCE - elif operation == 'XOR': op_type = pyclipper.CT_XOR + op_type = pyclipper.CT_UNION # pyright: ignore[reportAttributeAccessIssue] + if operation == 'UNION': op_type = pyclipper.CT_UNION # pyright: ignore[reportAttributeAccessIssue] + elif operation == 'INTERSECTION': op_type = pyclipper.CT_INTERSECTION # pyright: ignore[reportAttributeAccessIssue] + elif operation == 'DIFFERENCE': op_type = pyclipper.CT_DIFFERENCE # pyright: ignore[reportAttributeAccessIssue] + elif operation == 'XOR': op_type = pyclipper.CT_XOR # pyright: ignore[reportAttributeAccessIssue] - solution = pc.Execute(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) + solution = pc.Execute(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue] for path in solution: if not path: continue @@ -237,9 +259,9 @@ def boolean_slice(subject: list[np.ndarray], object: list[np.ndarray], operation return results -def buffer_slice(slice_paths: list[np.ndarray], distance: float, miter='round', tolerance=.01) -> list[np.ndarray]: +def buffer_slice(slice_paths: list[np.ndarray], distance: float, miter='round', tolerance=.01) -> tuple[list[np.ndarray], list[int]]: - if not slice_paths: return [] + if not slice_paths: return [], [] # Group paths by Z height to avoid flattening the entire model into 2D groups: dict[int, list[np.ndarray]] = {} @@ -250,17 +272,19 @@ def buffer_slice(slice_paths: list[np.ndarray], distance: float, miter='round', groups.setdefault(z_key, []).append(p) join_types = { - 'round': pyclipper.JT_ROUND, - 'square': pyclipper.JT_SQUARE, - 'flat': pyclipper.JT_SQUARE, - 'miter': pyclipper.JT_MITER + 'round': pyclipper.JT_ROUND, # pyright: ignore[reportAttributeAccessIssue] + 'square': pyclipper.JT_SQUARE, # pyright: ignore[reportAttributeAccessIssue] + 'flat': pyclipper.JT_SQUARE, # pyright: ignore[reportAttributeAccessIssue] + 'miter': pyclipper.JT_MITER # pyright: ignore[reportAttributeAccessIssue] } - jt = join_types.get(miter, pyclipper.JT_ROUND) + jt = join_types.get(miter, pyclipper.JT_ROUND) # pyright: ignore[reportAttributeAccessIssue] - res = [] - pco = pyclipper.PyclipperOffset(arc_tolerance=tolerance * SCALE) + paths_list: list[np.ndarray] = [] + groups_list: list[int] = [] + pco = pyclipper.PyclipperOffset(arc_tolerance=tolerance * SCALE) # pyright: ignore[reportAttributeAccessIssue] # Buffer each layer independently + group_id = 0 for z_key in sorted(groups.keys()): layer_paths = groups[z_key] z = layer_paths[0][0, 2] @@ -279,7 +303,7 @@ def buffer_slice(slice_paths: list[np.ndarray], distance: float, miter='round', if not scaled_paths: continue # Add all paths for this specific Z layer - pco.AddPaths(scaled_paths, jt, pyclipper.ET_CLOSEDPOLYGON) + pco.AddPaths(scaled_paths, jt, pyclipper.ET_CLOSEDPOLYGON) # pyright: ignore[reportAttributeAccessIssue] solution = pco.Execute(distance * SCALE) for path in solution: @@ -288,9 +312,113 @@ def buffer_slice(slice_paths: list[np.ndarray], distance: float, miter='round', fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)]) # Re-close loop fpath_3d = np.vstack([fpath_3d, fpath_3d[0]]) - res.append(fpath_3d) + paths_list.append(fpath_3d) + groups_list.append(group_id) + + group_id += 1 - return res + return paths_list, groups_list + +def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float = 0.0) -> tuple[list[np.ndarray], list[int]]: + """Generates a grid infill clipped by the provided paths. Consistent across layers.""" + if not slice_paths or density <= 0: return [], [] + + rotation = np.radians(rotation_deg) + + # Calculate global bounding box for the entire model to ensure grid alignment + all_pts_model = np.concatenate(slice_paths) + g_min_x, g_min_y = all_pts_model[:, 0].min(), all_pts_model[:, 1].min() + g_max_x, g_max_y = all_pts_model[:, 0].max(), all_pts_model[:, 1].max() + global_center = np.array([(g_min_x + g_max_x) / 2, (g_min_y + g_max_y) / 2, 0]) + + # Pre-calculate rotation matrices + c_inv, s_inv = np.cos(-rotation), np.sin(-rotation) + rot_mat_inv = np.array([[c_inv, -s_inv, 0], [s_inv, c_inv, 0], [0, 0, 1]]) + + c, s = np.cos(rotation), np.sin(rotation) + rot_mat = np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]]) + + # Determine grid bounds in the rotated space to ensure full coverage + # We rotate the corners of the global bounding box to find the new required extent + corners = np.array([ + [g_min_x, g_min_y, 0], [g_max_x, g_min_y, 0], + [g_min_x, g_max_y, 0], [g_max_x, g_max_y, 0] + ]) + rotated_corners = (corners - global_center) @ rot_mat_inv.T + global_center + rg_min_x, rg_min_y = rotated_corners[:, 0].min(), rotated_corners[:, 1].min() + rg_max_x, rg_max_y = rotated_corners[:, 0].max(), rotated_corners[:, 1].max() + + # Generate the global static grid lines in de-rotated space + # Add a small buffer to ensure we don't miss edges + rg_min_x -= density + rg_max_x += density + rg_min_y -= density + rg_max_y += density + + scaled_grid_paths = [] + + # Vertical lines (constant X, varies Y) + x = rg_min_x + while x <= rg_max_x: + scaled_grid_paths.append([ + (int(x * SCALE), int(rg_min_y * SCALE)), + (int(x * SCALE), int(rg_max_y * SCALE)) + ]) + x += density + + # Horizontal lines (constant Y, varies X) + y = rg_min_y + while y <= rg_max_y: + scaled_grid_paths.append([ + (int(rg_min_x * SCALE), int(y * SCALE)), + (int(rg_max_x * SCALE), int(y * SCALE)) + ]) + y += density + + # Group by Z + groups: dict[int, list[np.ndarray]] = {} + for p, g in zip(slice_paths, slice_paths.groups): + groups.setdefault(g, []).append(p) + + output_paths: list[np.ndarray] = [] + output_groups: list[int] = [] + + for g in groups.keys(): + + layer_paths = groups[g] + z = layer_paths[0][0, 2] + + # De-rotate layer paths + offset_2d = np.array([global_center[0], global_center[1], 0]) + active_paths = [(p - offset_2d) @ rot_mat_inv.T + offset_2d for p in layer_paths] + + # Clip lines with active_paths + pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue] + + for p in active_paths: + path_pts = p[:, :2] + if len(path_pts) > 2 and np.allclose(path_pts[0], path_pts[-1]): + path_pts = path_pts[:-1] + if len(path_pts) < 3: continue + scaled = (path_pts * SCALE).astype(np.int64).tolist() + pc.AddPath(scaled, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue] + + if scaled_grid_paths: + pc.AddPaths(scaled_grid_paths, pyclipper.PT_SUBJECT, False) # pyright: ignore[reportAttributeAccessIssue] + + solution = pc.Execute2(pyclipper.CT_INTERSECTION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue] + clipped_lines = pyclipper.OpenPathsFromPolyTree(solution) # pyright: ignore[reportAttributeAccessIssue] + + # Rotate back + for path in clipped_lines: + if not path: continue + fpath = np.array(path) / SCALE + fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)]) + rotated_fpath_3d = (fpath_3d - offset_2d) @ rot_mat.T + offset_2d + output_paths.append(rotated_fpath_3d) + output_groups.append(g) + + return output_paths, output_groups def group_segments_by_z(segments: np.ndarray) -> dict[int, np.ndarray]: z_values: np.ndarray = segments[:, 0, 2] diff --git a/doc/NODES.md b/doc/NODES.md index 7e2a109..07ec03a 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -9,8 +9,7 @@ | `SLICESOCK_Int` | `int` | Python int | Integer value | | `SLICESOCK_String` | `str` | Python string | Text/Path value | | `SLICESOCK_Mesh` | `SlicingObject` | NumPy NDArray (Triangles) | Internal mesh data (Transformed/Scaled) | -| `SLICESOCK_Slices` | `list[np.ndarray]` | List of closed paths (N, 3) | Closed manifold contours (Inside/Outside defined) | -| `SLICESOCK_Paths` | `list[np.ndarray]` | List of paths (N, 3) | Generic toolpaths (No defined interior) | +| `SLICESOCK_Paths` | `list[np.ndarray]` | List of paths (N, 3) | Generic toolpaths or slice boundaries | | `SLICESOCK_Gcode` | `str` | Multi-line string | Generated G-code text | ## Detailed Data Types @@ -23,18 +22,12 @@ The `SlicingObject` class wraps a high-performance NumPy representation of a Ble - `[i, 3, :]`: The face normal of the $i$-th triangle. - **Coordinates**: Transformed to world space and scaled by the Blender scene unit scale (multiplied by 1000 for mm-standard G-code). -### Slices (`SLICESOCK_Slices`) -A `list[np.ndarray]` (each `(N, 3)`) containing the results of the slicing operation. -- **Content**: Guaranteed to be closed loops forming a valid manifold boundary. +### Paths (`SLICESOCK_Paths`) +A `list[np.ndarray]` (each `(N, 3)`) containing toolpaths or slice results. +- **Content**: Can be open or closed segments. When generated from `Slice Shells`, they are guaranteed to be closed loops forming valid manifold boundaries. - **Z-Awareness**: Coordinates are stored as 3D `(X, Y, Z)` to maintain the correct layer height. - **Topology**: Generated and cleaned using `pyclipper`. -### Paths (`SLICESOCK_Paths`) -A `list[np.ndarray]` (each `(N, 3)`) containing generic toolpaths. -- **Content**: Can be open or closed segments. -- **Z-Awareness**: Coordinates are stored as 3D `(X, Y, Z)`. -- **Source**: Typically generated from `Slices` boundaries via the `Slices to Paths` node. - ### Gcode (`SLICESOCK_Gcode`) A standard Python string containing formatted G-code. - **Format**: `G0` for travels, `G1` for extrusions. @@ -51,9 +44,9 @@ A standard Python string containing formatted G-code. ### Processing Nodes - `SLICENODE_GetMesh`: Converts a Blender Object into internal `SlicingObject` format. -- `SLICENODE_SliceShells`: Slices a `SlicingObject` into 2D `Slices` at a given layer height. -- `SLICENODE_BufferShells`: Offsets/buffers 2D `Slices` using a "Distance" parameter. Buffers each Z-layer independently to maintain 3D structure and uses winding rules to correctly handle nested holes. -- `SLICENODE_SlicesToPaths`: Extracts the boundaries of `Slices` to create `Paths`. +- `SLICENODE_SliceShells`: Slices a `SlicingObject` into 2D `Paths` at a given layer height. +- `SLICENODE_BufferShells`: Offsets/buffers 2D `Paths` using a "Distance" parameter. Buffers each Z-layer independently to maintain 3D structure and uses winding rules to correctly handle nested holes. +- `SLICENODE_GridInfill`: Generates a grid of lines clipped by boundary paths. - `SLICENODE_PathsToGcode`: Converts `Paths` into G-code blocks. ### Joiner Nodes (Variable Inputs) diff --git a/nodes.blend b/nodes.blend new file mode 100644 index 0000000..5f0ed8e Binary files /dev/null and b/nodes.blend differ