diff --git a/core/c_seam.c b/core/c_seam.c index 09ee01e..eaa765a 100644 --- a/core/c_seam.c +++ b/core/c_seam.c @@ -748,3 +748,59 @@ EXPORT int reposition_path_seam_c( 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; +} diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index 2647290..7c292f2 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -606,6 +606,7 @@ class SLICENODE_SeamPlacement(SliceNode): def init(self, context): self.add_input("SLICESOCK_Paths") self.add_input("SLICESOCK_Mesh") + self.add_input("NodeSocketFloat", "Seam Staggering").value = 0.0 self.add_output("SLICESOCK_Paths") def draw_buttons(self, context, layout): @@ -619,13 +620,16 @@ class SLICENODE_SeamPlacement(SliceNode): paths: SlicePaths = self.resolve("Paths") 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: return SlicePaths() - if not mesh_input: + if not mesh_input and abs(stagger_mm) < 1e-6: return paths 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) + diff --git a/core/slice.py b/core/slice.py index c80492a..c5dd693 100644 --- a/core/slice.py +++ b/core/slice.py @@ -204,6 +204,16 @@ try: ctypes.POINTER(ctypes.c_double) ] _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: _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 +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]): 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) + 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 @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 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 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: return self - tris = extract_mesh_triangles(mesh_input) - if len(tris) == 0: + 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 and abs(stagger_mm) < 1e-6: return self - if acquire_transforms and getattr(self, "transforms", None): - mesh_transforms = extract_mesh_transforms(mesh_input) - delta_transforms = compute_delta_transforms(self.transforms, mesh_transforms) - if delta_transforms: - tris = apply_transforms_to_triangles(tris, delta_transforms) + if len(tris) > 0: + if acquire_transforms and getattr(self, "transforms", None): + mesh_transforms = extract_mesh_transforms(mesh_input) + delta_transforms = compute_delta_transforms(self.transforms, mesh_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 - tz_min = np.min(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_max = float(np.max(tz_max)) if len(tz_max) > 0 else 0.0 + # Precompute triangle Z bounds + tz_min = np.min(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_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) 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_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: z_um = int(round(float(p[0, 2]) * SCALE)) z_to_indices.setdefault(z_um, []).append(i) else: non_planar_indices.append(i) - # Process planar paths layer-by-layer - for z_um, indices in z_to_indices.items(): + # Process planar paths layer-by-layer in sorted Z order + 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]] pz = float(first_p[0, 2]) - # Filter triangles overlapping this layer Z - t_mask = (tz_min <= pz) & (tz_max >= pz) - active_tris = tris[t_mask] + mesh_segs = np.empty((0, 2, 2), dtype=np.float64) + if len(tris) > 0 and (mesh_z_min <= pz <= mesh_z_max): + 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: p = self[idx] - inter = intersect_path_with_2d_segments(p, mesh_segs, pz, mode=mode, mesh_center=mesh_center) - if inter is None: - 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_ext = self.extrusion_rate[idx] + curr_feed = self.feedrate[idx] 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, - extrusion_rate=self.extrusion_rate[idx], - feedrate=self.feedrate[idx], - extra_attributes=curr_extra - ) - res_paths[idx] = p_mod - res_ext[idx] = ext_mod if ext_mod is not None else self.extrusion_rate[idx] - res_feed[idx] = feed_mod if feed_mod is not None else self.feedrate[idx] - if extra_mod: + + # 1. Reposition to cutting mesh intersection if available + if len(mesh_segs) > 0: + inter = intersect_path_with_2d_segments(p, mesh_segs, pz, mode=mode, mesh_center=mesh_center) + if inter is not None: + seg_idx, t, ipt = inter + p, curr_ext, curr_feed, curr_extra = reposition_path_seam( + p, seg_idx, t, ipt, + extrusion_rate=curr_ext, + 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: - 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: for k in self.extra_attributes: res_extra[k][idx] = self.extra_attributes[k][idx] # 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] - inter = find_path_mesh_intersection(p, tris, mode=mode, mesh_center=mesh_center) - if inter is None: - 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_ext = self.extrusion_rate[idx] + curr_feed = self.feedrate[idx] 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, - extrusion_rate=self.extrusion_rate[idx], - feedrate=self.feedrate[idx], - extra_attributes=curr_extra - ) - res_paths[idx] = p_mod - res_ext[idx] = ext_mod if ext_mod is not None else self.extrusion_rate[idx] - res_feed[idx] = feed_mod if feed_mod is not None else self.feedrate[idx] - if extra_mod: + + if len(tris) > 0: + inter = find_path_mesh_intersection(p, tris, mode=mode, mesh_center=mesh_center) + if inter is not None: + seg_idx, t, ipt = inter + p, curr_ext, curr_feed, curr_extra = reposition_path_seam( + p, seg_idx, t, ipt, + extrusion_rate=curr_ext, + feedrate=curr_feed, + 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: - 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: for k in self.extra_attributes: res_extra[k][idx] = self.extra_attributes[k][idx] diff --git a/doc/NODES.md b/doc/NODES.md index ca25402..5d80b89 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -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. - **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). + - **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**). - **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. diff --git a/tests/test_seam_placement.py b/tests/test_seam_placement.py index cf67b33..189e38d 100644 --- a/tests/test_seam_placement.py +++ b/tests/test_seam_placement.py @@ -610,6 +610,143 @@ class TestSeamPlacement(unittest.TestCase): # Transformed seam should NOT equal the unmodified transformed start point 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__': unittest.main() @@ -617,3 +754,4 @@ if __name__ == '__main__': +