1371 lines
No EOL
55 KiB
Python
1371 lines
No EOL
55 KiB
Python
from __future__ import annotations
|
|
from numpy import float64
|
|
import numpy as np
|
|
import pyclipper
|
|
from typing import Any, Literal
|
|
from shapely.geometry.base import BaseGeometry
|
|
|
|
from .geometry import ndarray_lru_cache
|
|
|
|
# Scaling factor for pyclipper (it works with integers)
|
|
SCALE = 1_000_000
|
|
|
|
class SlicePaths(list[np.ndarray]):
|
|
groups: list[int]
|
|
extrusion_rate: list[np.ndarray]
|
|
feedrate: list[np.ndarray]
|
|
extra_attributes: dict[str, list[np.ndarray]]
|
|
|
|
def __init__(self, paths: list[np.ndarray] = [], groups: list[int] = [], extrusion_rate: list[np.ndarray] | None = None, feedrate: list[np.ndarray] | None = None, extra_attributes: dict[str, list[np.ndarray]] | None = None):
|
|
super().__init__(paths)
|
|
|
|
self.groups = []
|
|
if groups: self.groups = list(groups)
|
|
else: self.groups = list(range(0,len(paths)))
|
|
|
|
self.extrusion_rate = []
|
|
if extrusion_rate:
|
|
self.extrusion_rate = list(extrusion_rate)
|
|
else:
|
|
self.extrusion_rate = [np.ones(len(p)) for p in paths]
|
|
|
|
self.feedrate = []
|
|
if feedrate:
|
|
self.feedrate = list(feedrate)
|
|
else:
|
|
self.feedrate = [np.zeros(len(p)) for p in paths]
|
|
|
|
self.extra_attributes = extra_attributes or {}
|
|
|
|
def copy_with(self, paths: list[np.ndarray], groups: list[int] | None = None, extrusion_rate: list[np.ndarray] | None = None, feedrate: list[np.ndarray] | None = None, extra_attributes: dict[str, list[np.ndarray]] | None = None) -> SlicePaths:
|
|
"""Returns a new SlicePaths object with new paths/groups but preserving all other metadata."""
|
|
if extrusion_rate is None:
|
|
# Only reuse extrusion_rate if path lengths match exactly
|
|
if len(paths) == len(self) and all(len(p) == len(op) for p, op in zip(paths, self)):
|
|
extrusion_rate = self.extrusion_rate
|
|
|
|
if feedrate is None:
|
|
if len(paths) == len(self) and all(len(p) == len(op) for p, op in zip(paths, self)):
|
|
feedrate = self.feedrate
|
|
|
|
if extra_attributes is None:
|
|
if len(paths) == len(self) and all(len(p) == len(op) for p, op in zip(paths, self)):
|
|
extra_attributes = self.extra_attributes
|
|
|
|
new_obj = SlicePaths(paths, groups if groups is not None else self.groups, extrusion_rate, feedrate, extra_attributes)
|
|
# Copy any other attributes that might have been added
|
|
for k, v in self.__dict__.items():
|
|
if k not in ["groups", "extrusion_rate", "feedrate", "extra_attributes"]:
|
|
setattr(new_obj, k, v)
|
|
return new_obj
|
|
|
|
def offset(self, offset_vec: np.ndarray) -> SlicePaths:
|
|
"""Returns a new SlicePaths object shifted by the given vector."""
|
|
return self.copy_with([p + offset_vec for p in self])
|
|
|
|
def transform(self, matrix: np.ndarray) -> SlicePaths:
|
|
"""Applies a transformation matrix to all paths."""
|
|
return self.copy_with([p @ matrix.T for p in self])
|
|
|
|
def buffer(self, distance: float, miter: str = 'round', rotation: Any = None) -> SlicePaths:
|
|
"""Buffers (offsets) the paths. Handles optional rotation for non-planar buffering."""
|
|
p = self
|
|
if rotation and any(rotation):
|
|
from mathutils import Euler
|
|
mat = Euler(rotation).to_matrix().inverted()
|
|
p = self.transform(np.array(mat))
|
|
|
|
res_paths, res_groups = buffer_slice(p, distance, miter=miter)
|
|
new_paths = self.copy_with(res_paths, res_groups)
|
|
|
|
if rotation and any(rotation):
|
|
from mathutils import Euler
|
|
mat_inv = Euler(rotation).to_matrix()
|
|
new_paths = new_paths.transform(np.array(mat_inv))
|
|
|
|
return new_paths
|
|
|
|
def buffer_curve(self, distance: float, cap_style: str = 'round', join_style: str = 'round', single_sided: bool = False, rotation: Any = None) -> SlicePaths:
|
|
"""Buffers open curves using shapely. Handles optional rotation for non-planar buffering."""
|
|
p = self
|
|
if rotation and any(rotation):
|
|
from mathutils import Euler
|
|
mat = Euler(rotation).to_matrix().inverted()
|
|
p = self.transform(np.array(mat))
|
|
|
|
res_paths, res_groups = buffer_curve_slice(p, distance, cap_style=cap_style, join_style=join_style, single_sided=single_sided)
|
|
new_paths = self.copy_with(res_paths, res_groups)
|
|
|
|
if rotation and any(rotation):
|
|
from mathutils import Euler
|
|
mat_inv = Euler(rotation).to_matrix()
|
|
new_paths = new_paths.transform(np.array(mat_inv))
|
|
|
|
return new_paths
|
|
|
|
def offset_curve(self, distance: float, join_style: Literal['round', 'mitre'] = 'round', rotation: Any = None) -> SlicePaths:
|
|
"""Offsets open/closed curves by a distance using shapely. Handles optional rotation."""
|
|
p = self
|
|
if rotation and any(rotation):
|
|
from mathutils import Euler
|
|
mat = Euler(rotation).to_matrix().inverted()
|
|
p = self.transform(np.array(mat))
|
|
|
|
res_paths, res_groups = offset_curve_slice(p, distance, join_style=join_style)
|
|
new_paths = self.copy_with(res_paths, res_groups)
|
|
|
|
if rotation and any(rotation):
|
|
from mathutils import Euler
|
|
mat_inv = Euler(rotation).to_matrix()
|
|
new_paths = new_paths.transform(np.array(mat_inv))
|
|
|
|
return new_paths
|
|
|
|
def get_infill_mask(self) -> SlicePaths:
|
|
"""Computes the intersection of all paths in each group using shapely."""
|
|
res_paths, res_groups = get_infill_mask_slice(self)
|
|
return self.copy_with(res_paths, res_groups)
|
|
|
|
def get_overhang_mask(self, tolerance: float = 0.05) -> SlicePaths:
|
|
"""Computes the overhang mask for each layer, propagating from top to bottom."""
|
|
res_paths, res_groups = get_overhang_mask_slice(self, tuple(self.groups), tolerance=tolerance)
|
|
return self.copy_with(res_paths, res_groups)
|
|
|
|
def infill(self, density: float, rotation_deg: float = 0.0, infill_type: str = 'GRID', layer_rotation: Any = None) -> SlicePaths:
|
|
"""Generates infill for the paths."""
|
|
p = self
|
|
if layer_rotation and any(layer_rotation):
|
|
from mathutils import Euler
|
|
mat = Euler(layer_rotation).to_matrix().inverted()
|
|
p = self.transform(np.array(mat))
|
|
|
|
res_paths, res_groups = infill_slice(p, density, rotation_deg=rotation_deg, infill_type=infill_type)
|
|
new_paths = self.copy_with(res_paths, res_groups)
|
|
|
|
if layer_rotation and any(layer_rotation):
|
|
from mathutils import Euler
|
|
mat_inv = Euler(layer_rotation).to_matrix()
|
|
new_paths = new_paths.transform(np.array(mat_inv))
|
|
|
|
return new_paths
|
|
|
|
def rotate(self, angle_deg: float, center: np.ndarray | None = None) -> SlicePaths:
|
|
"""Rotates the paths around Z-axis."""
|
|
if angle_deg == 0:
|
|
return self
|
|
|
|
if center is None:
|
|
center = self.get_first_layer_midpoint()
|
|
|
|
angle_rad = np.radians(angle_deg)
|
|
c, s = np.cos(angle_rad), np.sin(angle_rad)
|
|
rot_mat = np.array([
|
|
[c, -s, 0],
|
|
[s, c, 0],
|
|
[0, 0, 1]
|
|
], dtype=np.float32)
|
|
|
|
new_paths = []
|
|
for p in self:
|
|
# Shift to center, rotate, shift back
|
|
new_p = (p - center) @ rot_mat.T + center
|
|
new_paths.append(new_p)
|
|
|
|
return self.copy_with(new_paths)
|
|
|
|
def get_first_layer_midpoint(self) -> np.ndarray:
|
|
if not self:
|
|
return np.zeros(3)
|
|
|
|
# Find minimum group index
|
|
min_g = min(self.groups)
|
|
first_layer_indices = [i for i, g in enumerate(self.groups) if g == min_g]
|
|
|
|
all_pts = []
|
|
for i in first_layer_indices:
|
|
all_pts.append(self[i])
|
|
|
|
if not all_pts:
|
|
return np.zeros(3)
|
|
|
|
pts = np.concatenate(all_pts)
|
|
min_pts = np.min(pts, axis=0)
|
|
max_pts = np.max(pts, axis=0)
|
|
return (min_pts + max_pts) / 2.0
|
|
|
|
def boolean(self, other: SlicePaths, operation: str = 'UNION', connect_along_boundary: bool = False) -> SlicePaths:
|
|
return boolean_slice(self, other, operation, connect_along_boundary=connect_along_boundary)
|
|
|
|
def extend_with_groups(self, other: SlicePaths | list[np.ndarray]):
|
|
if isinstance(other, SlicePaths):
|
|
self.extend(other)
|
|
self.groups.extend(other.groups)
|
|
self.extrusion_rate.extend(other.extrusion_rate)
|
|
self.feedrate.extend(other.feedrate)
|
|
for k, v in other.extra_attributes.items():
|
|
if k in self.extra_attributes:
|
|
self.extra_attributes[k].extend(v)
|
|
else:
|
|
# Pad with zeros for existing paths if we didn't have this attribute
|
|
self.extra_attributes[k] = [np.zeros(len(p)) for p in self[:-len(other)]] + list(v)
|
|
# Pad existing attributes if 'other' didn't have them
|
|
for k in self.extra_attributes:
|
|
if k not in other.extra_attributes:
|
|
self.extra_attributes[k].extend([np.zeros(len(p)) for p in other])
|
|
else:
|
|
self.extend(other)
|
|
self.groups.extend(list(range(0,len(other))))
|
|
self.extrusion_rate.extend([np.ones(len(p)) for p in other])
|
|
self.feedrate.extend([np.zeros(len(p)) for p in other])
|
|
for k in self.extra_attributes:
|
|
self.extra_attributes[k].extend([np.zeros(len(p)) for p in other])
|
|
|
|
def append_with_group(self, path: np.ndarray, group: int = 0, extrusion_rate: np.ndarray | None = None, feedrate: np.ndarray | None = None, extra_attributes: dict[str, np.ndarray] | None = None):
|
|
self.append(path)
|
|
self.groups.append(group)
|
|
self.extrusion_rate.append(extrusion_rate if extrusion_rate is not None else np.ones(len(path)))
|
|
self.feedrate.append(feedrate if feedrate is not None else np.zeros(len(path)))
|
|
|
|
extra_attributes = extra_attributes or {}
|
|
for k, v in extra_attributes.items():
|
|
if k in self.extra_attributes:
|
|
self.extra_attributes[k].append(v)
|
|
else:
|
|
self.extra_attributes[k] = [np.zeros(len(p)) for p in self[:-1]] + [v]
|
|
|
|
for k in self.extra_attributes:
|
|
if k not in extra_attributes:
|
|
self.extra_attributes[k].append(np.zeros(len(path)))
|
|
|
|
def normalize_groups(self) -> None:
|
|
self.groups = [m.setdefault(x, len(m)) for m in [{}] for x in self.groups]
|
|
|
|
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
|
|
|
|
@ndarray_lru_cache(maxsize=128)
|
|
def segments_to_slice(segments: np.ndarray) -> list[np.ndarray]:
|
|
|
|
linked = link_segments(segments)
|
|
if not linked: return []
|
|
|
|
closed_paths = []
|
|
open_paths = []
|
|
for p in linked:
|
|
path_pts = p[:, :2]
|
|
if len(path_pts) > 2 and np.allclose(path_pts[0], path_pts[-1]):
|
|
closed_paths.append(p)
|
|
else:
|
|
open_paths.append(p)
|
|
|
|
res = []
|
|
z = segments[0, 0, 2]
|
|
|
|
if closed_paths:
|
|
pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue]
|
|
for p in closed_paths:
|
|
path_pts = p[:, :2]
|
|
path_pts = path_pts[:-1]
|
|
scaled = (path_pts * SCALE).astype(np.int64).tolist()
|
|
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) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
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)
|
|
|
|
res.extend(open_paths)
|
|
return res
|
|
|
|
def find_point_on_loop(pt: np.ndarray, loop: np.ndarray, tol: float = 1.0) -> tuple[int, float]:
|
|
"""Finds the edge index and parameter u [0,1] for a point on a loop. loop is (N,2) and closed."""
|
|
p1 = loop[:-1]
|
|
p2 = loop[1:]
|
|
v = p2 - p1
|
|
w = pt - p1
|
|
|
|
l2 = np.sum(v**2, axis=1)
|
|
mask = l2 > 0
|
|
u = np.zeros(len(l2))
|
|
u[mask] = np.sum(w[mask] * v[mask], axis=1) / l2[mask]
|
|
u = np.clip(u, 0, 1)
|
|
|
|
dist_sq = np.sum((p1 + u[:, np.newaxis] * v - pt)**2, axis=1)
|
|
min_idx = np.argmin(dist_sq)
|
|
if dist_sq[min_idx] < tol**2:
|
|
return int(min_idx), float(u[min_idx])
|
|
return -1, 0.0
|
|
|
|
def get_boundary_path(pt1: np.ndarray, pt2: np.ndarray, loops: list[np.ndarray]) -> np.ndarray | None:
|
|
"""Returns the shortest path along one of the loops between pt1 and pt2."""
|
|
best_path = None
|
|
min_len = float('inf')
|
|
|
|
for loop in loops:
|
|
# Pyclipper paths are usually not closed in the array (last != first) if ET_CLOSEDPOLYGON
|
|
# but NodeSlicer usually keeps them closed. Ensure it's closed for logic.
|
|
working_loop = loop
|
|
if not np.allclose(loop[0], loop[-1]):
|
|
working_loop = np.vstack([loop, loop[0]])
|
|
|
|
idx1, u1 = find_point_on_loop(pt1, working_loop)
|
|
if idx1 == -1: continue
|
|
idx2, u2 = find_point_on_loop(pt2, working_loop)
|
|
if idx2 == -1: continue
|
|
|
|
# Found on same loop. Try forward and backward.
|
|
num_verts = len(working_loop) - 1
|
|
|
|
# Forward path
|
|
fwd_pts = [pt1]
|
|
curr = idx1
|
|
while curr != idx2:
|
|
curr = (curr + 1) % num_verts
|
|
fwd_pts.append(working_loop[curr])
|
|
fwd_pts.append(pt2)
|
|
fwd_path = np.array(fwd_pts)
|
|
fwd_len = np.sum(np.sqrt(np.sum(np.diff(fwd_path, axis=0)**2, axis=1)))
|
|
|
|
# Backward path
|
|
bwd_pts = [pt1]
|
|
curr = idx1
|
|
while curr != (idx2 + 1) % num_verts:
|
|
bwd_pts.append(working_loop[curr])
|
|
curr = (curr - 1 + num_verts) % num_verts
|
|
bwd_pts.append(pt2)
|
|
bwd_path = np.array(bwd_pts)
|
|
bwd_len = np.sum(np.sqrt(np.sum(np.diff(bwd_path, axis=0)**2, axis=1)))
|
|
|
|
if fwd_len < bwd_len:
|
|
if fwd_len < min_len:
|
|
min_len = fwd_len
|
|
best_path = fwd_path
|
|
else:
|
|
if bwd_len < min_len:
|
|
min_len = bwd_len
|
|
best_path = bwd_path
|
|
|
|
return best_path
|
|
|
|
@ndarray_lru_cache(maxsize=128)
|
|
def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNION', connect_along_boundary: bool = False) -> SlicePaths:
|
|
all_z_keys: set[int] = set()
|
|
subject_dict: dict[int, list[np.ndarray]] = {}
|
|
object_dict: dict[int, list[np.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: SlicePaths = SlicePaths()
|
|
|
|
group = 0
|
|
for k in sorted(all_z_keys):
|
|
subject_slices = subject_dict.get(k, [])
|
|
object_slices = object_dict.get(k, [])
|
|
|
|
if not subject_slices:
|
|
if object_slices and operation == 'UNION':
|
|
results.extend(object_slices)
|
|
results.groups.extend([group] * len(object_slices))
|
|
continue
|
|
|
|
if not object_slices:
|
|
if operation != 'INTERSECTION':
|
|
results.extend(subject_slices)
|
|
results.groups.extend([group] * len(subject_slices))
|
|
continue
|
|
|
|
z = subject_slices[0][0, 2]
|
|
|
|
pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
# Function to process and add paths to Clipper
|
|
def add_paths_to_clipper(paths, role):
|
|
for s in paths:
|
|
path_pts = s[:, :2]
|
|
is_closed = len(path_pts) > 2 and np.allclose(path_pts[0], path_pts[-1])
|
|
if is_closed:
|
|
path_pts = path_pts[:-1]
|
|
scaled = (path_pts * SCALE).astype(np.int64).tolist()
|
|
pc.AddPath(scaled, role, is_closed) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
layer_results = []
|
|
|
|
def process_op(subs, clips_scaled, clips_pts, op_type):
|
|
for s in subs:
|
|
path_pts = s[:, :2]
|
|
is_closed = len(path_pts) > 2 and np.allclose(path_pts[0], path_pts[-1])
|
|
|
|
if not connect_along_boundary or is_closed:
|
|
pc.Clear()
|
|
if is_closed: path_pts = path_pts[:-1]
|
|
pc.AddPath((path_pts * SCALE).astype(np.int64).tolist(), pyclipper.PT_SUBJECT, is_closed) # pyright: ignore[reportAttributeAccessIssue]
|
|
for c in clips_scaled:
|
|
pc.AddPath(c, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue]
|
|
for path in pyclipper.ClosedPathsFromPolyTree(solution): # pyright: ignore[reportAttributeAccessIssue]
|
|
fpath = np.array(path) / SCALE
|
|
fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)])
|
|
layer_results.append(np.vstack([fpath_3d, fpath_3d[0]]))
|
|
for path in pyclipper.OpenPathsFromPolyTree(solution): # pyright: ignore[reportAttributeAccessIssue]
|
|
fpath = np.array(path) / SCALE
|
|
layer_results.append(np.column_stack([fpath, np.full(len(fpath), z)]))
|
|
else:
|
|
pc.Clear()
|
|
pc.AddPath((path_pts * SCALE).astype(np.int64).tolist(), pyclipper.PT_SUBJECT, False) # pyright: ignore[reportAttributeAccessIssue]
|
|
for c in clips_scaled:
|
|
pc.AddPath(c, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue]
|
|
open_results = pyclipper.OpenPathsFromPolyTree(solution) # pyright: ignore[reportAttributeAccessIssue]
|
|
if not open_results: continue
|
|
|
|
def get_p(pt):
|
|
p1 = path_pts[:-1]
|
|
p2 = path_pts[1:]
|
|
v = p2 - p1
|
|
w = pt - p1
|
|
l2 = np.sum(v**2, axis=1)
|
|
mask = l2 > 0
|
|
u = np.zeros(len(l2))
|
|
u[mask] = np.sum(w[mask] * v[mask], axis=1) / l2[mask]
|
|
u = np.clip(u, 0, 1)
|
|
dist_sq = np.sum((p1 + u[:, np.newaxis] * v - pt)**2, axis=1)
|
|
i = np.argmin(dist_sq)
|
|
return i + u[i]
|
|
|
|
segments = []
|
|
for path in open_results:
|
|
fpath = np.array(path) / SCALE
|
|
p_start = get_p(fpath[0])
|
|
p_end = get_p(fpath[-1])
|
|
if p_start > p_end:
|
|
fpath = fpath[::-1]
|
|
p_start, p_end = p_end, p_start
|
|
segments.append((p_start, fpath))
|
|
|
|
segments.sort(key=lambda x: x[0])
|
|
|
|
current_group = [segments[0][1]]
|
|
for i in range(1, len(segments)):
|
|
prev_end = current_group[-1][-1] * SCALE
|
|
curr_start = segments[i][1][0] * SCALE
|
|
scaled_loops = [(pts * SCALE) for pts in clips_pts]
|
|
b_path = get_boundary_path(prev_end, curr_start, scaled_loops)
|
|
|
|
if b_path is not None:
|
|
if len(b_path) > 2:
|
|
current_group.append(b_path[1:-1] / SCALE)
|
|
current_group.append(segments[i][1])
|
|
else:
|
|
# Path is not continuous along boundary, finalize current group
|
|
path_to_add = np.vstack(current_group)
|
|
layer_results.append(np.column_stack([path_to_add, np.full(len(path_to_add), z)]))
|
|
current_group = [segments[i][1]]
|
|
|
|
path_to_add = np.vstack(current_group)
|
|
layer_results.append(np.column_stack([path_to_add, np.full(len(path_to_add), z)]))
|
|
|
|
if operation == 'INTERSECTION':
|
|
obj_closed_pts = [s[:, :2] for s in object_slices if len(s) > 2 and np.allclose(s[0], s[-1])]
|
|
obj_closed_scaled = [(pts[:-1] * SCALE).astype(np.int64).tolist() for pts in obj_closed_pts]
|
|
process_op(subject_slices, obj_closed_scaled, obj_closed_pts, pyclipper.CT_INTERSECTION) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
# Pass 2 for B_open ∩ A_closed
|
|
b_open = [s for s in object_slices if not (len(s) > 2 and np.allclose(s[0], s[-1]))]
|
|
if b_open:
|
|
sub_closed_pts = [s[:, :2] for s in subject_slices if len(s) > 2 and np.allclose(s[0], s[-1])]
|
|
sub_closed_scaled = [(pts[:-1] * SCALE).astype(np.int64).tolist() for pts in sub_closed_pts]
|
|
process_op(b_open, sub_closed_scaled, sub_closed_pts, pyclipper.CT_INTERSECTION) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
elif operation == 'DIFFERENCE':
|
|
obj_closed_pts = [s[:, :2] for s in object_slices if len(s) > 2 and np.allclose(s[0], s[-1])]
|
|
obj_closed_scaled = [(pts[:-1] * SCALE).astype(np.int64).tolist() for pts in obj_closed_pts]
|
|
process_op(subject_slices, obj_closed_scaled, obj_closed_pts, pyclipper.CT_DIFFERENCE) # pyright: ignore[reportAttributeAccessIssue]
|
|
# DIFFERENCE is not symmetric for open paths in the same way, usually A - B means A is the main focus.
|
|
|
|
else:
|
|
# UNION, XOR
|
|
add_paths_to_clipper(subject_slices, pyclipper.PT_SUBJECT) # pyright: ignore[reportAttributeAccessIssue]
|
|
obj_closed = []
|
|
obj_open = []
|
|
for s in object_slices:
|
|
if len(s) > 2 and np.allclose(s[0], s[-1]): obj_closed.append(s)
|
|
else: obj_open.append(s)
|
|
|
|
if operation in ['UNION', 'XOR']:
|
|
add_paths_to_clipper(obj_open, pyclipper.PT_SUBJECT) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
for s in obj_closed:
|
|
path_pts = s[:, :2]
|
|
scaled = (path_pts[:-1] * SCALE).astype(np.int64).tolist()
|
|
pc.AddPath(scaled, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
op_type = pyclipper.CT_UNION # pyright: ignore[reportAttributeAccessIssue]
|
|
if operation == 'UNION': op_type = pyclipper.CT_UNION # pyright: ignore[reportAttributeAccessIssue]
|
|
elif operation == 'XOR': op_type = pyclipper.CT_XOR # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue]
|
|
for path in pyclipper.ClosedPathsFromPolyTree(solution): # pyright: ignore[reportAttributeAccessIssue]
|
|
fpath = np.array(path) / SCALE
|
|
fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)])
|
|
layer_results.append(np.vstack([fpath_3d, fpath_3d[0]]))
|
|
for path in pyclipper.OpenPathsFromPolyTree(solution): # pyright: ignore[reportAttributeAccessIssue]
|
|
fpath = np.array(path) / SCALE
|
|
layer_results.append(np.column_stack([fpath, np.full(len(fpath), z)]))
|
|
|
|
results.extend(layer_results)
|
|
results.groups.extend([group] * len(layer_results))
|
|
group += 1
|
|
|
|
return results
|
|
|
|
def geometry_to_paths(geom: BaseGeometry, z: float) -> list[np.ndarray]:
|
|
"""Helper to convert shapely geometries to a list of 3D numpy arrays with a given Z height."""
|
|
paths = []
|
|
if geom.is_empty:
|
|
return paths
|
|
|
|
if geom.geom_type == 'Polygon':
|
|
ext_coords = np.array(geom.exterior.coords)
|
|
ext_coords_3d = np.column_stack([ext_coords, np.full(len(ext_coords), z)])
|
|
paths.append(ext_coords_3d)
|
|
for interior in geom.interiors:
|
|
int_coords = np.array(interior.coords)
|
|
int_coords_3d = np.column_stack([int_coords, np.full(len(int_coords), z)])
|
|
paths.append(int_coords_3d)
|
|
elif geom.geom_type == 'MultiPolygon':
|
|
for poly in geom.geoms:
|
|
paths.extend(geometry_to_paths(poly, z))
|
|
elif geom.geom_type == 'GeometryCollection':
|
|
for sub_geom in geom.geoms:
|
|
paths.extend(geometry_to_paths(sub_geom, z))
|
|
|
|
return paths
|
|
|
|
@ndarray_lru_cache(maxsize=64)
|
|
def buffer_curve_slice(
|
|
slice_paths: list[np.ndarray],
|
|
distance: float,
|
|
cap_style: str = 'round',
|
|
join_style: str = 'round',
|
|
single_sided: bool = False
|
|
) -> tuple[list[np.ndarray], list[int]]:
|
|
if not slice_paths or distance <= 0:
|
|
return [], []
|
|
|
|
from shapely.geometry import LineString
|
|
|
|
# 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)
|
|
|
|
paths_list: list[np.ndarray] = []
|
|
groups_list: list[int] = []
|
|
|
|
group_id = 0
|
|
for z_key in sorted(groups.keys()):
|
|
layer_paths = groups[z_key]
|
|
z = layer_paths[0][0, 2]
|
|
|
|
for p in layer_paths:
|
|
if len(p) < 2:
|
|
continue
|
|
|
|
pts_2d = p[:, :2]
|
|
line = LineString(pts_2d)
|
|
|
|
poly = line.buffer(
|
|
distance,
|
|
cap_style=cap_style,
|
|
join_style=join_style,
|
|
single_sided=single_sided,
|
|
quad_segs=16
|
|
)
|
|
|
|
layer_result_paths = geometry_to_paths(poly, z)
|
|
|
|
for path in layer_result_paths:
|
|
paths_list.append(path)
|
|
groups_list.append(group_id)
|
|
|
|
group_id += 1
|
|
|
|
return paths_list, groups_list
|
|
|
|
def offset_geometry_to_paths(geom: BaseGeometry, z: float) -> list[np.ndarray]:
|
|
"""Helper to convert shapely line geometries to a list of 3D numpy arrays with a given Z height."""
|
|
paths = []
|
|
if geom.is_empty:
|
|
return paths
|
|
|
|
if geom.geom_type in ('LineString', 'LinearRing'):
|
|
coords = np.array(geom.coords)
|
|
coords_3d = np.column_stack([coords, np.full(len(coords), z)])
|
|
paths.append(coords_3d)
|
|
elif geom.geom_type == 'MultiLineString':
|
|
for line in geom.geoms:
|
|
coords = np.array(line.coords)
|
|
coords_3d = np.column_stack([coords, np.full(len(coords), z)])
|
|
paths.append(coords_3d)
|
|
elif geom.geom_type == 'GeometryCollection':
|
|
for sub_geom in geom.geoms:
|
|
paths.extend(offset_geometry_to_paths(sub_geom, z))
|
|
elif geom.geom_type in ('Polygon', 'MultiPolygon'):
|
|
boundary = geom.boundary
|
|
paths.extend(offset_geometry_to_paths(boundary, z))
|
|
return paths
|
|
|
|
@ndarray_lru_cache(maxsize=64)
|
|
def offset_curve_slice(
|
|
slice_paths: list[np.ndarray],
|
|
distance: float,
|
|
join_style: Literal['round', 'mitre'] = 'round'
|
|
) -> tuple[list[np.ndarray], list[int]]:
|
|
if not slice_paths:
|
|
return [], []
|
|
|
|
from shapely.geometry import LineString, Polygon, LinearRing
|
|
|
|
# Group paths by Z height
|
|
groups: dict[int, list[np.ndarray]] = {}
|
|
for p in slice_paths:
|
|
z = p[0, 2]
|
|
z_key = int(round(z * 1000))
|
|
groups.setdefault(z_key, []).append(p)
|
|
|
|
paths_list: list[np.ndarray] = []
|
|
groups_list: list[int] = []
|
|
|
|
group_id = 0
|
|
for z_key in sorted(groups.keys()):
|
|
layer_paths = groups[z_key]
|
|
z = layer_paths[0][0, 2]
|
|
|
|
for p in layer_paths:
|
|
if len(p) < 2:
|
|
continue
|
|
|
|
pts_2d = p[:, :2]
|
|
|
|
# Check if path is closed
|
|
is_closed = len(pts_2d) > 2 and np.allclose(pts_2d[0], pts_2d[-1])
|
|
|
|
if is_closed:
|
|
try:
|
|
# Check direction/orientation
|
|
ccw = LinearRing(pts_2d).is_ccw
|
|
poly = Polygon(pts_2d)
|
|
if poly.is_valid:
|
|
# Use Polygon buffer which keeps boundary closed
|
|
buf_dist = distance if ccw else -distance
|
|
offset_geom = poly.buffer(
|
|
buf_dist,
|
|
join_style=join_style,
|
|
quad_segs=16
|
|
)
|
|
else:
|
|
line = LineString(pts_2d)
|
|
offset_geom = line.offset_curve(
|
|
distance,
|
|
join_style=join_style,
|
|
quad_segs=16
|
|
)
|
|
except Exception:
|
|
line = LineString(pts_2d)
|
|
offset_geom = line.offset_curve(
|
|
distance,
|
|
join_style=join_style,
|
|
quad_segs=16
|
|
)
|
|
else:
|
|
line = LineString(pts_2d)
|
|
offset_geom = line.offset_curve(
|
|
distance,
|
|
join_style=join_style,
|
|
quad_segs=16
|
|
)
|
|
|
|
layer_result_paths = offset_geometry_to_paths(offset_geom, z)
|
|
|
|
for path in layer_result_paths:
|
|
paths_list.append(path)
|
|
groups_list.append(group_id)
|
|
|
|
group_id += 1
|
|
|
|
return paths_list, groups_list
|
|
|
|
@ndarray_lru_cache(maxsize=64)
|
|
def get_infill_mask_slice(slice_paths: list[np.ndarray]) -> tuple[list[np.ndarray], list[int]]:
|
|
if not slice_paths:
|
|
return [], []
|
|
|
|
from shapely.geometry import Polygon
|
|
|
|
# Group paths by their Z height
|
|
groups: dict[int, list[np.ndarray]] = {}
|
|
for p in slice_paths:
|
|
z = p[0, 2]
|
|
z_key = int(round(z * 1000))
|
|
groups.setdefault(z_key, []).append(p)
|
|
|
|
paths_list: list[np.ndarray] = []
|
|
groups_list: list[int] = []
|
|
|
|
group_id = 0
|
|
for z_key in sorted(groups.keys()):
|
|
layer_paths = groups[z_key]
|
|
if not layer_paths:
|
|
continue
|
|
|
|
z = layer_paths[0][0, 2]
|
|
|
|
# Convert each closed path to a Shapely Polygon
|
|
polygons = []
|
|
for p in layer_paths:
|
|
if len(p) < 3:
|
|
continue
|
|
pts_2d = p[:, :2]
|
|
poly = Polygon(pts_2d)
|
|
if poly.is_valid and not poly.is_empty:
|
|
polygons.append(poly)
|
|
|
|
if not polygons:
|
|
continue
|
|
|
|
# Compute the intersection of all polygons in the layer
|
|
intersection_poly = polygons[0]
|
|
for poly in polygons[1:]:
|
|
intersection_poly = intersection_poly.intersection(poly)
|
|
|
|
# Convert resulting polygon back to 3D paths
|
|
result_paths = geometry_to_paths(intersection_poly, z)
|
|
|
|
for path in result_paths:
|
|
paths_list.append(path)
|
|
groups_list.append(group_id)
|
|
|
|
group_id += 1
|
|
|
|
return paths_list, groups_list
|
|
|
|
def paths_to_shapely_polygon(paths: list[np.ndarray]) -> BaseGeometry:
|
|
"""Converts a list of paths on a layer to a single Shapely geometry using pyclipper to resolve holes."""
|
|
if not paths:
|
|
return Polygon()
|
|
|
|
import pyclipper
|
|
from shapely.geometry import Polygon
|
|
from shapely.ops import unary_union
|
|
|
|
pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue]
|
|
for p in paths:
|
|
if len(p) < 3:
|
|
continue
|
|
pts_2d = p[:, :2]
|
|
if np.allclose(pts_2d[0], pts_2d[-1]):
|
|
pts_2d = pts_2d[:-1]
|
|
scaled = (pts_2d * SCALE).astype(np.int64).tolist()
|
|
pc.AddPath(scaled, pyclipper.PT_SUBJECT, True) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
tree = pc.Execute2(pyclipper.CT_UNION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
def polytree_to_shapely(node) -> list[Polygon]:
|
|
polygons = []
|
|
if hasattr(node, 'Contour') and node.Contour and not node.IsHole:
|
|
exterior = np.array(node.Contour) / SCALE
|
|
interiors = []
|
|
child_islands = []
|
|
for child in node.Childs:
|
|
if child.IsHole:
|
|
if child.Contour:
|
|
interiors.append(np.array(child.Contour) / SCALE)
|
|
for grandchild in child.Childs:
|
|
child_islands.append(grandchild)
|
|
else:
|
|
child_islands.append(child)
|
|
polygons.append(Polygon(exterior, interiors))
|
|
for island in child_islands:
|
|
polygons.extend(polytree_to_shapely(island))
|
|
else:
|
|
for child in node.Childs:
|
|
polygons.extend(polytree_to_shapely(child))
|
|
return polygons
|
|
|
|
polys = polytree_to_shapely(tree)
|
|
return unary_union(polys)
|
|
|
|
@ndarray_lru_cache(maxsize=32)
|
|
def get_overhang_mask_slice(slice_paths: list[np.ndarray], groups: tuple[int, ...], tolerance: float = 0.05) -> tuple[list[np.ndarray], list[int]]:
|
|
if not slice_paths or not groups:
|
|
return [], []
|
|
|
|
from shapely.geometry import Polygon
|
|
|
|
# Group paths by their group index
|
|
group_map: dict[int, list[np.ndarray]] = {}
|
|
for p, g in zip(slice_paths, groups):
|
|
group_map.setdefault(g, []).append(p)
|
|
|
|
sorted_groups = sorted(group_map.keys())
|
|
if len(sorted_groups) < 2:
|
|
# If there's only 1 layer, there are no overhangs under any layer
|
|
return [], []
|
|
|
|
# Pre-build solid geometry for each group
|
|
layer_geoms = {g: paths_to_shapely_polygon(group_map[g]) for g in sorted_groups}
|
|
|
|
paths_list: list[np.ndarray] = []
|
|
groups_list: list[int] = []
|
|
|
|
accumulated = Polygon()
|
|
|
|
# Iterate from top group down to bottom group
|
|
for i in range(len(sorted_groups) - 1, -1, -1):
|
|
g = sorted_groups[i]
|
|
z = group_map[g][0][0, 2] if group_map[g] and len(group_map[g][0]) > 0 else 0.0
|
|
geom_i = layer_geoms[g]
|
|
|
|
# Calculate raw support needed at layer g: union of all layers above g, minus geom_i.
|
|
# This prevents boundary mismatches/slivers caused by subtracting and then unioning back the same layers.
|
|
if i < len(sorted_groups) - 1:
|
|
raw_support = accumulated.difference(geom_i)
|
|
if not raw_support.is_empty:
|
|
# Apply tolerance clearance only to the output of the current layer.
|
|
geom_i_sub = geom_i.buffer(tolerance) if tolerance > 0 else geom_i
|
|
output_geom = raw_support.difference(geom_i_sub)
|
|
if not output_geom.is_empty:
|
|
result_paths = geometry_to_paths(output_geom, z)
|
|
for path in result_paths:
|
|
paths_list.append(path)
|
|
groups_list.append(g)
|
|
|
|
# Accumulate the union of model layers for the layers below
|
|
accumulated = accumulated.union(geom_i)
|
|
|
|
# Sort output by group index so that paths are ordered bottom-to-top
|
|
sorted_indices = np.argsort(groups_list)
|
|
paths_list = [paths_list[idx] for idx in sorted_indices]
|
|
groups_list = [int(groups_list[idx]) for idx in sorted_indices]
|
|
|
|
return paths_list, groups_list
|
|
|
|
@ndarray_lru_cache(maxsize=64)
|
|
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 [], []
|
|
|
|
# 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, # 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) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
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]
|
|
|
|
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) # pyright: ignore[reportAttributeAccessIssue]
|
|
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]])
|
|
paths_list.append(fpath_3d)
|
|
groups_list.append(group_id)
|
|
|
|
group_id += 1
|
|
|
|
return paths_list, groups_list
|
|
|
|
@ndarray_lru_cache(maxsize=32)
|
|
def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float = 0.0, infill_type: str = 'GRID') -> tuple[list[np.ndarray], list[int]]:
|
|
"""Generates a grid or rectilinear 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
|
|
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
|
|
rg_min_x -= density
|
|
rg_max_x += density
|
|
rg_min_y -= density
|
|
rg_max_y += density
|
|
|
|
vertical_lines = []
|
|
x = rg_min_x
|
|
while x <= rg_max_x:
|
|
vertical_lines.append([
|
|
(int(x * SCALE), int(rg_min_y * SCALE)),
|
|
(int(x * SCALE), int(rg_max_y * SCALE))
|
|
])
|
|
x += density
|
|
|
|
horizontal_lines = []
|
|
y = rg_min_y
|
|
while y <= rg_max_y:
|
|
horizontal_lines.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]] = {}
|
|
if hasattr(slice_paths, 'groups'):
|
|
for p, g in zip(slice_paths, slice_paths.groups):
|
|
groups.setdefault(g, []).append(p)
|
|
else:
|
|
# Fallback if it's just a list
|
|
for p in slice_paths:
|
|
z_key = int(round(p[0, 2] * 1000))
|
|
groups.setdefault(z_key, []).append(p)
|
|
|
|
output_paths: list[np.ndarray] = []
|
|
output_groups: list[int] = []
|
|
|
|
# Sort groups by Z to determine layer index for alternating rectilinear infill
|
|
unique_groups = sorted(groups.keys())
|
|
group_to_layer_index = {g: i for i, g in enumerate(unique_groups)}
|
|
|
|
for g in groups.keys():
|
|
layer_idx = group_to_layer_index[g]
|
|
|
|
if infill_type == 'GRID':
|
|
current_grid_paths = vertical_lines + horizontal_lines
|
|
elif infill_type == 'RECTILINEAR':
|
|
current_grid_paths = vertical_lines if layer_idx % 2 == 0 else horizontal_lines
|
|
else:
|
|
current_grid_paths = []
|
|
|
|
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 current_grid_paths:
|
|
pc.AddPaths(current_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]
|
|
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
|
|
|
|
# Robust initialization: ensure metadata lists match paths length
|
|
e_rate = getattr(paths, "extrusion_rate", None)
|
|
if e_rate is not None and len(e_rate) == len(paths):
|
|
self.extrusion_rates = e_rate
|
|
else:
|
|
self.extrusion_rates = [np.ones(len(p)) for p in paths]
|
|
|
|
f_rate = getattr(paths, "feedrate", None)
|
|
if f_rate is not None and len(f_rate) == len(paths):
|
|
self.feedrates = f_rate
|
|
else:
|
|
self.feedrates = [np.zeros(len(p)) for p in paths]
|
|
|
|
extra = getattr(paths, "extra_attributes", None)
|
|
if extra is not None and all(len(v) == len(paths) for v in extra.values()):
|
|
self.extra_attributes = extra
|
|
else:
|
|
self.extra_attributes = {}
|
|
|
|
self.bed_center_x: float = 250.
|
|
self.bed_center_y: float = 250.
|
|
self.feedrate: float = 1200.
|
|
self.travel_feedrate: float = 2400.
|
|
self.extrusion_rate: float = 0.017
|
|
self.retraction: float = 0.0
|
|
self.lift: float = 0.0
|
|
|
|
def to_gcode(self, include_header: bool = True) -> str:
|
|
if not self.paths:
|
|
return ""
|
|
|
|
# Centering logic using NumPy
|
|
all_pts = np.concatenate(self.paths)
|
|
min_xy = all_pts[:, :2].min(axis=0)
|
|
max_xy = all_pts[:, :2].max(axis=0)
|
|
center_xy = (min_xy + max_xy) / 2.0
|
|
|
|
offset_x = self.bed_center_x - center_xy[0]
|
|
offset_y = self.bed_center_y - center_xy[1]
|
|
|
|
# Apply offset to all paths
|
|
centered_paths = []
|
|
for path in self.paths:
|
|
new_path = path.copy()
|
|
new_path[:, 0] += offset_x
|
|
new_path[:, 1] += offset_y
|
|
centered_paths.append(new_path)
|
|
|
|
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] = []
|
|
|
|
if include_header:
|
|
header: list[str] = [
|
|
"; Generated from NodeSlicer",
|
|
f";TYPE:Custom",
|
|
f";DEFAULT_FEEDRATE={self.feedrate}",
|
|
f";DEFAULT_TRAVEL_FEEDRATE={self.travel_feedrate}",
|
|
f";DEFAULT_EXTRUSION_RATE={self.extrusion_rate}",
|
|
f";OFFSET_X={offset_x:.6f}",
|
|
f";OFFSET_Y={offset_y:.6f}",
|
|
"G21 ; set units to mm",
|
|
"G90 ; absolute XYZ positioning",
|
|
"M83 ; relative extrusion mode",
|
|
"G92 E0",
|
|
]
|
|
gcode += header
|
|
|
|
# Safely parse retraction and lift fields.
|
|
# If they cannot be parsed or value is zero, we skip them.
|
|
try:
|
|
retraction_val = float(self.retraction) if self.retraction is not None else 0.0
|
|
except (TypeError, ValueError):
|
|
retraction_val = 0.0
|
|
|
|
try:
|
|
lift_val = float(self.lift) if self.lift is not None else 0.0
|
|
except (TypeError, ValueError):
|
|
lift_val = 0.0
|
|
|
|
last_z = -1.0
|
|
max_z = 0
|
|
last_nozzle_z = 0.0
|
|
|
|
for i, line in enumerate(centered_paths):
|
|
coords = line.tolist()
|
|
if len(coords) < 2:
|
|
continue
|
|
|
|
e_rate = self.extrusion_rates[i]
|
|
f_rate = self.feedrates[i]
|
|
extras = {prefix: vals[i] for prefix, vals in self.extra_attributes.items()}
|
|
|
|
x0, y0, z0 = coords[0]
|
|
|
|
active_feedrate = self.feedrate
|
|
if i > 0:
|
|
# 1. Retract if retraction is enabled
|
|
if retraction_val > 0.0:
|
|
gcode.append(f"G1 E-{retraction_val:.5f} F1800")
|
|
active_feedrate = 1800.0
|
|
|
|
# 2. Lift Z if lift is enabled
|
|
if lift_val > 0.0:
|
|
gcode.append(f"G0 Z{last_nozzle_z + lift_val:.3f} F720")
|
|
active_feedrate = 720.0
|
|
|
|
# 3. Travel to the start of path `i` (x0, y0) in XY
|
|
travel_fr = f_rate[0] if f_rate[0] > 0 else self.travel_feedrate
|
|
gcode.append(f"G0 X{x0:.3f} Y{y0:.3f} F{travel_fr}")
|
|
active_feedrate = travel_fr
|
|
|
|
# 4. Handle Z alignment (layer change and/or un-lift)
|
|
if abs(z0 - last_z) > 0.001:
|
|
gcode.append(f";BEFORE_LAYER_CHANGE")
|
|
gcode.append(f"G0 Z{z0:.3f} F720")
|
|
gcode.append(f";AFTER_LAYER_CHANGE")
|
|
last_z = z0
|
|
active_feedrate = 720.0
|
|
elif lift_val > 0.0:
|
|
gcode.append(f"G0 Z{z0:.3f} F720")
|
|
active_feedrate = 720.0
|
|
|
|
# 5. Un-retract if retraction was enabled
|
|
if retraction_val > 0.0:
|
|
gcode.append(f"G1 E{retraction_val:.5f} F1800")
|
|
active_feedrate = 1800.0
|
|
else:
|
|
if abs(z0 - last_z) > 0.001:
|
|
gcode.append(f";BEFORE_LAYER_CHANGE")
|
|
gcode.append(f"G0 Z{z0:.3f} F720")
|
|
gcode.append(f";AFTER_LAYER_CHANGE")
|
|
last_z = z0
|
|
active_feedrate = 720.0
|
|
|
|
old_fr = f_rate[0] if f_rate[0] > 0 else self.travel_feedrate
|
|
gcode.append(f"G0 X{x0:.3f} Y{y0:.3f} Z{z0:.3f} F{old_fr}")
|
|
active_feedrate = old_fr
|
|
|
|
old_fr = active_feedrate
|
|
old_x, old_y, old_z = x0, y0, z0
|
|
old_extras = {prefix: None for prefix in extras}
|
|
for j, (p1, p2) in enumerate(zip(coords, coords[1:])):
|
|
seg_len: float = segment_length_3d(p1, p2)
|
|
e_segment = seg_len * self.extrusion_rate * e_rate[j]
|
|
|
|
# Per-segment feedrate value (not multiplier)
|
|
fr = f_rate[j] if f_rate[j] > 0 else self.feedrate
|
|
|
|
old_x, old_y, old_z = p1
|
|
x, y, z = p2
|
|
|
|
cur_gcode = "G1"
|
|
|
|
if not old_x == x: cur_gcode += f" X{x:.3f}"
|
|
if not old_y == y: cur_gcode += f" Y{y:.3f}"
|
|
if not old_z == z: cur_gcode += f" Z{z:.3f}"
|
|
if e_segment: cur_gcode += f" E{e_segment:.5f}"
|
|
if not old_fr == fr: cur_gcode += f" F{fr:.3f}"
|
|
|
|
for prefix, evals in extras.items():
|
|
val = evals[j]
|
|
if not old_extras[prefix] == val:
|
|
cur_gcode += f" {prefix}{val:.3f}"
|
|
old_extras[prefix] = val
|
|
|
|
gcode += [cur_gcode]
|
|
|
|
max_z = max(max_z, z)
|
|
old_fr = fr
|
|
|
|
last_nozzle_z = coords[-1][2]
|
|
|
|
return "\n".join(gcode) |