inclined infill + gn preview

This commit is contained in:
Nicolas Predella 2026-05-06 20:55:56 +03:00
parent 77bab165af
commit 36e11cdd49
4 changed files with 277 additions and 103 deletions

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from numpy._typing._array_like import NDArray from numpy._typing._array_like import NDArray
from numpy import float64 from numpy import float64, ndarray
bl_info = { bl_info = {
"name": "NodeSlicer", "name": "NodeSlicer",
@ -13,7 +13,6 @@ bl_info = {
} }
from math import floor from math import floor
from itertools import zip_longest
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import bpy import bpy
import os import os
@ -25,7 +24,7 @@ from bpy.props import PointerProperty, StringProperty, FloatProperty, IntPropert
from .registry import register_class from .registry import register_class
from .core import slice 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_mesh_capture import SlicingObject, capture_mesh_edges
from .infra.blender_slice_nodes_generic import SliceNode, SliceNodeSocket, resolve_input from .infra.blender_slice_nodes_generic import SliceNode, SliceNodeSocket, resolve_input
@ -44,6 +43,8 @@ class SliceIntrinsics:
self.feedrate = feedrate self.feedrate = feedrate
self.extrusion_rate = extrusion_rate self.extrusion_rate = extrusion_rate
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Node tree # Node tree
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@ -125,11 +126,15 @@ class SLICESOCK_String(InputNodeSocket):
color = (0.4, 0.7, 1.0, 1.0) color = (0.4, 0.7, 1.0, 1.0)
@register_class @register_class
class SLICESOCK_Vector(InputNodeSocket): class SLICESOCK_VectorRotation_Common(InputNodeSocket):
bl_label = "Vector" bl_label = "VectorRotation"
value: bpy.props.FloatVectorProperty(name="Vector", size=3, default=(0.0, 0.0, 0.0), subtype='EULER') # pyright: ignore[reportInvalidTypeForm] 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) 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 @register_class
class SLICESOCK_Mesh(LabelNodeSocket): class SLICESOCK_Mesh(LabelNodeSocket):
@ -137,16 +142,10 @@ class SLICESOCK_Mesh(LabelNodeSocket):
default = None default = None
color = (0, 214/255, 163/255, 1.0) 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 @register_class
class SLICESOCK_Paths(LabelNodeSocket): class SLICESOCK_Paths(LabelNodeSocket):
bl_label = "Paths" bl_label = "Paths"
default = [] default = SlicePaths()
color = (214/255, 0, 120/255, 1.0) color = (214/255, 0, 120/255, 1.0)
@register_class @register_class
@ -192,6 +191,10 @@ class SLICENODE_InputFloat(InputNode):
class SLICENODE_InputInt(InputNode): class SLICENODE_InputInt(InputNode):
input_type = "Int" input_type = "Int"
@register_class
class SLICENODE_InputVectorRotation(InputNode):
input_type = "VectorRotation"
@register_class @register_class
class SLICENODE_InputObject(InputNode): class SLICENODE_InputObject(InputNode):
input_type = "Object" input_type = "Object"
@ -312,12 +315,12 @@ class SLICENODE_GetPaths(SliceNode):
self.add_input("SLICESOCK_Object") self.add_input("SLICESOCK_Object")
self.add_output("SLICESOCK_Paths") 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")) 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 @register_class
class SLICENODE_SliceShells(SliceNode): class SLICENODE_SliceShells(SliceNode):
@ -326,15 +329,15 @@ class SLICENODE_SliceShells(SliceNode):
def init(self, context): def init(self, context):
self.add_input("SLICESOCK_Mesh") self.add_input("SLICESOCK_Mesh")
self.add_input("SLICESOCK_Float", "Layer Height").value = 0.2 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]: def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Slices": return [] if socket_name != "Paths": return SlicePaths()
slicingmesh: SlicingObject | None = resolve_input(self.inputs.get("Mesh")) slicingmesh: SlicingObject | None = resolve_input(self.inputs.get("Mesh"))
layer_height: float = resolve_input(self.inputs.get("Layer Height")) 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 = slicingmesh.mesh[:,0:3,2:3].min()
min = floor(min / layer_height) * layer_height min = floor(min / layer_height) * layer_height
@ -346,7 +349,7 @@ class SLICENODE_SliceShells(SliceNode):
assert slices_group assert slices_group
slices = slices_group[0] slices = slices_group[0]
return slices return SlicePaths(slices)
@register_class @register_class
class SLICENODE_BufferShells(SliceNode): class SLICENODE_BufferShells(SliceNode):
@ -362,54 +365,86 @@ class SLICENODE_BufferShells(SliceNode):
) # pyright: ignore[reportInvalidTypeForm] ) # pyright: ignore[reportInvalidTypeForm]
def init(self, context): 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_Float", "Distance").value = 0
self.add_input("SLICESOCK_Vector", "Rotation") self.add_input("SLICESOCK_VectorRotation", "Rotation")
self.add_output("SLICESOCK_Slices") self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout): def draw_buttons(self, context, layout):
assert layout assert layout
layout.prop(self, "shape_option") layout.prop(self, "shape_option")
def evaluate_output(self, socket_name: str, **_kwargs) -> list[np.ndarray]: def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Slices": return [] 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 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) 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): if any(rotation):
# De-rotate shells to Z-perpendicular plane # De-rotate shells to Z-perpendicular plane
mat = Euler(rotation).to_matrix().inverted() mat = Euler(rotation).to_matrix().inverted()
mat_np = np.array(mat) 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 # Buffer
buffered_slices = slice.buffer_slice(de_rotated_slices, distance, miter=self.shape_option) res_paths, res_groups = slice.buffer_slice(prepared_paths, distance, miter=self.shape_option)
if any(rotation):
# Re-rotate shells back # Re-rotate shells back
mat_inv = Euler(rotation).to_matrix() mat_inv = Euler(rotation).to_matrix()
mat_inv_np = np.array(mat_inv) mat_inv_np = np.array(mat_inv)
re_rotated_slices = [s @ mat_inv_np.T for s in buffered_slices] final_paths = [s @ mat_inv_np.T for s in res_paths]
return re_rotated_slices else:
final_paths = res_paths
buffered_slices = slice.buffer_slice(slices, distance, miter=self.shape_option) return SlicePaths(final_paths, res_groups)
return buffered_slices
@register_class @register_class
class SLICENODE_SlicesToPaths(SliceNode): class SLICENODE_Infill(SliceNode):
bl_label = "Slices to Paths" bl_label = "Infill"
def init(self, context): 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") 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:
slices: list[np.ndarray] = resolve_input(self.inputs.get("Slices")) if socket_name != "Paths": return SlicePaths()
return slices
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 @register_class
class SLICENODE_PathsToGcode(SliceNode): class SLICENODE_PathsToGcode(SliceNode):
@ -494,7 +529,7 @@ class SLICENODE_JoinGcode(SliceNodeJoiner):
@register_class @register_class
class SLICENODE_JoinSlices(SliceNodeJoiner): class SLICENODE_JoinSlices(SliceNodeJoiner):
bl_label = "Join Slices" bl_label = "Join Slices"
socket_type = "SLICESOCK_Slices" socket_type = "SLICESOCK_Paths"
operation: bpy.props.EnumProperty( operation: bpy.props.EnumProperty(
name="Operation", name="Operation",
@ -511,9 +546,9 @@ class SLICENODE_JoinSlices(SliceNodeJoiner):
assert layout assert layout
layout.prop(self, "operation", text="") layout.prop(self, "operation", text="")
def evaluate_output(self, socket_name: str, **_kwargs) -> list[np.ndarray]: def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
accumulated: list[np.ndarray] = [] accumulated: SlicePaths = SlicePaths()
current: list[np.ndarray] = [] current: SlicePaths = SlicePaths()
for input in self.inputs: for input in self.inputs:
current = resolve_input(input) current = resolve_input(input)
@ -522,11 +557,14 @@ class SLICENODE_JoinSlices(SliceNodeJoiner):
if not accumulated: if not accumulated:
accumulated = current accumulated = current
current = [] current = SlicePaths()
continue continue
accumulated = slice.boolean_slice(accumulated, current, self.operation) # Boolean operation loses specific per-segment grouping as it merges polygons.
current = [] # We return a new SlicePaths from the result.
res = slice.boolean_slice(accumulated, current, self.operation)
accumulated = SlicePaths(res)
current = SlicePaths()
return accumulated return accumulated
@ -563,31 +601,45 @@ class SLICENODE_JoinPaths(SliceNodeJoiner):
assert layout assert layout
layout.prop(self, "mode", text="") layout.prop(self, "mode", text="")
def evaluate_output(self, socket_name: str, **_kwargs) -> list[np.ndarray]: def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
inputs_data: list[list[np.ndarray]] = [] inputs_data: list[SlicePaths] = []
for input in self.inputs: for input in self.inputs:
paths = resolve_input(input) paths = resolve_input(input)
if paths: if paths:
inputs_data.append(paths) inputs_data.append(paths)
if not inputs_data: if not inputs_data:
return [] return SlicePaths()
if self.mode == 'EXTEND': if self.mode == 'EXTEND':
merged_paths = [] merged = SlicePaths()
for paths in inputs_data: for paths in inputs_data:
merged_paths.extend(paths) merged.extend_with_groups(paths)
return merged_paths return merged
elif self.mode == 'ZIP': elif self.mode == 'ZIP':
merged_paths = [] # Group-based Zip: sequentially add each group from all inputs.
for group in zip_longest(*inputs_data): # E.g. Group 1 (In1), Group 1 (In2), Group 2 (In1), Group 2 (In2)...
for path in group: all_groups = set()
if path is not None: grouped_inputs = []
merged_paths.append(path) for p_list in inputs_data:
return merged_paths d = {}
for i, g in enumerate(p_list.groups):
all_groups.add(g)
d.setdefault(g, []).append(p_list[i])
grouped_inputs.append(d)
return [] 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 SlicePaths()
### I/O ### 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="String").type = "SLICENODE_InputString"
layout.operator("node.add_node", text="Float").type = "SLICENODE_InputFloat" 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="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="Integer Math").type = "SLICENODE_IntMath"
layout.operator("node.add_node", text="Object").type = "SLICENODE_InputObject" layout.operator("node.add_node", text="Object").type = "SLICENODE_InputObject"
layout.operator("node.add_node", text="Printer Intrinsics").type = "SLICENODE_InputIntrinsics" layout.operator("node.add_node", text="Printer Intrinsics").type = "SLICENODE_InputIntrinsics"
@ -787,7 +840,7 @@ class SLICENODE_MT_Menu(Menu):
layout.separator() layout.separator()
layout.operator("node.add_node", text="Slice Shells").type = "SLICENODE_SliceShells" 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="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.operator("node.add_node", text="Paths to Gcode").type = "SLICENODE_PathsToGcode"
layout.separator() layout.separator()
layout.operator("node.add_node", text="Gcode Block").type = "SLICENODE_GcodeBlock" 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 Gcode").type = "SLICENODE_JoinGcode"
layout.operator("node.add_node", text="Join Mesh").type = "SLICENODE_JoinMesh" 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 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.separator()
layout.operator("node.add_node", text="Gcode to File").type = "SLICENODE_GcodeToFile" layout.operator("node.add_node", text="Gcode to File").type = "SLICENODE_GcodeToFile"
layout.operator("node.add_node", text="Gcode to Mesh").type = "SLICENODE_GcodeToMesh" layout.operator("node.add_node", text="Gcode to Mesh").type = "SLICENODE_GcodeToMesh"

View file

@ -1,11 +1,33 @@
from __future__ import annotations
from numpy import ndarray from numpy import ndarray
from typing import Any
import numpy as np import numpy as np
import pyclipper import pyclipper
# Scaling factor for pyclipper (it works with integers) # Scaling factor for pyclipper (it works with integers)
SCALE = 1_000_000 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]]: def slice_at_range(models, z_values) -> list[list[np.ndarray]]:
if not models: if not models:
return [] return []
@ -146,7 +168,7 @@ def segments_to_slice(segments: np.ndarray) -> list[np.ndarray]:
linked = link_segments(segments) linked = link_segments(segments)
if not linked: return [] if not linked: return []
pc = pyclipper.Pyclipper() pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue]
for p in linked: for p in linked:
# Pyclipper expects open paths (last point != first point) for ET_CLOSEDPOLYGON # Pyclipper expects open paths (last point != first point) for ET_CLOSEDPOLYGON
path_pts = p[:, :2] path_pts = p[:, :2]
@ -154,10 +176,10 @@ def segments_to_slice(segments: np.ndarray) -> list[np.ndarray]:
path_pts = path_pts[:-1] path_pts = path_pts[:-1]
scaled = (path_pts * SCALE).astype(np.int64).tolist() 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. # 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] z = segments[0, 0, 2]
res = [] res = []
@ -205,28 +227,28 @@ def boolean_slice(subject: list[np.ndarray], object: list[np.ndarray], operation
z = subject_slices[0][0, 2] z = subject_slices[0][0, 2]
pc = pyclipper.Pyclipper() pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue]
# Subject # Subject
for s in subject_slices: for s in subject_slices:
path_pts = s[:, :2] path_pts = s[:, :2]
scaled = (path_pts * SCALE).astype(np.int64).tolist() 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 # Object
for s in object_slices: for s in object_slices:
path_pts = s[:, :2] path_pts = s[:, :2]
scaled = (path_pts * SCALE).astype(np.int64).tolist() 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. # Determine operation type. For now only UNION is supported.
op_type = pyclipper.CT_UNION op_type = pyclipper.CT_UNION # pyright: ignore[reportAttributeAccessIssue]
if operation == 'UNION': op_type = pyclipper.CT_UNION if operation == 'UNION': op_type = pyclipper.CT_UNION # pyright: ignore[reportAttributeAccessIssue]
elif operation == 'INTERSECTION': op_type = pyclipper.CT_INTERSECTION elif operation == 'INTERSECTION': op_type = pyclipper.CT_INTERSECTION # pyright: ignore[reportAttributeAccessIssue]
elif operation == 'DIFFERENCE': op_type = pyclipper.CT_DIFFERENCE elif operation == 'DIFFERENCE': op_type = pyclipper.CT_DIFFERENCE # pyright: ignore[reportAttributeAccessIssue]
elif operation == 'XOR': op_type = pyclipper.CT_XOR 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: for path in solution:
if not path: continue if not path: continue
@ -237,9 +259,9 @@ def boolean_slice(subject: list[np.ndarray], object: list[np.ndarray], operation
return results 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 # Group paths by Z height to avoid flattening the entire model into 2D
groups: dict[int, list[np.ndarray]] = {} 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) groups.setdefault(z_key, []).append(p)
join_types = { join_types = {
'round': pyclipper.JT_ROUND, 'round': pyclipper.JT_ROUND, # pyright: ignore[reportAttributeAccessIssue]
'square': pyclipper.JT_SQUARE, 'square': pyclipper.JT_SQUARE, # pyright: ignore[reportAttributeAccessIssue]
'flat': pyclipper.JT_SQUARE, 'flat': pyclipper.JT_SQUARE, # pyright: ignore[reportAttributeAccessIssue]
'miter': pyclipper.JT_MITER '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 = [] paths_list: list[np.ndarray] = []
pco = pyclipper.PyclipperOffset(arc_tolerance=tolerance * SCALE) groups_list: list[int] = []
pco = pyclipper.PyclipperOffset(arc_tolerance=tolerance * SCALE) # pyright: ignore[reportAttributeAccessIssue]
# Buffer each layer independently # Buffer each layer independently
group_id = 0
for z_key in sorted(groups.keys()): for z_key in sorted(groups.keys()):
layer_paths = groups[z_key] layer_paths = groups[z_key]
z = layer_paths[0][0, 2] 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 if not scaled_paths: continue
# Add all paths for this specific Z layer # 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) solution = pco.Execute(distance * SCALE)
for path in solution: 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)]) fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)])
# Re-close loop # Re-close loop
fpath_3d = np.vstack([fpath_3d, fpath_3d[0]]) fpath_3d = np.vstack([fpath_3d, fpath_3d[0]])
res.append(fpath_3d) paths_list.append(fpath_3d)
groups_list.append(group_id)
return res group_id += 1
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]: def group_segments_by_z(segments: np.ndarray) -> dict[int, np.ndarray]:
z_values: np.ndarray = segments[:, 0, 2] z_values: np.ndarray = segments[:, 0, 2]

View file

@ -9,8 +9,7 @@
| `SLICESOCK_Int` | `int` | Python int | Integer value | | `SLICESOCK_Int` | `int` | Python int | Integer value |
| `SLICESOCK_String` | `str` | Python string | Text/Path value | | `SLICESOCK_String` | `str` | Python string | Text/Path value |
| `SLICESOCK_Mesh` | `SlicingObject` | NumPy NDArray (Triangles) | Internal mesh data (Transformed/Scaled) | | `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 or slice boundaries |
| `SLICESOCK_Paths` | `list[np.ndarray]` | List of paths (N, 3) | Generic toolpaths (No defined interior) |
| `SLICESOCK_Gcode` | `str` | Multi-line string | Generated G-code text | | `SLICESOCK_Gcode` | `str` | Multi-line string | Generated G-code text |
## Detailed Data Types ## 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. - `[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). - **Coordinates**: Transformed to world space and scaled by the Blender scene unit scale (multiplied by 1000 for mm-standard G-code).
### Slices (`SLICESOCK_Slices`) ### Paths (`SLICESOCK_Paths`)
A `list[np.ndarray]` (each `(N, 3)`) containing the results of the slicing operation. A `list[np.ndarray]` (each `(N, 3)`) containing toolpaths or slice results.
- **Content**: Guaranteed to be closed loops forming a valid manifold boundary. - **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. - **Z-Awareness**: Coordinates are stored as 3D `(X, Y, Z)` to maintain the correct layer height.
- **Topology**: Generated and cleaned using `pyclipper`. - **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`) ### Gcode (`SLICESOCK_Gcode`)
A standard Python string containing formatted G-code. A standard Python string containing formatted G-code.
- **Format**: `G0` for travels, `G1` for extrusions. - **Format**: `G0` for travels, `G1` for extrusions.
@ -51,9 +44,9 @@ A standard Python string containing formatted G-code.
### Processing Nodes ### Processing Nodes
- `SLICENODE_GetMesh`: Converts a Blender Object into internal `SlicingObject` format. - `SLICENODE_GetMesh`: Converts a Blender Object into internal `SlicingObject` format.
- `SLICENODE_SliceShells`: Slices a `SlicingObject` into 2D `Slices` at a given layer height. - `SLICENODE_SliceShells`: Slices a `SlicingObject` into 2D `Paths` 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_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_SlicesToPaths`: Extracts the boundaries of `Slices` to create `Paths`. - `SLICENODE_GridInfill`: Generates a grid of lines clipped by boundary paths.
- `SLICENODE_PathsToGcode`: Converts `Paths` into G-code blocks. - `SLICENODE_PathsToGcode`: Converts `Paths` into G-code blocks.
### Joiner Nodes (Variable Inputs) ### Joiner Nodes (Variable Inputs)

BIN
nodes.blend Normal file

Binary file not shown.