seam stagger

This commit is contained in:
Nicolas Predella 2026-10-09 17:00:26 +03:00
parent d54349594e
commit 973e38fe53
5 changed files with 364 additions and 96 deletions

View file

@ -748,3 +748,59 @@ EXPORT int reposition_path_seam_c(
return out_idx; return out_idx;
} }
EXPORT int shift_path_seam_by_distance_c(
const double* in_pts,
int num_pts,
int stride,
double dist_mm,
double* out_pts
) {
if (num_pts < 4 || fabs(dist_mm) < 1e-6) {
memcpy(out_pts, in_pts, num_pts * stride * sizeof(double));
return num_pts;
}
int M = num_pts - 1;
double total_len = 0.0;
for (int i = 0; i < M; ++i) {
double dx = in_pts[(i + 1) * stride + 0] - in_pts[i * stride + 0];
double dy = in_pts[(i + 1) * stride + 1] - in_pts[i * stride + 1];
double dz = in_pts[(i + 1) * stride + 2] - in_pts[i * stride + 2];
total_len += sqrt(dx * dx + dy * dy + dz * dz);
}
if (total_len <= 1e-6) {
memcpy(out_pts, in_pts, num_pts * stride * sizeof(double));
return num_pts;
}
double target_d = fmod(dist_mm, total_len);
if (target_d < 0.0) target_d += total_len;
if (target_d <= 1e-6 || fabs(target_d - total_len) <= 1e-6) {
memcpy(out_pts, in_pts, num_pts * stride * sizeof(double));
return num_pts;
}
double cum = 0.0;
for (int i = 0; i < M; ++i) {
double dx = in_pts[(i + 1) * stride + 0] - in_pts[i * stride + 0];
double dy = in_pts[(i + 1) * stride + 1] - in_pts[i * stride + 1];
double dz = in_pts[(i + 1) * stride + 2] - in_pts[i * stride + 2];
double seg_l = sqrt(dx * dx + dy * dy + dz * dz);
if (cum + seg_l >= target_d || i == M - 1) {
double t = (seg_l > 1e-7) ? (target_d - cum) / seg_l : 0.0;
if (t < 0.0) t = 0.0;
if (t > 1.0) t = 1.0;
double ipt[3];
for (int s = 0; s < 3; ++s) {
ipt[s] = (1.0 - t) * in_pts[i * stride + s] + t * in_pts[(i + 1) * stride + s];
}
return reposition_path_seam_c(in_pts, num_pts, stride, i, t, ipt, out_pts);
}
cum += seg_l;
}
memcpy(out_pts, in_pts, num_pts * stride * sizeof(double));
return num_pts;
}

View file

@ -606,6 +606,7 @@ class SLICENODE_SeamPlacement(SliceNode):
def init(self, context): def init(self, context):
self.add_input("SLICESOCK_Paths") self.add_input("SLICESOCK_Paths")
self.add_input("SLICESOCK_Mesh") self.add_input("SLICESOCK_Mesh")
self.add_input("NodeSocketFloat", "Seam Staggering").value = 0.0
self.add_output("SLICESOCK_Paths") self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout): def draw_buttons(self, context, layout):
@ -619,13 +620,16 @@ class SLICENODE_SeamPlacement(SliceNode):
paths: SlicePaths = self.resolve("Paths") paths: SlicePaths = self.resolve("Paths")
mesh_input: Any = self.resolve("Mesh") mesh_input: Any = self.resolve("Mesh")
stagger_val = self.resolve("Seam Staggering")
stagger_mm: float = float(stagger_val) if stagger_val is not None else 0.0
if not paths: if not paths:
return SlicePaths() return SlicePaths()
if not mesh_input: if not mesh_input and abs(stagger_mm) < 1e-6:
return paths return paths
acquire_t = bool(getattr(self, "acquire_transforms", True)) acquire_t = bool(getattr(self, "acquire_transforms", True))
return paths.place_seams(mesh_input, mode=self.mode.lower(), acquire_transforms=acquire_t) return paths.place_seams(mesh_input, mode=self.mode.lower(), acquire_transforms=acquire_t, stagger_mm=stagger_mm)

View file

@ -204,6 +204,16 @@ try:
ctypes.POINTER(ctypes.c_double) ctypes.POINTER(ctypes.c_double)
] ]
_c_seam_lib.intersect_path_with_2d_segments_c.restype = ctypes.c_int _c_seam_lib.intersect_path_with_2d_segments_c.restype = ctypes.c_int
if hasattr(_c_seam_lib, "shift_path_seam_by_distance_c"):
_c_seam_lib.shift_path_seam_by_distance_c.argtypes = [
ctypes.POINTER(ctypes.c_double),
ctypes.c_int,
ctypes.c_int,
ctypes.c_double,
ctypes.POINTER(ctypes.c_double)
]
_c_seam_lib.shift_path_seam_by_distance_c.restype = ctypes.c_int
except Exception: except Exception:
_c_seam_lib = None _c_seam_lib = None
@ -969,6 +979,58 @@ def reposition_path_seam(
return new_p, new_ext, new_feed, new_extra if extra_attributes else extra_attributes return new_p, new_ext, new_feed, new_extra if extra_attributes else extra_attributes
def shift_path_seam_by_distance(
path: np.ndarray,
dist_mm: float,
extrusion_rate: np.ndarray | None = None,
feedrate: np.ndarray | None = None,
extra_attributes: dict[str, np.ndarray] | None = None
) -> tuple[np.ndarray, np.ndarray | None, np.ndarray | None, dict[str, np.ndarray] | None]:
"""
Shifts the starting point (seam) of a closed path along its perimeter by dist_mm.
Positive dist_mm moves forward along the path direction, negative moves backward.
Preserves and interpolates per-vertex attributes.
"""
if len(path) < 4 or not is_closed_path(path) or abs(dist_mm) < 1e-6:
return path, extrusion_rate, feedrate, extra_attributes
pts = path[:, :3]
diffs = np.diff(pts, axis=0)
seg_lens = np.sqrt(np.sum(diffs ** 2, axis=1))
total_len = float(np.sum(seg_lens))
if total_len <= 1e-6:
return path, extrusion_rate, feedrate, extra_attributes
target_d = dist_mm % total_len
if target_d < 0:
target_d += total_len
if target_d <= 1e-6 or abs(target_d - total_len) <= 1e-6:
return path, extrusion_rate, feedrate, extra_attributes
cum = np.concatenate(([0.0], np.cumsum(seg_lens)))
seg_idx = int(np.searchsorted(cum, target_d, side='right') - 1)
seg_idx = max(0, min(len(seg_lens) - 1, seg_idx))
l_seg = seg_lens[seg_idx]
if l_seg > 1e-7:
t = float((target_d - cum[seg_idx]) / l_seg)
else:
t = 0.0
t = max(0.0, min(1.0, t))
p0 = path[seg_idx]
p1 = path[seg_idx + 1]
ipt = (1.0 - t) * p0 + t * p1
return reposition_path_seam(
path, seg_idx, t, ipt,
extrusion_rate=extrusion_rate,
feedrate=feedrate,
extra_attributes=extra_attributes
)
class SlicePaths(list[np.ndarray]): class SlicePaths(list[np.ndarray]):
groups: list[int] groups: list[int]
@ -1435,35 +1497,50 @@ class SlicePaths(list[np.ndarray]):
return self.copy_with(new_paths, extrusion_rate=new_ext, feedrate=new_feed, extra_attributes=new_extra) return self.copy_with(new_paths, extrusion_rate=new_ext, feedrate=new_feed, extra_attributes=new_extra)
def stagger_seams(self, stagger_mm: float) -> SlicePaths:
"""
Shifts the seams of closed paths layer-by-layer, alternating between +stagger_mm (even layers)
and -stagger_mm (odd layers) along the perimeter to avoid vertical seam alignment weak spots.
"""
return self.place_seams(mesh_input=None, stagger_mm=stagger_mm)
from ..infra.debug import debug from ..infra.debug import debug
@debug @debug
def place_seams(self, mesh_input: Any, mode: str = "closest", acquire_transforms: bool = True) -> SlicePaths: def place_seams(self, mesh_input: Any = None, mode: str = "closest", acquire_transforms: bool = True, stagger_mm: float = 0.0) -> SlicePaths:
""" """
Repositions the starting point (seam) of closed slice paths (including outer contours Repositions the starting point (seam) of closed slice paths (including outer contours
and interior holes) to the intersection points between the slice paths and the mesh. and interior holes) to the intersection points between the slice paths and the mesh.
If acquire_transforms is True, applies upstream transforms from slicepaths metadata to the cutting mesh. If acquire_transforms is True, applies upstream transforms from slicepaths metadata to the cutting mesh.
If there is no intersection for a given path, its seam remains unchanged. If stagger_mm is non-zero, offsets the seam along the path perimeter (+stagger_mm on even layers, -stagger_mm on odd layers).
If there is no intersection for a given path, its seam remains unchanged (aside from any staggering).
""" """
if not self: if not self:
return self return self
tris = extract_mesh_triangles(mesh_input) tris = extract_mesh_triangles(mesh_input) if mesh_input is not None else np.empty((0, 3, 3), dtype=np.float64)
if len(tris) == 0: if len(tris) == 0 and abs(stagger_mm) < 1e-6:
return self return self
if acquire_transforms and getattr(self, "transforms", None): if len(tris) > 0:
mesh_transforms = extract_mesh_transforms(mesh_input) if acquire_transforms and getattr(self, "transforms", None):
delta_transforms = compute_delta_transforms(self.transforms, mesh_transforms) mesh_transforms = extract_mesh_transforms(mesh_input)
if delta_transforms: delta_transforms = compute_delta_transforms(self.transforms, mesh_transforms)
tris = apply_transforms_to_triangles(tris, delta_transforms) if delta_transforms:
tris = apply_transforms_to_triangles(tris, delta_transforms)
mesh_center = np.mean(tris[:, :3, :].reshape(-1, 3), axis=0) mesh_center = np.mean(tris[:, :3, :].reshape(-1, 3), axis=0)
# Precompute triangle Z bounds # Precompute triangle Z bounds
tz_min = np.min(tris[:, :3, 2], axis=1) - 1e-4 tz_min = np.min(tris[:, :3, 2], axis=1) - 1e-4
tz_max = np.max(tris[:, :3, 2], axis=1) + 1e-4 tz_max = np.max(tris[:, :3, 2], axis=1) + 1e-4
mesh_z_min = float(np.min(tz_min)) if len(tz_min) > 0 else 0.0 mesh_z_min = float(np.min(tz_min)) if len(tz_min) > 0 else 0.0
mesh_z_max = float(np.max(tz_max)) if len(tz_max) > 0 else 0.0 mesh_z_max = float(np.max(tz_max)) if len(tz_max) > 0 else 0.0
else:
mesh_center = np.zeros(3)
tz_min = np.empty(0, dtype=np.float64)
tz_max = np.empty(0, dtype=np.float64)
mesh_z_min = 0.0
mesh_z_max = 0.0
n_paths = len(self) n_paths = len(self)
res_paths: list[np.ndarray | None] = [None] * n_paths res_paths: list[np.ndarray | None] = [None] * n_paths
@ -1487,107 +1564,98 @@ class SlicePaths(list[np.ndarray]):
pz_min = float(np.min(p[:, 2])) pz_min = float(np.min(p[:, 2]))
pz_max = float(np.max(p[:, 2])) pz_max = float(np.max(p[:, 2]))
if pz_max < mesh_z_min or pz_min > mesh_z_max:
# Completely outside cutting mesh Z-bounds
res_paths[i] = p
res_ext[i] = self.extrusion_rate[i]
res_feed[i] = self.feedrate[i]
for k in self.extra_attributes:
res_extra[k][i] = self.extra_attributes[k][i]
continue
if (pz_max - pz_min) < 1e-3: if (pz_max - pz_min) < 1e-3:
z_um = int(round(float(p[0, 2]) * SCALE)) z_um = int(round(float(p[0, 2]) * SCALE))
z_to_indices.setdefault(z_um, []).append(i) z_to_indices.setdefault(z_um, []).append(i)
else: else:
non_planar_indices.append(i) non_planar_indices.append(i)
# Process planar paths layer-by-layer # Process planar paths layer-by-layer in sorted Z order
for z_um, indices in z_to_indices.items(): sorted_z_keys = sorted(z_to_indices.keys())
for layer_idx, z_um in enumerate(sorted_z_keys):
layer_stagger = float(stagger_mm) if (layer_idx % 2 == 0) else -float(stagger_mm)
indices = z_to_indices[z_um]
first_p = self[indices[0]] first_p = self[indices[0]]
pz = float(first_p[0, 2]) pz = float(first_p[0, 2])
# Filter triangles overlapping this layer Z mesh_segs = np.empty((0, 2, 2), dtype=np.float64)
t_mask = (tz_min <= pz) & (tz_max >= pz) if len(tris) > 0 and (mesh_z_min <= pz <= mesh_z_max):
active_tris = tris[t_mask] t_mask = (tz_min <= pz) & (tz_max >= pz)
active_tris = tris[t_mask]
if len(active_tris) > 0:
mesh_segs = slice_triangles_at_z(active_tris, pz)
if len(active_tris) == 0:
for idx in indices:
res_paths[idx] = self[idx]
res_ext[idx] = self.extrusion_rate[idx]
res_feed[idx] = self.feedrate[idx]
for k in self.extra_attributes:
res_extra[k][idx] = self.extra_attributes[k][idx]
continue
# Slice triangles at Z height ONCE for all paths in this layer
mesh_segs = slice_triangles_at_z(active_tris, pz)
if len(mesh_segs) == 0:
for idx in indices:
res_paths[idx] = self[idx]
res_ext[idx] = self.extrusion_rate[idx]
res_feed[idx] = self.feedrate[idx]
for k in self.extra_attributes:
res_extra[k][idx] = self.extra_attributes[k][idx]
continue
# Intersect each path in the layer with pre-sliced 2D segments
for idx in indices: for idx in indices:
p = self[idx] p = self[idx]
inter = intersect_path_with_2d_segments(p, mesh_segs, pz, mode=mode, mesh_center=mesh_center) curr_ext = self.extrusion_rate[idx]
if inter is None: curr_feed = self.feedrate[idx]
res_paths[idx] = p
res_ext[idx] = self.extrusion_rate[idx]
res_feed[idx] = self.feedrate[idx]
for k in self.extra_attributes:
res_extra[k][idx] = self.extra_attributes[k][idx]
continue
seg_idx, t, ipt = inter
curr_extra = {k: self.extra_attributes[k][idx] for k in self.extra_attributes} if self.extra_attributes else None curr_extra = {k: self.extra_attributes[k][idx] for k in self.extra_attributes} if self.extra_attributes else None
p_mod, ext_mod, feed_mod, extra_mod = reposition_path_seam(
p, seg_idx, t, ipt, # 1. Reposition to cutting mesh intersection if available
extrusion_rate=self.extrusion_rate[idx], if len(mesh_segs) > 0:
feedrate=self.feedrate[idx], inter = intersect_path_with_2d_segments(p, mesh_segs, pz, mode=mode, mesh_center=mesh_center)
extra_attributes=curr_extra if inter is not None:
) seg_idx, t, ipt = inter
res_paths[idx] = p_mod p, curr_ext, curr_feed, curr_extra = reposition_path_seam(
res_ext[idx] = ext_mod if ext_mod is not None else self.extrusion_rate[idx] p, seg_idx, t, ipt,
res_feed[idx] = feed_mod if feed_mod is not None else self.feedrate[idx] extrusion_rate=curr_ext,
if extra_mod: feedrate=curr_feed,
extra_attributes=curr_extra
)
# 2. Shift seam along path if seam staggering is requested
if abs(layer_stagger) > 1e-6 and is_closed_path(p):
p, curr_ext, curr_feed, curr_extra = shift_path_seam_by_distance(
p, layer_stagger,
extrusion_rate=curr_ext,
feedrate=curr_feed,
extra_attributes=curr_extra
)
res_paths[idx] = p
res_ext[idx] = curr_ext if curr_ext is not None else self.extrusion_rate[idx]
res_feed[idx] = curr_feed if curr_feed is not None else self.feedrate[idx]
if curr_extra:
for k in self.extra_attributes: for k in self.extra_attributes:
res_extra[k][idx] = extra_mod.get(k, self.extra_attributes[k][idx]) res_extra[k][idx] = curr_extra.get(k, self.extra_attributes[k][idx])
else: else:
for k in self.extra_attributes: for k in self.extra_attributes:
res_extra[k][idx] = self.extra_attributes[k][idx] res_extra[k][idx] = self.extra_attributes[k][idx]
# Process any non-planar paths # Process any non-planar paths
for idx in non_planar_indices: for np_idx, idx in enumerate(non_planar_indices):
layer_idx = len(sorted_z_keys) + np_idx
layer_stagger = float(stagger_mm) if (layer_idx % 2 == 0) else -float(stagger_mm)
p = self[idx] p = self[idx]
inter = find_path_mesh_intersection(p, tris, mode=mode, mesh_center=mesh_center) curr_ext = self.extrusion_rate[idx]
if inter is None: curr_feed = self.feedrate[idx]
res_paths[idx] = p
res_ext[idx] = self.extrusion_rate[idx]
res_feed[idx] = self.feedrate[idx]
for k in self.extra_attributes:
res_extra[k][idx] = self.extra_attributes[k][idx]
continue
seg_idx, t, ipt = inter
curr_extra = {k: self.extra_attributes[k][idx] for k in self.extra_attributes} if self.extra_attributes else None curr_extra = {k: self.extra_attributes[k][idx] for k in self.extra_attributes} if self.extra_attributes else None
p_mod, ext_mod, feed_mod, extra_mod = reposition_path_seam(
p, seg_idx, t, ipt, if len(tris) > 0:
extrusion_rate=self.extrusion_rate[idx], inter = find_path_mesh_intersection(p, tris, mode=mode, mesh_center=mesh_center)
feedrate=self.feedrate[idx], if inter is not None:
extra_attributes=curr_extra seg_idx, t, ipt = inter
) p, curr_ext, curr_feed, curr_extra = reposition_path_seam(
res_paths[idx] = p_mod p, seg_idx, t, ipt,
res_ext[idx] = ext_mod if ext_mod is not None else self.extrusion_rate[idx] extrusion_rate=curr_ext,
res_feed[idx] = feed_mod if feed_mod is not None else self.feedrate[idx] feedrate=curr_feed,
if extra_mod: extra_attributes=curr_extra
)
if abs(layer_stagger) > 1e-6 and is_closed_path(p):
p, curr_ext, curr_feed, curr_extra = shift_path_seam_by_distance(
p, layer_stagger,
extrusion_rate=curr_ext,
feedrate=curr_feed,
extra_attributes=curr_extra
)
res_paths[idx] = p
res_ext[idx] = curr_ext if curr_ext is not None else self.extrusion_rate[idx]
res_feed[idx] = curr_feed if curr_feed is not None else self.feedrate[idx]
if curr_extra:
for k in self.extra_attributes: for k in self.extra_attributes:
res_extra[k][idx] = extra_mod.get(k, self.extra_attributes[k][idx]) res_extra[k][idx] = curr_extra.get(k, self.extra_attributes[k][idx])
else: else:
for k in self.extra_attributes: for k in self.extra_attributes:
res_extra[k][idx] = self.extra_attributes[k][idx] res_extra[k][idx] = self.extra_attributes[k][idx]

View file

@ -83,6 +83,8 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
- **Slice Shells**: Slices a `SlicingObject` into 2D `SlicePaths` at a given layer height. Automatically aligns path seams to the back (+Y) of each layer. Supports rotation for slicing at an angle. - **Slice Shells**: Slices a `SlicingObject` into 2D `SlicePaths` at a given layer height. Automatically aligns path seams to the back (+Y) of each layer. Supports rotation for slicing at an angle.
- **Ignore Holes**: Boolean option to omit interior hole perimeters and generate only the outer shell boundaries. - **Ignore Holes**: Boolean option to omit interior hole perimeters and generate only the outer shell boundaries.
- **Seam Placement**: Repositions the starting vertex (seam) of closed slice paths (including outer contours and interior holes) to the intersection points between the slice paths and a guide/seam mesh. If there is no intersection for a given path, its seam remains unchanged. Agnostic to mesh input type (`SlicingObject`, `SlicingCollection`, `SlicingGroup`, or lists). - **Seam Placement**: Repositions the starting vertex (seam) of closed slice paths (including outer contours and interior holes) to the intersection points between the slice paths and a guide/seam mesh. If there is no intersection for a given path, its seam remains unchanged. Agnostic to mesh input type (`SlicingObject`, `SlicingCollection`, `SlicingGroup`, or lists).
- **Mesh**: Input cutting mesh socket.
- **Seam Staggering**: Float input socket specifying a distance in millimeters to shift seams along the perimeter alternating layer-by-layer (+stagger on even layers, -stagger on odd layers) to avoid vertical seam alignment weak spots. Can also be used standalone without a cutting mesh.
- **Mode**: Strategy for choosing intersection when multiple intersections occur (**Closest to Seam**, **First Intersection**, or **Mesh Centroid**). - **Mode**: Strategy for choosing intersection when multiple intersections occur (**Closest to Seam**, **First Intersection**, or **Mesh Centroid**).
- **Acquire Transforms**: Checkbox to automatically apply upstream transformation metadata from `SlicePaths` (such as from Transform Geometry or Drop Geometry) to the cutting mesh so that the cutting geometry aligns with the paths as in the viewport. - **Acquire Transforms**: Checkbox to automatically apply upstream transformation metadata from `SlicePaths` (such as from Transform Geometry or Drop Geometry) to the cutting mesh so that the cutting geometry aligns with the paths as in the viewport.
- **Buffer Shells**: Offsets/buffers 2D `SlicePaths`. Supports rotated offsetting and multiple miter types. - **Buffer Shells**: Offsets/buffers 2D `SlicePaths`. Supports rotated offsetting and multiple miter types.

View file

@ -610,6 +610,143 @@ class TestSeamPlacement(unittest.TestCase):
# Transformed seam should NOT equal the unmodified transformed start point # Transformed seam should NOT equal the unmodified transformed start point
self.assertFalse(np.allclose(res[0][0], sp[0][0])) self.assertFalse(np.allclose(res[0][0], sp[0][0]))
def test_seam_staggering_alternating_layers_with_mesh(self):
# Create 3 layers of square paths: Z=0.0, Z=0.2, Z=0.4
# Path vertices: (-10,-10) -> (10,-10) -> (10,10) -> (-10,10) -> (-10,-10)
# Cutting plane at top edge (X=0, Y=10)
p0 = self.square_path.copy()
p0[:, 2] = 0.0
p1 = self.square_path.copy()
p1[:, 2] = 0.2
p2 = self.square_path.copy()
p2[:, 2] = 0.4
sp = SlicePaths([p0, p1, p2])
cutting_plane = create_quad_mesh(
[0.0, 5.0, -5.0],
[0.0, 15.0, -5.0],
[0.0, 15.0, 5.0],
[0.0, 5.0, 5.0]
)
stagger_dist = 2.0
res = sp.place_seams(cutting_plane, stagger_mm=stagger_dist)
self.assertEqual(len(res), 3)
for p in res:
self.assertTrue(is_closed_path(p))
# Intersection is at (0, 10, Z).
# On top edge from (10, 10) to (-10, 10), moving forward along path goes in -X direction.
# Layer 0 (even): +2.0 mm forward along path -> (0 - 2.0, 10, 0.0) = (-2.0, 10.0, 0.0)
np.testing.assert_allclose(res[0][0], [-2.0, 10.0, 0.0], atol=1e-4)
np.testing.assert_allclose(res[0][-1], [-2.0, 10.0, 0.0], atol=1e-4)
# Layer 1 (odd): -2.0 mm backward along path -> (0 + 2.0, 10, 0.2) = (+2.0, 10.0, 0.2)
np.testing.assert_allclose(res[1][0], [2.0, 10.0, 0.2], atol=1e-4)
np.testing.assert_allclose(res[1][-1], [2.0, 10.0, 0.2], atol=1e-4)
# Layer 2 (even): +2.0 mm forward along path -> (-2.0, 10.0, 0.4)
np.testing.assert_allclose(res[2][0], [-2.0, 10.0, 0.4], atol=1e-4)
np.testing.assert_allclose(res[2][-1], [-2.0, 10.0, 0.4], atol=1e-4)
def test_seam_staggering_without_mesh(self):
# Stagger seams directly without any cutting mesh
p0 = self.square_path.copy()
p0[:, 2] = 0.0
p1 = self.square_path.copy()
p1[:, 2] = 0.2
sp = SlicePaths([p0, p1])
stagger_dist = 3.0
res = sp.stagger_seams(stagger_dist)
self.assertEqual(len(res), 2)
self.assertTrue(is_closed_path(res[0]))
self.assertTrue(is_closed_path(res[1]))
# Original start was (-10, -10).
# First segment is from (-10, -10) to (10, -10) along +X.
# Layer 0 (even): +3.0 mm forward -> (-7.0, -10.0, 0.0)
np.testing.assert_allclose(res[0][0], [-7.0, -10.0, 0.0], atol=1e-4)
np.testing.assert_allclose(res[0][-1], [-7.0, -10.0, 0.0], atol=1e-4)
# Layer 1 (odd): -3.0 mm backward -> wraps to previous segment from (-10, 10) to (-10, -10) at (-10.0, -7.0, 0.2)
np.testing.assert_allclose(res[1][0], [-10.0, -7.0, 0.2], atol=1e-4)
np.testing.assert_allclose(res[1][-1], [-10.0, -7.0, 0.2], atol=1e-4)
def test_seam_staggering_preserves_attributes(self):
p0 = self.square_path.copy()
p0[:, 2] = 0.0
p1 = self.square_path.copy()
p1[:, 2] = 0.2
ext = [np.array([1.0, 2.0, 3.0, 4.0, 1.0]), np.array([5.0, 6.0, 7.0, 8.0, 5.0])]
feed = [np.array([10.0, 20.0, 30.0, 40.0, 10.0]), np.array([50.0, 60.0, 70.0, 80.0, 50.0])]
extra = {"temp": [np.array([200.0, 210.0, 220.0, 230.0, 200.0]), np.array([205.0, 215.0, 225.0, 235.0, 205.0])]}
sp = SlicePaths([p0, p1], extrusion_rate=ext, feedrate=feed, extra_attributes=extra)
res = sp.stagger_seams(2.0)
self.assertEqual(len(res), 2)
self.assertEqual(len(res.extrusion_rate), 2)
self.assertEqual(len(res.feedrate), 2)
self.assertIn("temp", res.extra_attributes)
self.assertEqual(len(res.extra_attributes["temp"]), 2)
for i in range(2):
self.assertEqual(len(res[i]), len(res.extrusion_rate[i]))
self.assertEqual(len(res[i]), len(res.feedrate[i]))
self.assertEqual(len(res[i]), len(res.extra_attributes["temp"][i]))
# Extrusion rate at start and end must match for closed loop
self.assertAlmostEqual(res.extrusion_rate[i][0], res.extrusion_rate[i][-1])
self.assertAlmostEqual(res.feedrate[i][0], res.feedrate[i][-1])
self.assertAlmostEqual(res.extra_attributes["temp"][i][0], res.extra_attributes["temp"][i][-1])
def test_seam_placement_node_socket_and_execution(self):
node = SLICENODE_SeamPlacement()
# Mock inputs
inputs_dict = {}
class MockSocket:
def __init__(self, name):
self.name = name
self.value = 0.0
def mock_add_input(type_name, name=None):
s_name = name or ("Paths" if "Paths" in type_name else "Mesh")
sock = MockSocket(s_name)
inputs_dict[s_name] = sock
return sock
node.add_input = mock_add_input
node.add_output = MagicMock()
node.init(None)
self.assertIn("Seam Staggering", inputs_dict)
self.assertEqual(inputs_dict["Seam Staggering"].value, 0.0)
# Mock node.resolve
p0 = self.square_path.copy()
p0[:, 2] = 0.0
p1 = self.square_path.copy()
p1[:, 2] = 0.2
sp = SlicePaths([p0, p1])
def mock_resolve(name):
if name == "Paths": return sp
if name == "Mesh": return None
if name == "Seam Staggering": return 2.5
return None
node.resolve = mock_resolve
res = node.evaluate_output("Paths")
self.assertEqual(len(res), 2)
self.assertTrue(is_closed_path(res[0]))
self.assertTrue(is_closed_path(res[1]))
# Check layer 0 staggered forward by 2.5mm
np.testing.assert_allclose(res[0][0], [-7.5, -10.0, 0.0], atol=1e-4)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
@ -617,3 +754,4 @@ if __name__ == '__main__':