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;
}
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):
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)

View file

@ -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]

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.
- **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.

View file

@ -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__':