seams on the back

This commit is contained in:
Nicolas Predella 2026-10-09 15:33:48 +03:00
parent b09a5fb636
commit f1f686dbab
16 changed files with 99 additions and 182 deletions

View file

@ -12,67 +12,60 @@
EXPORT int find_sharpest_seam_c(const double* pts, int M, int stride, double scale) { EXPORT int find_sharpest_seam_c(const double* pts, int M, int stride, double scale) {
if (M < 3) return 0; if (M < 3) return 0;
int64_t x_buf[1024]; // Find Y bounding range
int64_t y_buf[1024]; double min_y = pts[1];
int64_t* x = (M <= 1024) ? x_buf : (int64_t*)malloc(M * sizeof(int64_t)); double max_y = pts[1];
int64_t* y = (M <= 1024) ? y_buf : (int64_t*)malloc(M * sizeof(int64_t)); for (int i = 1; i < M; ++i) {
double y = pts[i * stride + 1];
for (int i = 0; i < M; ++i) { if (y < min_y) min_y = y;
x[i] = (int64_t)llround(pts[i * stride + 0] * scale); if (y > max_y) max_y = y;
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;
} }
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; int best_idx = 0;
double min_cos_concave = 2.0; double best_score = -1e30;
int best_concave_idx = -1; double best_x = 1e30;
double min_cos_overall = 2.0;
for (int i = 0; i < M; ++i) { for (int i = 0; i < M; ++i) {
int prev = (i == 0) ? M - 1 : i - 1; int prev = (i == 0) ? M - 1 : i - 1;
int next = (i == M - 1) ? 0 : i + 1; int next = (i == M - 1) ? 0 : i + 1;
int64_t vin_x = x[i] - x[prev]; double xi = pts[i * stride + 0];
int64_t vin_y = y[i] - y[prev]; double yi = pts[i * stride + 1];
int64_t vout_x = x[next] - x[i];
int64_t vout_y = y[next] - y[i];
int64_t l2_in = vin_x * vin_x + vin_y * vin_y; double vin_x = xi - pts[prev * stride + 0];
int64_t l2_out = vout_x * vout_x + vout_y * vout_y; 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; double sharpness = 0.0;
int is_concave = (signed_area > 0) ? (cross < 0) : (cross > 0); if (l_in > 1e-7 && l_out > 1e-7) {
double dot = vin_x * vout_x + vin_y * vout_y;
double dot = (double)(vin_x * vout_x + vin_y * vout_y); double cos_ang = dot / (l_in * l_out);
double denom = sqrt((double)l2_in * (double)l2_out); if (cos_ang > 1.0) cos_ang = 1.0;
double cos_ang = dot / denom; 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 (is_concave) {
if (cos_ang < min_cos_concave) {
min_cos_concave = cos_ang;
best_concave_idx = i;
}
} }
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; 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; return best_idx;
} }

View file

@ -190,89 +190,68 @@ def weld_vertices(tris: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
return unique_verts, faces 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) Finds the optimal starting vertex index (seam) positioned at the back of the object (+Y).
is located at the sharpest concave angle (inner corner). If no concave angles exist, Uses a weighted heuristic prioritizing maximum Y with a sharpness bonus for corners,
falls back to the sharpest angle overall. with deterministic tie-breaking for consistent vertical layer alignment.
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.
""" """
if len(path) < 4: if len(path) < 4 or not is_closed_path(path):
return path return 0
if not is_closed_path(path):
return path
pts = path[:-1] pts = path[:-1]
M = len(pts) M = len(pts)
if M < 3: if M < 3:
return path return 0
if _c_seam_lib is not None: if _c_seam_lib is not None:
try: try:
c_pts = np.ascontiguousarray(pts, dtype=np.float64) c_pts = np.ascontiguousarray(pts, dtype=np.float64)
stride = int(c_pts.shape[1]) stride = int(c_pts.shape[1])
ptr = c_pts.ctypes.data_as(ctypes.POINTER(ctypes.c_double)) 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))) return 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
except Exception: except Exception:
pass pass
pts_um = np.rint(pts[:, :2] * SCALE).astype(np.int64) # NumPy fallback
x = pts_um[:, 0] x = pts[:, 0]
y = pts_um[:, 1] y = pts[:, 1]
signed_area_um = np.sum(x[:-1] * y[1:] - x[1:] * y[:-1]) + (x[-1] * y[0] - x[0] * y[-1]) y_min, y_max = float(np.min(y)), float(np.max(y))
if signed_area_um == 0: 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 return path
v_in = np.empty((M, 2), dtype=np.int64) M = len(path) - 1
v_in[0, 0] = x[0] - x[-1] best_idx = find_path_seam(path)
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))
if best_idx == 0: if best_idx == 0:
return path return path
@ -283,6 +262,7 @@ def align_path_seam_sharpest(path: np.ndarray) -> np.ndarray:
return res return res
class SlicePaths(list[np.ndarray]): class SlicePaths(list[np.ndarray]):
groups: list[int] groups: list[int]
extrusion_rate: list[np.ndarray] extrusion_rate: list[np.ndarray]
@ -681,11 +661,10 @@ class SlicePaths(list[np.ndarray]):
def align_seams(self, mode: str = "sharpest") -> SlicePaths: def align_seams(self, mode: str = "sharpest") -> SlicePaths:
""" """
Aligns the seams (starting point of closed paths) in SlicePaths. Aligns the seams (starting point of closed paths) in SlicePaths to the back (+Y).
Supported modes: 'sharpest'. Preserves all per-path and per-vertex attributes.
Uses 1-micron integer math.
""" """
if not self or mode != "sharpest": if not self:
return self return self
new_paths: list[np.ndarray] = [] new_paths: list[np.ndarray] = []
@ -702,9 +681,9 @@ class SlicePaths(list[np.ndarray]):
new_extra[k].append(self.extra_attributes[k][i]) new_extra[k].append(self.extra_attributes[k][i])
continue continue
pts = p[:-1] M = len(p) - 1
M = len(pts) best_idx = find_path_seam(p)
if M < 3: if best_idx == 0:
new_paths.append(p) new_paths.append(p)
new_ext.append(self.extrusion_rate[i]) new_ext.append(self.extrusion_rate[i])
new_feed.append(self.feedrate[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]) new_extra[k].append(self.extra_attributes[k][i])
continue 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: else:
new_p = np.empty_like(p) new_p = np.empty_like(p)
split = M - best_idx split = M - best_idx

View file

@ -28,8 +28,8 @@ This file provides a mapping of modules to their primary responsibilities and ke
- `slice.py`: - `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_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. - `_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. - `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 sharpest concave angles while preserving per-vertex attributes and metadata. - `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. - `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). - `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. - `link_segments()`: Logic for joining loose segments into continuous paths.

View file

@ -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. - **Extrusion Multiplier Attribute**: Mesh attribute to use for per-segment extrusion rates.
- **Feedrate Attribute**: Mesh attribute to use for per-segment feedrates. - **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`). - **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. - **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**: Offsets/buffers 2D `SlicePaths`. Supports rotated offsetting and multiple miter types.
- **Buffer Shells Multiple**: Offsets 2D `SlicePaths` with multiple buffer passes. - **Buffer Shells Multiple**: Offsets 2D `SlicePaths` with multiple buffer passes.

View file

@ -725,9 +725,7 @@ class TestSlice(unittest.TestCase):
def test_align_seam_sharpest_triangle(self): def test_align_seam_sharpest_triangle(self):
from NodeSlicer.core.slice import align_path_seam_sharpest from NodeSlicer.core.slice import align_path_seam_sharpest
# Right triangle with acute 30-degree vertex at (10, 0) # Right triangle with acute vertex at (10,0) and back vertex at (0, 5.7735)
# Vertices: 90 deg at (0,0), 60 deg at (0, 5.7735), 30 deg at (10,0)
# Start at 90 deg vertex (0,0)
path = np.array([ path = np.array([
[0.0, 0.0, 0.0], [0.0, 0.0, 0.0],
[0.0, 5.7735, 0.0], [0.0, 5.7735, 0.0],
@ -736,8 +734,8 @@ class TestSlice(unittest.TestCase):
]) ])
aligned = align_path_seam_sharpest(path) aligned = align_path_seam_sharpest(path)
self.assertTrue(np.allclose(aligned[0], aligned[-1])) self.assertTrue(np.allclose(aligned[0], aligned[-1]))
# Sharpest angle is 30 degrees at (10.0, 0.0) # Back seam should be placed at the rear-most vertex (+Y) at (0.0, 5.7735)
np.testing.assert_allclose(aligned[0, :2], [10.0, 0.0], atol=1e-3) np.testing.assert_allclose(aligned[0, :2], [0.0, 5.7735], atol=1e-3)
def test_slicepaths_align_seams(self): def test_slicepaths_align_seams(self):
from NodeSlicer.core.slice import SlicePaths from NodeSlicer.core.slice import SlicePaths
@ -777,8 +775,9 @@ class TestSlice(unittest.TestCase):
aligned = align_path_seam_sharpest(path) aligned = align_path_seam_sharpest(path)
self.assertEqual(len(aligned), len(path)) self.assertEqual(len(aligned), len(path))
self.assertTrue(np.allclose(aligned[0], aligned[-1])) self.assertTrue(np.allclose(aligned[0], aligned[-1]))
# Seam should be placed at the concave inner corner (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], [5.0, 5.0]) np.testing.assert_allclose(aligned[0, :2], [0.0, 10.0])
def test_intrinsics_retraction_feedrate(self): def test_intrinsics_retraction_feedrate(self):
from NodeSlicer.core.nodeslicer.nodes.base import SliceIntrinsics from NodeSlicer.core.nodeslicer.nodes.base import SliceIntrinsics

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB