diff --git a/core/c_seam.c b/core/c_seam.c index 897de47..4d98d6d 100644 --- a/core/c_seam.c +++ b/core/c_seam.c @@ -12,67 +12,60 @@ EXPORT int find_sharpest_seam_c(const double* pts, int M, int stride, double scale) { if (M < 3) return 0; - int64_t x_buf[1024]; - int64_t y_buf[1024]; - int64_t* x = (M <= 1024) ? x_buf : (int64_t*)malloc(M * sizeof(int64_t)); - int64_t* y = (M <= 1024) ? y_buf : (int64_t*)malloc(M * sizeof(int64_t)); - - for (int i = 0; i < M; ++i) { - x[i] = (int64_t)llround(pts[i * stride + 0] * scale); - y[i] = (int64_t)llround(pts[i * stride + 1] * scale); - } - - int64_t signed_area = (x[M - 1] * y[0] - x[0] * y[M - 1]); - for (int i = 0; i < M - 1; ++i) { - signed_area += (x[i] * y[i + 1] - x[i + 1] * y[i]); - } - - if (signed_area == 0) { - if (M > 1024) { free(x); free(y); } - return 0; + // Find Y bounding range + double min_y = pts[1]; + double max_y = pts[1]; + for (int i = 1; i < M; ++i) { + double y = pts[i * stride + 1]; + if (y < min_y) min_y = y; + if (y > max_y) max_y = y; } + double y_range = max_y - min_y; + double corner_bonus = (y_range > 0.0) ? (0.1 * y_range < 2.0 ? 0.1 * y_range : 2.0) : 2.0; int best_idx = 0; - double min_cos_concave = 2.0; - int best_concave_idx = -1; - double min_cos_overall = 2.0; + double best_score = -1e30; + double best_x = 1e30; for (int i = 0; i < M; ++i) { int prev = (i == 0) ? M - 1 : i - 1; int next = (i == M - 1) ? 0 : i + 1; - int64_t vin_x = x[i] - x[prev]; - int64_t vin_y = y[i] - y[prev]; - int64_t vout_x = x[next] - x[i]; - int64_t vout_y = y[next] - y[i]; + double xi = pts[i * stride + 0]; + double yi = pts[i * stride + 1]; - int64_t l2_in = vin_x * vin_x + vin_y * vin_y; - int64_t l2_out = vout_x * vout_x + vout_y * vout_y; + double vin_x = xi - pts[prev * stride + 0]; + double vin_y = yi - pts[prev * stride + 1]; + double vout_x = pts[next * stride + 0] - xi; + double vout_y = pts[next * stride + 1] - yi; - if (l2_in == 0 || l2_out == 0) continue; + double l_in = sqrt(vin_x * vin_x + vin_y * vin_y); + double l_out = sqrt(vout_x * vout_x + vout_y * vout_y); - int64_t cross = vin_x * vout_y - vin_y * vout_x; - int is_concave = (signed_area > 0) ? (cross < 0) : (cross > 0); - - double dot = (double)(vin_x * vout_x + vin_y * vout_y); - double denom = sqrt((double)l2_in * (double)l2_out); - double cos_ang = dot / denom; - - if (is_concave) { - if (cos_ang < min_cos_concave) { - min_cos_concave = cos_ang; - best_concave_idx = i; - } + double sharpness = 0.0; + if (l_in > 1e-7 && l_out > 1e-7) { + double dot = vin_x * vout_x + vin_y * vout_y; + double cos_ang = dot / (l_in * l_out); + if (cos_ang > 1.0) cos_ang = 1.0; + if (cos_ang < -1.0) cos_ang = -1.0; + sharpness = 1.0 - cos_ang; // 0 for straight line, 1 for 90 deg corner, 2 for 180 deg turnaround } - if (cos_ang < min_cos_overall) { - min_cos_overall = cos_ang; + + double score = yi + sharpness * corner_bonus; + + // Highest score wins (back of the object). Tie-break: smallest X for deterministic consistency across layers + if (score > best_score + 1e-6) { + best_score = score; + best_x = xi; best_idx = i; + } else if (fabs(score - best_score) <= 1e-6) { + if (xi < best_x) { + best_x = xi; + best_idx = i; + } } } - if (M > 1024) { free(x); free(y); } - - if (best_concave_idx >= 0) return best_concave_idx; return best_idx; } diff --git a/core/slice.py b/core/slice.py index ecefb7b..cc0af37 100644 --- a/core/slice.py +++ b/core/slice.py @@ -190,89 +190,68 @@ def weld_vertices(tris: np.ndarray) -> tuple[np.ndarray, np.ndarray]: return unique_verts, faces -def align_path_seam_sharpest(path: np.ndarray) -> np.ndarray: +def find_path_seam(path: np.ndarray) -> int: """ - Reorders a closed 2D/3D polygon path (N, 3) or (N, 2) so that its starting vertex (seam) - is located at the sharpest concave angle (inner corner). If no concave angles exist, - falls back to the sharpest angle overall. - - If path is open or has fewer than 3 distinct vertices, returns path unchanged. - Uses exact 1-micron integer arithmetic for orientation and angle selection. + Finds the optimal starting vertex index (seam) positioned at the back of the object (+Y). + Uses a weighted heuristic prioritizing maximum Y with a sharpness bonus for corners, + with deterministic tie-breaking for consistent vertical layer alignment. """ - if len(path) < 4: - return path - - if not is_closed_path(path): - return path + if len(path) < 4 or not is_closed_path(path): + return 0 pts = path[:-1] M = len(pts) if M < 3: - return path + return 0 if _c_seam_lib is not None: try: c_pts = np.ascontiguousarray(pts, dtype=np.float64) stride = int(c_pts.shape[1]) ptr = c_pts.ctypes.data_as(ctypes.POINTER(ctypes.c_double)) - best_idx = int(_c_seam_lib.find_sharpest_seam_c(ptr, M, stride, float(SCALE))) - if best_idx == 0: - return path - res = np.empty_like(path) - split = M - best_idx - res[:split] = path[best_idx:-1] - res[split:] = path[:best_idx + 1] - return res + return int(_c_seam_lib.find_sharpest_seam_c(ptr, M, stride, float(SCALE))) except Exception: pass - pts_um = np.rint(pts[:, :2] * SCALE).astype(np.int64) - x = pts_um[:, 0] - y = pts_um[:, 1] - signed_area_um = np.sum(x[:-1] * y[1:] - x[1:] * y[:-1]) + (x[-1] * y[0] - x[0] * y[-1]) - if signed_area_um == 0: + # NumPy fallback + x = pts[:, 0] + y = pts[:, 1] + y_min, y_max = float(np.min(y)), float(np.max(y)) + y_range = y_max - y_min + bonus = min(2.0, 0.1 * y_range) if y_range > 0 else 2.0 + + prev_pts = np.roll(pts[:, :2], 1, axis=0) + next_pts = np.roll(pts[:, :2], -1, axis=0) + + v_in = pts[:, :2] - prev_pts + v_out = next_pts - pts[:, :2] + + l_in = np.linalg.norm(v_in, axis=1) + l_out = np.linalg.norm(v_out, axis=1) + + valid = (l_in > 1e-7) & (l_out > 1e-7) + cos_ang = np.ones(M, dtype=np.float64) + cos_ang[valid] = np.sum(v_in[valid] * v_out[valid], axis=1) / (l_in[valid] * l_out[valid]) + cos_ang = np.clip(cos_ang, -1.0, 1.0) + sharpness = 1.0 - cos_ang + + scores = y + sharpness * bonus + # Deterministic tie-break: highest score, smallest x + return int(np.lexsort((x, -scores))[0]) + + +def align_path_seam_sharpest(path: np.ndarray) -> np.ndarray: + """ + Reorders a closed 2D/3D polygon path (N, 3) or (N, 2) so that its starting vertex (seam) + is located at the back of the object (highest Y with corner preference). + + If path is open or has fewer than 3 distinct vertices, returns path unchanged. + """ + if len(path) < 4 or not is_closed_path(path): return path - v_in = np.empty((M, 2), dtype=np.int64) - v_in[0, 0] = x[0] - x[-1] - v_in[0, 1] = y[0] - y[-1] - v_in[1:, 0] = x[1:] - x[:-1] - v_in[1:, 1] = y[1:] - y[:-1] - - l2_in = v_in[:, 0]**2 + v_in[:, 1]**2 - valid_in = l2_in > 0 - valid_out = np.empty_like(valid_in) - valid_out[:-1] = valid_in[1:] - valid_out[-1] = valid_in[0] - valid = valid_in & valid_out - if not np.any(valid): - return path - - cross_2d = np.empty(M, dtype=np.int64) - cross_2d[:-1] = v_in[:-1, 0] * v_in[1:, 1] - v_in[:-1, 1] * v_in[1:, 0] - cross_2d[-1] = v_in[-1, 0] * v_in[0, 1] - v_in[-1, 1] * v_in[0, 0] - - concave_mask = valid & (cross_2d < 0) if signed_area_um > 0 else valid & (cross_2d > 0) - - l_in = np.sqrt(l2_in, dtype=np.float64) - l_out = np.empty_like(l_in) - l_out[:-1] = l_in[1:] - l_out[-1] = l_in[0] - - dot_prods = np.empty(M, dtype=np.float64) - dot_prods[:-1] = v_in[:-1, 0] * v_in[1:, 0] + v_in[:-1, 1] * v_in[1:, 1] - dot_prods[-1] = v_in[-1, 0] * v_in[0, 0] + v_in[-1, 1] * v_in[0, 1] - - cos_angles = np.full(M, 1.0) - denom = l_in[valid] * l_out[valid] - cos_angles[valid] = dot_prods[valid] / denom - - if np.any(concave_mask): - concave_indices = np.where(concave_mask)[0] - best_idx = int(concave_indices[np.argmin(cos_angles[concave_mask])]) - else: - best_idx = int(np.argmin(cos_angles)) - + M = len(path) - 1 + best_idx = find_path_seam(path) if best_idx == 0: return path @@ -283,6 +262,7 @@ def align_path_seam_sharpest(path: np.ndarray) -> np.ndarray: return res + class SlicePaths(list[np.ndarray]): groups: list[int] extrusion_rate: list[np.ndarray] @@ -681,11 +661,10 @@ class SlicePaths(list[np.ndarray]): def align_seams(self, mode: str = "sharpest") -> SlicePaths: """ - Aligns the seams (starting point of closed paths) in SlicePaths. - Supported modes: 'sharpest'. - Uses 1-micron integer math. + Aligns the seams (starting point of closed paths) in SlicePaths to the back (+Y). + Preserves all per-path and per-vertex attributes. """ - if not self or mode != "sharpest": + if not self: return self new_paths: list[np.ndarray] = [] @@ -702,9 +681,9 @@ class SlicePaths(list[np.ndarray]): new_extra[k].append(self.extra_attributes[k][i]) continue - pts = p[:-1] - M = len(pts) - if M < 3: + M = len(p) - 1 + best_idx = find_path_seam(p) + if best_idx == 0: new_paths.append(p) new_ext.append(self.extrusion_rate[i]) new_feed.append(self.feedrate[i]) @@ -712,60 +691,6 @@ class SlicePaths(list[np.ndarray]): new_extra[k].append(self.extra_attributes[k][i]) continue - if _c_seam_lib is not None: - try: - c_pts = np.ascontiguousarray(pts, dtype=np.float64) - stride = int(c_pts.shape[1]) - ptr = c_pts.ctypes.data_as(ctypes.POINTER(ctypes.c_double)) - best_idx = int(_c_seam_lib.find_sharpest_seam_c(ptr, M, stride, float(SCALE))) - except Exception: - best_idx = 0 - else: - pts_um = np.round(pts[:, :2] * SCALE).astype(np.int64) - x = pts_um[:, 0] - y = pts_um[:, 1] - x_next = np.roll(x, -1) - y_next = np.roll(y, -1) - signed_area_um = np.sum(x * y_next - x_next * y) - - prev_pts = np.roll(pts_um, 1, axis=0) - next_pts = np.roll(pts_um, -1, axis=0) - - v_in = pts_um - prev_pts - v_out = next_pts - pts_um - - l2_in = np.sum(v_in**2, axis=1) - l2_out = np.sum(v_out**2, axis=1) - - valid = (l2_in > 0) & (l2_out > 0) - if not np.any(valid) or signed_area_um == 0: - best_idx = 0 - else: - cross_2d = v_in[:, 0] * v_out[:, 1] - v_in[:, 1] * v_out[:, 0] - if signed_area_um > 0: - concave_mask = valid & (cross_2d < 0) - else: - concave_mask = valid & (cross_2d > 0) - - l_in = np.sqrt(l2_in.astype(np.float64)) - l_out = np.sqrt(l2_out.astype(np.float64)) - - cos_angles = np.full(M, 1.0) - dot_prods = np.sum(v_in[valid] * v_out[valid], axis=1).astype(np.float64) - cos_angles[valid] = dot_prods / (l_in[valid] * l_out[valid]) - - if np.any(concave_mask): - concave_indices = np.where(concave_mask)[0] - best_idx = int(concave_indices[np.argmin(cos_angles[concave_mask])]) - else: - best_idx = int(np.argmin(cos_angles)) - - if best_idx == 0: - new_paths.append(p) - new_ext.append(self.extrusion_rate[i]) - new_feed.append(self.feedrate[i]) - for k in self.extra_attributes: - new_extra[k].append(self.extra_attributes[k][i]) else: new_p = np.empty_like(p) split = M - best_idx diff --git a/doc/MODULES.md b/doc/MODULES.md index d29e8df..35dc015 100644 --- a/doc/MODULES.md +++ b/doc/MODULES.md @@ -28,8 +28,8 @@ This file provides a mapping of modules to their primary responsibilities and ke - `slice.py`: - `slice_at_range()`: Top-level slicing function that iterates through Z-heights using `manifold3d` for guaranteed watertight solid slicing (resolving coplanar and floating-point degeneracies) with a fallback to `trimesh` multiplane slicing for open sheets (supports `ignore_holes` option). - `_slice_single_mesh()`: Mesh slicer implementing `manifold3d` solid slicing and `trimesh` multiplane intersection with micro-epsilon perturbation at boundary faces. - - `align_path_seam_sharpest()`: High-performance vectorized heuristic that positions the start vertex (seam) of closed polygon loops at the sharpest concave angle (inner corner), with fallback to sharpest angle overall. - - `SlicePaths.align_seams()`: Method on `SlicePaths` to align path seams to sharpest concave angles while preserving per-vertex attributes and metadata. + - `align_path_seam_sharpest()`: High-performance vectorized heuristic that positions the start vertex (seam) of closed polygon loops at the back of the object (+Y) with corner sharpness preference. + - `SlicePaths.align_seams()`: Method on `SlicePaths` to align path seams to the back (+Y) while preserving per-vertex attributes and metadata. - `Pather`: Class that converts toolpaths into G-code strings. Supports per-segment extrusion rates, feedrates, and custom extra attributes. - `segments_to_slice()`: Utility to convert raw intersection segments into continuous paths (closed polygons are processed via Pyclipper, supports `ignore_holes`, open paths remain open). - `link_segments()`: Logic for joining loose segments into continuous paths. diff --git a/doc/NODES.md b/doc/NODES.md index 951f18f..5c0c97f 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -80,7 +80,7 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Extrusion Multiplier Attribute**: Mesh attribute to use for per-segment extrusion rates. - **Feedrate Attribute**: Mesh attribute to use for per-segment feedrates. - **Extra Attributes**: Dictionary mapping mesh attribute names to custom G-code prefixes (e.g., `opening_size` -> `S`). -- **Slice Shells**: Slices a `SlicingObject` into 2D `SlicePaths` at a given layer height. Automatically aligns path seams to the sharpest concave angle (inner corner) 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. - **Buffer Shells**: Offsets/buffers 2D `SlicePaths`. Supports rotated offsetting and multiple miter types. - **Buffer Shells Multiple**: Offsets 2D `SlicePaths` with multiple buffer passes. diff --git a/tests/test_slice.py b/tests/test_slice.py index 9f7924e..8cf1b94 100644 --- a/tests/test_slice.py +++ b/tests/test_slice.py @@ -725,9 +725,7 @@ class TestSlice(unittest.TestCase): def test_align_seam_sharpest_triangle(self): from NodeSlicer.core.slice import align_path_seam_sharpest - # Right triangle with acute 30-degree vertex at (10, 0) - # Vertices: 90 deg at (0,0), 60 deg at (0, 5.7735), 30 deg at (10,0) - # Start at 90 deg vertex (0,0) + # Right triangle with acute vertex at (10,0) and back vertex at (0, 5.7735) path = np.array([ [0.0, 0.0, 0.0], [0.0, 5.7735, 0.0], @@ -736,8 +734,8 @@ class TestSlice(unittest.TestCase): ]) aligned = align_path_seam_sharpest(path) self.assertTrue(np.allclose(aligned[0], aligned[-1])) - # Sharpest angle is 30 degrees at (10.0, 0.0) - np.testing.assert_allclose(aligned[0, :2], [10.0, 0.0], atol=1e-3) + # Back seam should be placed at the rear-most vertex (+Y) at (0.0, 5.7735) + np.testing.assert_allclose(aligned[0, :2], [0.0, 5.7735], atol=1e-3) def test_slicepaths_align_seams(self): from NodeSlicer.core.slice import SlicePaths @@ -777,8 +775,9 @@ class TestSlice(unittest.TestCase): aligned = align_path_seam_sharpest(path) self.assertEqual(len(aligned), len(path)) self.assertTrue(np.allclose(aligned[0], aligned[-1])) - # Seam should be placed at the concave inner corner (5.0, 5.0) - np.testing.assert_allclose(aligned[0, :2], [5.0, 5.0]) + # Back seam should be placed on the rear corner (+Y) at (0.0, 10.0) + np.testing.assert_allclose(aligned[0, :2], [0.0, 10.0]) + def test_intrinsics_retraction_feedrate(self): from NodeSlicer.core.nodeslicer.nodes.base import SliceIntrinsics diff --git a/tests/visual/images/01_max_coverage_overlapping_squares.png b/tests/visual/images/01_max_coverage_overlapping_squares.png index 83109ff..8ef9642 100644 Binary files a/tests/visual/images/01_max_coverage_overlapping_squares.png and b/tests/visual/images/01_max_coverage_overlapping_squares.png differ diff --git a/tests/visual/images/02_max_coverage_donut_and_box.png b/tests/visual/images/02_max_coverage_donut_and_box.png index 121cc9b..7e3c22f 100644 Binary files a/tests/visual/images/02_max_coverage_donut_and_box.png and b/tests/visual/images/02_max_coverage_donut_and_box.png differ diff --git a/tests/visual/images/03_min_coverage_staggered_layers.png b/tests/visual/images/03_min_coverage_staggered_layers.png index bc0e4fd..46a3cff 100644 Binary files a/tests/visual/images/03_min_coverage_staggered_layers.png and b/tests/visual/images/03_min_coverage_staggered_layers.png differ diff --git a/tests/visual/images/04_min_coverage_overlapping_squares.png b/tests/visual/images/04_min_coverage_overlapping_squares.png index e32cfdd..96250f6 100644 Binary files a/tests/visual/images/04_min_coverage_overlapping_squares.png and b/tests/visual/images/04_min_coverage_overlapping_squares.png differ diff --git a/tests/visual/images/06_intersection_staggered_layers.png b/tests/visual/images/06_intersection_staggered_layers.png index 540d7ef..3272621 100644 Binary files a/tests/visual/images/06_intersection_staggered_layers.png and b/tests/visual/images/06_intersection_staggered_layers.png differ diff --git a/tests/visual/images/07_subtract_hole_creation.png b/tests/visual/images/07_subtract_hole_creation.png index 37a8b54..94196e5 100644 Binary files a/tests/visual/images/07_subtract_hole_creation.png and b/tests/visual/images/07_subtract_hole_creation.png differ diff --git a/tests/visual/images/08_xor_overlapping_squares.png b/tests/visual/images/08_xor_overlapping_squares.png index ddb8304..f2f8cd6 100644 Binary files a/tests/visual/images/08_xor_overlapping_squares.png and b/tests/visual/images/08_xor_overlapping_squares.png differ diff --git a/tests/visual/images/11_max_coverage_concentric_perimeters.png b/tests/visual/images/11_max_coverage_concentric_perimeters.png index a17f5bd..e2b8bf5 100644 Binary files a/tests/visual/images/11_max_coverage_concentric_perimeters.png and b/tests/visual/images/11_max_coverage_concentric_perimeters.png differ diff --git a/tests/visual/images/12_min_coverage_concentric_perimeters.png b/tests/visual/images/12_min_coverage_concentric_perimeters.png index 2778f12..4377adc 100644 Binary files a/tests/visual/images/12_min_coverage_concentric_perimeters.png and b/tests/visual/images/12_min_coverage_concentric_perimeters.png differ diff --git a/tests/visual/images/13_max_coverage_single_input_perimeters.png b/tests/visual/images/13_max_coverage_single_input_perimeters.png index 572d1aa..45fccb8 100644 Binary files a/tests/visual/images/13_max_coverage_single_input_perimeters.png and b/tests/visual/images/13_max_coverage_single_input_perimeters.png differ diff --git a/tests/visual/images/14_min_coverage_single_input_perimeters.png b/tests/visual/images/14_min_coverage_single_input_perimeters.png index a155741..2933eba 100644 Binary files a/tests/visual/images/14_min_coverage_single_input_perimeters.png and b/tests/visual/images/14_min_coverage_single_input_perimeters.png differ