nodeslicer/core/slice.py

360 lines
No EOL
12 KiB
Python

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
def slice_at_range(models, z_values) -> list[list[np.ndarray]]:
if not models:
return []
mmps: list[list[np.ndarray]] = []
for model in models:
model_edges: np.ndarray = edges_from_model(model)
layered_edges: np.ndarray = layered_model(model_edges, z_values)
sliced_edges: list[np.ndarray] = slice_at_z(layered_edges)
all_model_paths: list[np.ndarray] = []
for z, e in zip(z_values, sliced_edges):
if e is None or len(e) == 0:
continue
zf = float(z)
# Build 3D segments directly
seg3d = e.copy()
seg3d[..., 2] = zf
# Build geometry for this slice
slice_paths = segments_to_slice(seg3d)
all_model_paths.extend(slice_paths)
mmps.append(all_model_paths)
return mmps
def edges_from_model(model): # returns a numpy: (facet, 3, 2, xyz)
pairs_indices = np.array([[0, 1], [1, 2], [2, 0]]) # Create an array of indices to select the pairs
return model[:, pairs_indices, :] # Use advanced indexing to create the array
def layered_model(models: np.ndarray, z_values: np.ndarray) -> np.ndarray:
models_array: np.ndarray = np.array(models)[None, :] # (1, F, 3, 2, 3)
m = len(z_values)
# array of zeros with shape (m, 1, 1, 1, 3)
z_offsets_full = np.zeros((m, 1, 1, 1, 3))
z_offsets_full[..., 2] = z_values[:, None, None, None]
layered = models_array - z_offsets_full # (m, F, 3, 2, 3)
return layered
def slice_at_z(edges, normal_vector=np.array([0,0,1]), tolerance=1e-15):
edges_offs = edges # Shape (m, n, 3, 2, 3)
# Obtain intersection points
line_vector = edges_offs[..., 1, :] - edges_offs[..., 0, :]
numerator = np.sum(normal_vector * -edges_offs[..., 0, :], axis=-1)
denominator = np.sum(normal_vector[np.newaxis, np.newaxis, :] * line_vector, axis=-1)
# Avoid division by zero
with np.errstate(divide='ignore', invalid='ignore'):
t = numerator / denominator
t[denominator == 0] = np.nan # Assign NaN where there's no valid intersection
t[(t < 0) | (t > 1)] = np.nan
intersections = edges_offs[..., 0, :] + t[..., np.newaxis] * line_vector
intersections[np.abs(intersections) < tolerance] = 0 # Zero out small values
all_segments = []
for itx in intersections:
mask = ~np.any(np.isnan(itx), axis=-1)
filtered_arr = itx[mask]
segment_count = filtered_arr.shape[-2] // 2
segments = filtered_arr.reshape(segment_count, 2, 3)
segments = np.around(segments, 6)
all_segments.append(segments)
return all_segments
def link_segments(segments: np.ndarray, tolerance=1e-6) -> list[np.ndarray]:
"""Links segments (N, 2, 3) into continuous paths."""
if len(segments) == 0: return []
z = segments[0, 0, 2]
pts = segments[:, :, :2]
def to_key(p): return tuple((p / tolerance).round().astype(np.int64))
adj = {}
for i in range(len(segments)):
p1, p2 = pts[i, 0], pts[i, 1]
k1, k2 = to_key(p1), to_key(p2)
if k1 == k2: continue
adj.setdefault(k1, []).append((k2, i))
adj.setdefault(k2, []).append((k1, i))
paths = []
used = set()
for i in range(len(segments)):
if i in used: continue
p1, p2 = pts[i, 0], pts[i, 1]
k1, k2 = to_key(p1), to_key(p2)
if k1 == k2: continue
path_pts = [p1, p2]
used.add(i)
# Forward
curr_k = k2
while True:
found = False
for next_k, idx in adj.get(curr_k, []):
if idx not in used:
pk1, pk2 = to_key(pts[idx, 0]), to_key(pts[idx, 1])
other_pt = pts[idx, 1] if pk1 == curr_k else pts[idx, 0]
path_pts.append(other_pt)
used.add(idx)
curr_k = to_key(other_pt)
found = True
break
if not found: break
# Backward
curr_k = k1
while True:
found = False
for next_k, idx in adj.get(curr_k, []):
if idx not in used:
pk1, pk2 = to_key(pts[idx, 0]), to_key(pts[idx, 1])
other_pt = pts[idx, 1] if pk1 == curr_k else pts[idx, 0]
path_pts.insert(0, other_pt)
used.add(idx)
curr_k = to_key(other_pt)
found = True
break
if not found: break
res = np.column_stack([np.array(path_pts), np.full(len(path_pts), z)])
paths.append(res)
return paths
def segments_to_slice(segments: np.ndarray) -> list[np.ndarray]:
linked = link_segments(segments)
if not linked: return []
pc = pyclipper.Pyclipper()
for p in linked:
# Pyclipper expects open paths (last point != first point) for ET_CLOSEDPOLYGON
path_pts = p[:, :2]
if len(path_pts) > 2 and np.allclose(path_pts[0], path_pts[-1]):
path_pts = path_pts[:-1]
scaled = (path_pts * SCALE).astype(np.int64).tolist()
pc.AddPath(scaled, pyclipper.PT_SUBJECT, closed = True)
# Use EVENODD for robust hole detection.
solution = pc.Execute(pyclipper.CT_UNION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD)
z = segments[0, 0, 2]
res = []
for path in solution:
if not path: continue
fpath = np.array(path) / SCALE
fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)])
# Close loop by appending the first point for internal consistency
fpath_3d = np.vstack([fpath_3d, fpath_3d[0]])
res.append(fpath_3d)
return res
def boolean_slice(subject: list[np.ndarray], object: list[np.ndarray], operation: str = 'UNION') -> list[np.ndarray]:
all_z_keys: set[int] = set()
subject_dict: dict[int, list[ndarray]] = {}
object_dict: dict[int, list[ndarray]] = {}
for s in subject:
z = int(round(s[0, 2] * 1000))
all_z_keys.add(z)
subject_dict.setdefault(z, []).append(s)
for s in object:
z = int(round(s[0, 2] * 1000))
all_z_keys.add(z)
object_dict.setdefault(z, []).append(s)
results: list[np.ndarray] = []
for k in all_z_keys:
subject_slices = subject_dict.get(k)
object_slices = object_dict.get(k)
if subject_slices is None:
assert object_slices
if operation == 'UNION':
for s in object_slices: results.append(s)
continue
if object_slices is None:
assert subject_slices
if operation == 'INTERSECTION': continue
for s in subject_slices: results.append(s)
continue
z = subject_slices[0][0, 2]
pc = pyclipper.Pyclipper()
# 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)
# 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)
# 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
solution = pc.Execute(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD)
for path in solution:
if not path: continue
fpath = np.array(path) / SCALE
fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)])
fpath_3d = np.vstack([fpath_3d, fpath_3d[0]])
results.append(fpath_3d)
return results
def buffer_slice(slice_paths: list[np.ndarray], distance: float, miter='round', tolerance=.01) -> list[np.ndarray]:
if not slice_paths: return []
# Group paths by Z height to avoid flattening the entire model into 2D
groups: dict[int, list[np.ndarray]] = {}
for p in slice_paths:
z = p[0, 2]
# Use scaled integer for stable dictionary grouping of floats
z_key = int(round(z * 1000))
groups.setdefault(z_key, []).append(p)
join_types = {
'round': pyclipper.JT_ROUND,
'square': pyclipper.JT_SQUARE,
'flat': pyclipper.JT_SQUARE,
'miter': pyclipper.JT_MITER
}
jt = join_types.get(miter, pyclipper.JT_ROUND)
res = []
pco = pyclipper.PyclipperOffset(arc_tolerance=tolerance * SCALE)
# Buffer each layer independently
for z_key in sorted(groups.keys()):
layer_paths = groups[z_key]
z = layer_paths[0][0, 2]
pco.Clear()
scaled_paths = []
for p in layer_paths:
path_pts = p[:, :2]
# Strip redundant closing points
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_paths.append((path_pts * SCALE).astype(np.int64).tolist())
if not scaled_paths: continue
# Add all paths for this specific Z layer
pco.AddPaths(scaled_paths, jt, pyclipper.ET_CLOSEDPOLYGON)
solution = pco.Execute(distance * SCALE)
for path in solution:
if not path: continue
fpath = np.array(path) / SCALE
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)
return res
def group_segments_by_z(segments: np.ndarray) -> dict[int, np.ndarray]:
z_values: np.ndarray = segments[:, 0, 2]
unique_z: np.ndarray = np.unique(z_values)
groups: dict[int, np.ndarray] = {int(round(z*1000)): segments[z_values == z] for z in unique_z}
return groups
############################################################
class Pather(object):
def __init__(self, paths: list[np.ndarray], **kwargs) -> None:
self.paths = paths
self.bed_center_x: float = 250.
self.bed_center_y: float = 250.
self.feedrate: float = 1200.
self.extrusion_rate: float = 0.017
def to_gcode(self) -> str:
if not self.paths:
return ""
def segment_length_3d(p1, p2) -> float:
dx = p2[0] - p1[0]
dy = p2[1] - p1[1]
dz = p2[2] - p1[2]
return (dx * dx + dy * dy + dz * dz) ** 0.5
gcode: list[str] = [
"; Generated from NodeSlicer",
f";TYPE:Custom",
f";FEEDRATE={self.feedrate}",
f";EXTRUSION_RATE={self.extrusion_rate}",
"G21 ; set units to mm",
"G90 ; absolute XYZ positioning",
"M83 ; relative extrusion mode",
"G92 E0",
]
last_z = -1.0
for line in self.paths:
coords = line.tolist()
if len(coords) < 2:
continue
x0, y0, z0 = coords[0]
if abs(z0 - last_z) > 0.001:
gcode.append(f";BEFORE_LAYER_CHANGE")
gcode.append(f"G1 Z{z0:.3f} F720")
gcode.append(f";AFTER_LAYER_CHANGE")
last_z = z0
gcode.append(f"G0 X{x0:.3f} Y{y0:.3f} Z{z0:.3f}")
gcode.append(";TYPE:External perimeter")
for p1, p2 in zip(coords, coords[1:]):
seg_len: float = segment_length_3d(p1, p2)
# Calculate extrusion for this segment only
e_segment = seg_len * self.extrusion_rate
x, y, z = p2
gcode.append(
f"G1 X{x:.3f} Y{y:.3f} Z{z:.3f} E{e_segment:.5f} F{self.feedrate}"
)
return "\n".join(gcode)