inclined infill + gn preview
This commit is contained in:
parent
77bab165af
commit
36e11cdd49
4 changed files with 277 additions and 103 deletions
181
__init__.py
181
__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"
|
||||
|
|
|
|||
178
core/slice.py
178
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]
|
||||
|
|
|
|||
21
doc/NODES.md
21
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)
|
||||
|
|
|
|||
BIN
nodes.blend
Normal file
BIN
nodes.blend
Normal file
Binary file not shown.
Loading…
Add table
Reference in a new issue