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

View file

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

View file

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

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

View file

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

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