From d54349594e624f716b1dd7e074735e4a4fda591f Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Fri, 9 Oct 2026 16:49:22 +0300 Subject: [PATCH] custom seams --- .gitignore | 1 + core/c_seam.c | 594 +++++++++++++ core/nodeslicer/nodes/path.py | 50 ++ core/nodeslicer/tree.py | 37 + core/slice.py | 872 +++++++++++++++++++ doc/MODULES.md | 8 + doc/NODES.md | 3 + infra/blender/blender_slice_nodes_generic.py | 49 +- infra/debug.py | 747 ++++++++++++++++ tests/test_debug.py | 327 +++++++ tests/test_seam_placement.py | 619 +++++++++++++ 11 files changed, 3298 insertions(+), 9 deletions(-) create mode 100644 infra/debug.py create mode 100644 tests/test_debug.py create mode 100644 tests/test_seam_placement.py diff --git a/.gitignore b/.gitignore index 0515e60..eb7a86a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ release/ *.so *.dylib *.dll +debug_dumps diff --git a/core/c_seam.c b/core/c_seam.c index 4d98d6d..09ee01e 100644 --- a/core/c_seam.c +++ b/core/c_seam.c @@ -154,3 +154,597 @@ EXPORT double polygon_signed_area_2d_c(const double* pts, int N, int stride) { } return area * 0.5; } + +static inline int intersect_segments_2d( + double ax, double ay, double bx, double by, + double cx, double cy, double dx, double dy, + double* out_t, double* out_u +) { + double denom = (bx - ax) * (dy - cy) - (by - ay) * (dx - cx); + if (fabs(denom) < 1e-12) return 0; + double t = ((cx - ax) * (dy - cy) - (cy - ay) * (dx - cx)) / denom; + double u = ((cx - ax) * (by - ay) - (cy - ay) * (bx - ax)) / denom; + if (t >= -1e-6 && t <= 1.0 + 1e-6 && u >= -1e-6 && u <= 1.0 + 1e-6) { + if (t < 0.0) t = 0.0; + if (t > 1.0) t = 1.0; + if (u < 0.0) u = 0.0; + if (u > 1.0) u = 1.0; + *out_t = t; + *out_u = u; + return 1; + } + return 0; +} + +static inline int intersect_segment_triangle( + const double* p0, const double* p1, + const double* v0, const double* v1, const double* v2, + double* out_t, double* out_pt +) { + double e1x = v1[0] - v0[0], e1y = v1[1] - v0[1], e1z = v1[2] - v0[2]; + double e2x = v2[0] - v0[0], e2y = v2[1] - v0[1], e2z = v2[2] - v0[2]; + double nx = e1y * e2z - e1z * e2y; + double ny = e1z * e2x - e1x * e2z; + double nz = e1x * e2y - e1y * e2x; + double n_len_sq = nx * nx + ny * ny + nz * nz; + if (n_len_sq < 1e-14) return 0; + + double d0 = (p0[0] - v0[0]) * nx + (p0[1] - v0[1]) * ny + (p0[2] - v0[2]) * nz; + double d1 = (p1[0] - v0[0]) * nx + (p1[1] - v0[1]) * ny + (p1[2] - v0[2]) * nz; + + if ((d0 > 1e-6 && d1 > 1e-6) || (d0 < -1e-6 && d1 < -1e-6)) return 0; + + double denom = d0 - d1; + if (fabs(denom) > 1e-12) { + double t = d0 / denom; + if (t < -1e-6 || t > 1.0 + 1e-6) return 0; + if (t < 0.0) t = 0.0; + if (t > 1.0) t = 1.0; + + double ix = p0[0] + t * (p1[0] - p0[0]); + double iy = p0[1] + t * (p1[1] - p0[1]); + double iz = p0[2] + t * (p1[2] - p0[2]); + + double c0x = (v1[1] - v0[1]) * (iz - v0[2]) - (v1[2] - v0[2]) * (iy - v0[1]); + double c0y = (v1[2] - v0[2]) * (ix - v0[0]) - (v1[0] - v0[0]) * (iz - v0[2]); + double c0z = (v1[0] - v0[0]) * (iy - v0[1]) - (v1[1] - v0[1]) * (ix - v0[0]); + double dot0 = c0x * nx + c0y * ny + c0z * nz; + + double c1x = (v2[1] - v1[1]) * (iz - v1[2]) - (v2[2] - v1[2]) * (iy - v1[1]); + double c1y = (v2[2] - v1[2]) * (ix - v1[0]) - (v2[0] - v1[0]) * (iz - v1[2]); + double c1z = (v2[0] - v1[0]) * (iy - v1[1]) - (v2[1] - v1[1]) * (ix - v1[0]); + double dot1 = c1x * nx + c1y * ny + c1z * nz; + + double c2x = (v0[1] - v2[1]) * (iz - v2[2]) - (v0[2] - v2[2]) * (iy - v2[1]); + double c2y = (v0[2] - v2[2]) * (ix - v2[0]) - (v0[0] - v2[0]) * (iz - v2[2]); + double c2z = (v0[0] - v2[0]) * (iy - v2[1]) - (v0[1] - v2[1]) * (ix - v2[0]); + double dot2 = c2x * nx + c2y * ny + c2z * nz; + + double tol = -1e-6 * n_len_sq; + if (dot0 >= tol && dot1 >= tol && dot2 >= tol) { + *out_t = t; + out_pt[0] = ix; + out_pt[1] = iy; + out_pt[2] = iz; + return 1; + } + } else if (fabs(d0) < 1e-6) { + // Coplanar segment - test 3 edges in 2D projection along dominant normal axis + int ax0 = 0, ax1 = 1; + if (fabs(nx) >= fabs(ny) && fabs(nx) >= fabs(nz)) { + ax0 = 1; ax1 = 2; + } else if (fabs(ny) >= fabs(nx) && fabs(ny) >= fabs(nz)) { + ax0 = 0; ax1 = 2; + } + + double best_t = 1e30; + int found = 0; + double t_cand, u_cand; + + const double* verts[3] = {v0, v1, v2}; + for (int e = 0; e < 3; ++e) { + const double* va = verts[e]; + const double* vb = verts[(e + 1) % 3]; + if (intersect_segments_2d(p0[ax0], p0[ax1], p1[ax0], p1[ax1], + va[ax0], va[ax1], vb[ax0], vb[ax1], + &t_cand, &u_cand)) { + if (t_cand < best_t) { + best_t = t_cand; + found = 1; + } + } + } + if (found) { + *out_t = best_t; + out_pt[0] = p0[0] + best_t * (p1[0] - p0[0]); + out_pt[1] = p0[1] + best_t * (p1[1] - p0[1]); + out_pt[2] = p0[2] + best_t * (p1[2] - p0[2]); + return 1; + } + } + return 0; +} + +EXPORT int slice_triangles_at_z_c( + const double* tri_verts, + int num_tris, + double pz, + double* out_segs +) { + int num_segs = 0; + for (int j = 0; j < num_tris; ++j) { + const double* v[3] = { + tri_verts + (j * 9 + 0), + tri_verts + (j * 9 + 3), + tri_verts + (j * 9 + 6) + }; + + double tz_min = v[0][2] < v[1][2] ? (v[0][2] < v[2][2] ? v[0][2] : v[2][2]) : (v[1][2] < v[2][2] ? v[1][2] : v[2][2]); + double tz_max = v[0][2] > v[1][2] ? (v[0][2] > v[2][2] ? v[0][2] : v[2][2]) : (v[1][2] > v[2][2] ? v[1][2] : v[2][2]); + if (tz_max < pz - 1e-4 || tz_min > pz + 1e-4) continue; + + double pts2d[3][2]; + int n_pts2d = 0; + + for (int e = 0; e < 3; ++e) { + const double* va = v[e]; + const double* vb = v[(e + 1) % 3]; + double dza = va[2] - pz; + double dzb = vb[2] - pz; + + if ((dza <= 0.0 && dzb >= 0.0) || (dza >= 0.0 && dzb <= 0.0)) { + double denom = vb[2] - va[2]; + double u = (fabs(denom) > 1e-12) ? (pz - va[2]) / denom : 0.0; + if (u < 0.0) u = 0.0; + if (u > 1.0) u = 1.0; + double qx = va[0] + u * (vb[0] - va[0]); + double qy = va[1] + u * (vb[1] - va[1]); + + if (n_pts2d == 0 || (fabs(qx - pts2d[0][0]) > 1e-6 || fabs(qy - pts2d[0][1]) > 1e-6)) { + if (n_pts2d < 2) { + pts2d[n_pts2d][0] = qx; + pts2d[n_pts2d][1] = qy; + n_pts2d++; + } + } + } + } + + if (n_pts2d == 2) { + out_segs[num_segs * 4 + 0] = pts2d[0][0]; + out_segs[num_segs * 4 + 1] = pts2d[0][1]; + out_segs[num_segs * 4 + 2] = pts2d[1][0]; + out_segs[num_segs * 4 + 3] = pts2d[1][1]; + num_segs++; + } + } + return num_segs; +} + +EXPORT int intersect_path_with_2d_segments_c( + const double* path_pts, + int num_pts, + int stride, + const double* mesh_segs, + int num_segs, + double pz, + int mode, + const double* ref_point, + int* out_seg_idx, + double* out_t, + double* out_pt +) { + if (num_pts < 4 || num_segs <= 0) return 0; + int M = num_pts - 1; + + double p_min_x = path_pts[0], p_max_x = path_pts[0]; + double p_min_y = path_pts[1], p_max_y = path_pts[1]; + for (int i = 1; i < num_pts; ++i) { + double px = path_pts[i * stride + 0]; + double py = path_pts[i * stride + 1]; + if (px < p_min_x) p_min_x = px; + if (px > p_max_x) p_max_x = px; + if (py < p_min_y) p_min_y = py; + if (py > p_max_y) p_max_y = py; + } + p_min_x -= 1e-4; p_max_x += 1e-4; + p_min_y -= 1e-4; p_max_y += 1e-4; + + int found = 0; + int best_seg = -1; + double best_t = 0.0; + double best_pt[3] = {0.0, 0.0, 0.0}; + double best_dist_sq = 1e30; + + double rx = ref_point ? ref_point[0] : path_pts[0]; + double ry = ref_point ? ref_point[1] : path_pts[1]; + double rz = ref_point ? ref_point[2] : pz; + + for (int i = 0; i < M; ++i) { + const double* p0 = path_pts + (i * stride); + const double* p1 = path_pts + ((i + 1) * stride); + + double s_min_x = (p0[0] < p1[0] ? p0[0] : p1[0]) - 1e-4; + double s_max_x = (p0[0] > p1[0] ? p0[0] : p1[0]) + 1e-4; + double s_min_y = (p0[1] < p1[1] ? p0[1] : p1[1]) - 1e-4; + double s_max_y = (p0[1] > p1[1] ? p0[1] : p1[1]) + 1e-4; + + for (int s = 0; s < num_segs; ++s) { + double q0x = mesh_segs[s * 4 + 0]; + double q0y = mesh_segs[s * 4 + 1]; + double q1x = mesh_segs[s * 4 + 2]; + double q1y = mesh_segs[s * 4 + 3]; + + double q_min_x = (q0x < q1x ? q0x : q1x) - 1e-4; + double q_max_x = (q0x > q1x ? q0x : q1x) + 1e-4; + if (q_max_x < s_min_x || q_min_x > s_max_x) continue; + + double q_min_y = (q0y < q1y ? q0y : q1y) - 1e-4; + double q_max_y = (q0y > q1y ? q0y : q1y) + 1e-4; + if (q_max_y < s_min_y || q_min_y > s_max_y) continue; + + double t_cand, u_cand; + if (intersect_segments_2d(p0[0], p0[1], p1[0], p1[1], q0x, q0y, q1x, q1y, &t_cand, &u_cand)) { + double ix = p0[0] + t_cand * (p1[0] - p0[0]); + double iy = p0[1] + t_cand * (p1[1] - p0[1]); + double iz = pz; + + if (mode == 1) { // FIRST + *out_seg_idx = i; + *out_t = t_cand; + out_pt[0] = ix; + out_pt[1] = iy; + out_pt[2] = iz; + return 1; + } + + double dx = ix - rx; + double dy = iy - ry; + double dz = iz - rz; + double d_sq = dx * dx + dy * dy + dz * dz; + + if (!found || d_sq < best_dist_sq) { + found = 1; + best_dist_sq = d_sq; + best_seg = i; + best_t = t_cand; + best_pt[0] = ix; + best_pt[1] = iy; + best_pt[2] = iz; + } + } + } + } + + if (found) { + *out_seg_idx = best_seg; + *out_t = best_t; + out_pt[0] = best_pt[0]; + out_pt[1] = best_pt[1]; + out_pt[2] = best_pt[2]; + return 1; + } + return 0; +} + +EXPORT int find_path_mesh_intersection_c( + const double* path_pts, + int num_pts, + int stride, + const double* tri_verts, + int num_tris, + int mode, + const double* ref_point, + int* out_seg_idx, + double* out_t, + double* out_pt +) { + if (num_pts < 4 || num_tris <= 0) return 0; + int M = num_pts - 1; + + // 1. Path bounding box + double p_min_x = path_pts[0], p_max_x = path_pts[0]; + double p_min_y = path_pts[1], p_max_y = path_pts[1]; + double p_min_z = path_pts[2], p_max_z = path_pts[2]; + for (int i = 1; i < num_pts; ++i) { + double px = path_pts[i * stride + 0]; + double py = path_pts[i * stride + 1]; + double pz = path_pts[i * stride + 2]; + if (px < p_min_x) p_min_x = px; + if (px > p_max_x) p_max_x = px; + if (py < p_min_y) p_min_y = py; + if (py > p_max_y) p_max_y = py; + if (pz < p_min_z) p_min_z = pz; + if (pz > p_max_z) p_max_z = pz; + } + double p_eps = 1e-4; + p_min_x -= p_eps; p_max_x += p_eps; + p_min_y -= p_eps; p_max_y += p_eps; + p_min_z -= p_eps; p_max_z += p_eps; + + // 2. Pre-filter triangles that overlap the path bounding box + int* active_tris = (int*)malloc(num_tris * sizeof(int)); + if (!active_tris) return 0; + int num_active = 0; + + for (int j = 0; j < num_tris; ++j) { + const double* v0 = tri_verts + (j * 9 + 0); + const double* v1 = tri_verts + (j * 9 + 3); + const double* v2 = tri_verts + (j * 9 + 6); + + double tz_min = v0[2] < v1[2] ? (v0[2] < v2[2] ? v0[2] : v2[2]) : (v1[2] < v2[2] ? v1[2] : v2[2]); + double tz_max = v0[2] > v1[2] ? (v0[2] > v2[2] ? v0[2] : v2[2]) : (v1[2] > v2[2] ? v1[2] : v2[2]); + if (tz_max < p_min_z || tz_min > p_max_z) continue; + + double tx_min = v0[0] < v1[0] ? (v0[0] < v2[0] ? v0[0] : v2[0]) : (v1[0] < v2[0] ? v1[0] : v2[0]); + double tx_max = v0[0] > v1[0] ? (v0[0] > v2[0] ? v0[0] : v2[0]) : (v1[0] > v2[0] ? v1[0] : v2[0]); + if (tx_max < p_min_x || tx_min > p_max_x) continue; + + double ty_min = v0[1] < v1[1] ? (v0[1] < v2[1] ? v0[1] : v2[1]) : (v1[1] < v2[1] ? v1[1] : v2[1]); + double ty_max = v0[1] > v1[1] ? (v0[1] > v2[1] ? v0[1] : v2[1]) : (v1[1] > v2[1] ? v1[1] : v2[1]); + if (ty_max < p_min_y || ty_min > p_max_y) continue; + + active_tris[num_active++] = j; + } + + if (num_active == 0) { + free(active_tris); + return 0; + } + + int found = 0; + int best_seg = -1; + double best_t = 0.0; + double best_pt[3] = {0.0, 0.0, 0.0}; + double best_dist_sq = 1e30; + + double rx = ref_point ? ref_point[0] : path_pts[0]; + double ry = ref_point ? ref_point[1] : path_pts[1]; + double rz = ref_point ? ref_point[2] : path_pts[2]; + + int is_planar = (p_max_z - p_min_z) < 1e-3; + + if (is_planar) { + double pz = path_pts[2]; + double* mesh_segs = (double*)malloc(num_active * 4 * sizeof(double)); + int num_segs = 0; + + if (mesh_segs) { + for (int a = 0; a < num_active; ++a) { + int j = active_tris[a]; + const double* v[3] = { + tri_verts + (j * 9 + 0), + tri_verts + (j * 9 + 3), + tri_verts + (j * 9 + 6) + }; + + double pts2d[3][2]; + int n_pts2d = 0; + + for (int e = 0; e < 3; ++e) { + const double* va = v[e]; + const double* vb = v[(e + 1) % 3]; + double dza = va[2] - pz; + double dzb = vb[2] - pz; + + if ((dza <= 0.0 && dzb >= 0.0) || (dza >= 0.0 && dzb <= 0.0)) { + double denom = vb[2] - va[2]; + double u = (fabs(denom) > 1e-12) ? (pz - va[2]) / denom : 0.0; + if (u < 0.0) u = 0.0; + if (u > 1.0) u = 1.0; + double qx = va[0] + u * (vb[0] - va[0]); + double qy = va[1] + u * (vb[1] - va[1]); + + if (n_pts2d == 0 || (fabs(qx - pts2d[0][0]) > 1e-6 || fabs(qy - pts2d[0][1]) > 1e-6)) { + if (n_pts2d < 2) { + pts2d[n_pts2d][0] = qx; + pts2d[n_pts2d][1] = qy; + n_pts2d++; + } + } + } + } + + if (n_pts2d == 2) { + mesh_segs[num_segs * 4 + 0] = pts2d[0][0]; + mesh_segs[num_segs * 4 + 1] = pts2d[0][1]; + mesh_segs[num_segs * 4 + 2] = pts2d[1][0]; + mesh_segs[num_segs * 4 + 3] = pts2d[1][1]; + num_segs++; + } + } + + for (int i = 0; i < M; ++i) { + const double* p0 = path_pts + (i * stride); + const double* p1 = path_pts + ((i + 1) * stride); + + double s_min_x = (p0[0] < p1[0] ? p0[0] : p1[0]) - 1e-4; + double s_max_x = (p0[0] > p1[0] ? p0[0] : p1[0]) + 1e-4; + double s_min_y = (p0[1] < p1[1] ? p0[1] : p1[1]) - 1e-4; + double s_max_y = (p0[1] > p1[1] ? p0[1] : p1[1]) + 1e-4; + + for (int s = 0; s < num_segs; ++s) { + double q0x = mesh_segs[s * 4 + 0]; + double q0y = mesh_segs[s * 4 + 1]; + double q1x = mesh_segs[s * 4 + 2]; + double q1y = mesh_segs[s * 4 + 3]; + + double q_min_x = (q0x < q1x ? q0x : q1x) - 1e-4; + double q_max_x = (q0x > q1x ? q0x : q1x) + 1e-4; + if (q_max_x < s_min_x || q_min_x > s_max_x) continue; + + double q_min_y = (q0y < q1y ? q0y : q1y) - 1e-4; + double q_max_y = (q0y > q1y ? q0y : q1y) + 1e-4; + if (q_max_y < s_min_y || q_min_y > s_max_y) continue; + + double t_cand, u_cand; + if (intersect_segments_2d(p0[0], p0[1], p1[0], p1[1], q0x, q0y, q1x, q1y, &t_cand, &u_cand)) { + double ix = p0[0] + t_cand * (p1[0] - p0[0]); + double iy = p0[1] + t_cand * (p1[1] - p0[1]); + double iz = pz; + + if (mode == 1) { // FIRST + *out_seg_idx = i; + *out_t = t_cand; + out_pt[0] = ix; + out_pt[1] = iy; + out_pt[2] = iz; + free(mesh_segs); + free(active_tris); + return 1; + } + + double dx = ix - rx; + double dy = iy - ry; + double dz = iz - rz; + double d_sq = dx * dx + dy * dy + dz * dz; + + if (!found || d_sq < best_dist_sq) { + found = 1; + best_dist_sq = d_sq; + best_seg = i; + best_t = t_cand; + best_pt[0] = ix; + best_pt[1] = iy; + best_pt[2] = iz; + } + } + } + } + free(mesh_segs); + } + } + + if (!found) { + for (int i = 0; i < M; ++i) { + const double* p0 = path_pts + (i * stride); + const double* p1 = path_pts + ((i + 1) * stride); + + double s_min_x = (p0[0] < p1[0] ? p0[0] : p1[0]) - 1e-4; + double s_max_x = (p0[0] > p1[0] ? p0[0] : p1[0]) + 1e-4; + double s_min_y = (p0[1] < p1[1] ? p0[1] : p1[1]) - 1e-4; + double s_max_y = (p0[1] > p1[1] ? p0[1] : p1[1]) + 1e-4; + double s_min_z = (p0[2] < p1[2] ? p0[2] : p1[2]) - 1e-4; + double s_max_z = (p0[2] > p1[2] ? p0[2] : p1[2]) + 1e-4; + + for (int a = 0; a < num_active; ++a) { + int j = active_tris[a]; + const double* v0 = tri_verts + (j * 9 + 0); + const double* v1 = tri_verts + (j * 9 + 3); + const double* v2 = tri_verts + (j * 9 + 6); + + double cand_t = 0.0; + double cand_pt[3] = {0.0, 0.0, 0.0}; + if (intersect_segment_triangle(p0, p1, v0, v1, v2, &cand_t, cand_pt)) { + if (mode == 1) { // FIRST + *out_seg_idx = i; + *out_t = cand_t; + out_pt[0] = cand_pt[0]; + out_pt[1] = cand_pt[1]; + out_pt[2] = cand_pt[2]; + free(active_tris); + return 1; + } + + double dx = cand_pt[0] - rx; + double dy = cand_pt[1] - ry; + double dz = cand_pt[2] - rz; + double d_sq = dx * dx + dy * dy + dz * dz; + + if (!found || d_sq < best_dist_sq) { + found = 1; + best_dist_sq = d_sq; + best_seg = i; + best_t = cand_t; + best_pt[0] = cand_pt[0]; + best_pt[1] = cand_pt[1]; + best_pt[2] = cand_pt[2]; + } + } + } + } + } + + free(active_tris); + + if (found) { + *out_seg_idx = best_seg; + *out_t = best_t; + out_pt[0] = best_pt[0]; + out_pt[1] = best_pt[1]; + out_pt[2] = best_pt[2]; + return 1; + } + return 0; +} + +EXPORT int reposition_path_seam_c( + const double* in_pts, + int num_pts, + int stride, + int seg_idx, + double t, + const double* intersect_pt, + double* out_pts +) { + if (num_pts < 4) { + memcpy(out_pts, in_pts, num_pts * stride * sizeof(double)); + return num_pts; + } + int M = num_pts - 1; + + // Check snapping to existing vertices + if (t <= 1e-6) { + int k = seg_idx; + if (k == 0) { + memcpy(out_pts, in_pts, num_pts * stride * sizeof(double)); + return num_pts; + } + int split = M - k; + memcpy(out_pts, in_pts + (k * stride), split * stride * sizeof(double)); + memcpy(out_pts + (split * stride), in_pts, (k + 1) * stride * sizeof(double)); + return num_pts; + } + if (1.0 - t <= 1e-6) { + int next_k = (seg_idx + 1) % M; + if (next_k == 0) { + memcpy(out_pts, in_pts, num_pts * stride * sizeof(double)); + return num_pts; + } + int split = M - next_k; + memcpy(out_pts, in_pts + (next_k * stride), split * stride * sizeof(double)); + memcpy(out_pts + (split * stride), in_pts, (next_k + 1) * stride * sizeof(double)); + return num_pts; + } + + // Insert new vertex at intersect_pt + // Output: [intersect_pt, in_pts[k+1..M-1], in_pts[0..k], intersect_pt] + int k = seg_idx; + int out_idx = 0; + + // 1. First point: intersect_pt + for (int s = 0; s < stride; ++s) { + out_pts[out_idx * stride + s] = (s < 3) ? intersect_pt[s] : in_pts[k * stride + s]; + } + out_idx++; + + // 2. Points from k+1 to M-1 + int count_rest = M - (k + 1); + if (count_rest > 0) { + memcpy(out_pts + (out_idx * stride), in_pts + ((k + 1) * stride), count_rest * stride * sizeof(double)); + out_idx += count_rest; + } + + // 3. Points from 0 to k + int count_prefix = k + 1; + memcpy(out_pts + (out_idx * stride), in_pts, count_prefix * stride * sizeof(double)); + out_idx += count_prefix; + + // 4. Last point: intersect_pt + for (int s = 0; s < stride; ++s) { + out_pts[out_idx * stride + s] = (s < 3) ? intersect_pt[s] : in_pts[k * stride + s]; + } + out_idx++; + + return out_idx; +} diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index 5363ebd..2647290 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -579,3 +579,53 @@ class SLICENODE_SimplifyPaths(SliceNode): return paths return paths.simplify(tolerance, preserve_topology=self.preserve_topology) + + +@register_class +class SLICENODE_SeamPlacement(SliceNode): + bl_label = "Seam Placement" + + mode: str = NfEnumProperty( + name="Mode", + items=[ + ('CLOSEST', "Closest to Seam", "Reposition seam to intersection nearest to existing seam"), + ('FIRST', "First Intersection", "Reposition seam to first intersection along path traversal"), + ('CENTROID', "Mesh Centroid", "Reposition seam to intersection nearest to mesh center"), + ], + default='CLOSEST', + update=notify_tree_changed + ) + + acquire_transforms: bool = NfBoolProperty( + name="Acquire Transforms", + description="Apply transforms from paths metadata to the cutting mesh so they line up as in the viewport", + default=True, + update=notify_tree_changed + ) + + def init(self, context): + self.add_input("SLICESOCK_Paths") + self.add_input("SLICESOCK_Mesh") + self.add_output("SLICESOCK_Paths") + + def draw_buttons(self, context, layout): + assert layout + layout.prop(self, "mode", text="") + layout.prop(self, "acquire_transforms") + + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + if socket_name != "Paths": + return SlicePaths() + + paths: SlicePaths = self.resolve("Paths") + mesh_input: Any = self.resolve("Mesh") + + if not paths: + return SlicePaths() + + if not mesh_input: + return paths + + acquire_t = bool(getattr(self, "acquire_transforms", True)) + return paths.place_seams(mesh_input, mode=self.mode.lower(), acquire_transforms=acquire_t) + diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index 66a0c0d..76518c0 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -463,6 +463,42 @@ class SLICENODE_OT_Execute(Operator): return {"FINISHED"} +@register_class +class SLICENODE_OT_DebugDump(Operator): + bl_idname = "slicenode.debug_dump" + bl_label = "Debug Dump Node" + bl_description = "Capture and dump the selected node's inputs and state to disk for review" + + node_name: str = cast(Any, bpy.props.StringProperty(default="")) + + def execute(self, context) -> set['OperatorReturnItems']: + node_tree = getattr(context.space_data, "edit_tree", None) # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] + if not node_tree: + self.report({"WARNING"}, "No active node tree") + return {"CANCELLED"} + + node = None + if self.node_name: + node = node_tree.nodes.get(self.node_name) + if not node and hasattr(node_tree.nodes, "active"): + node = node_tree.nodes.active + + if not node: + self.report({"WARNING"}, "No node selected to debug") + return {"CANCELLED"} + + try: + from ...infra.debug import dump_node_execution, get_debug_dump_dir + dump_node_execution(node, "Output", output_val=None, duration_s=0.0) + dump_dir = get_debug_dump_dir() + self.report({"INFO"}, f"Dumped '{node.name}' inputs to {dump_dir / 'last_execution.txt'}") + except Exception as e: + self.report({"ERROR"}, f"Failed to dump node: {e}") + import traceback + traceback.print_exc() + + return {"FINISHED"} + @register_class class SLICENODE_MT_DataInput(Menu): bl_idname = "SLICENODE_MT_DataInput" @@ -503,6 +539,7 @@ class SLICENODE_MT_Geometry(Menu): layout.operator("node.add_node", text="Get Paths").type = "SLICENODE_GetPaths" layout.separator() layout.operator("node.add_node", text="Slice Shells").type = "SLICENODE_SliceShells" + layout.operator("node.add_node", text="Seam Placement").type = "SLICENODE_SeamPlacement" layout.operator("node.add_node", text="Buffer Shells").type = "SLICENODE_BufferShells" layout.operator("node.add_node", text="Buffer Shells Multiple").type = "SLICENODE_BufferShellsMultiple" layout.operator("node.add_node", text="Buffer Curve").type = "SLICENODE_BufferCurve" diff --git a/core/slice.py b/core/slice.py index cc0af37..c80492a 100644 --- a/core/slice.py +++ b/core/slice.py @@ -152,6 +152,58 @@ try: ctypes.c_int ] _c_seam_lib.polygon_signed_area_2d_c.restype = ctypes.c_double + + if hasattr(_c_seam_lib, "find_path_mesh_intersection_c"): + _c_seam_lib.find_path_mesh_intersection_c.argtypes = [ + ctypes.POINTER(ctypes.c_double), + ctypes.c_int, + ctypes.c_int, + ctypes.POINTER(ctypes.c_double), + ctypes.c_int, + ctypes.c_int, + ctypes.POINTER(ctypes.c_double), + ctypes.POINTER(ctypes.c_int), + ctypes.POINTER(ctypes.c_double), + ctypes.POINTER(ctypes.c_double) + ] + _c_seam_lib.find_path_mesh_intersection_c.restype = ctypes.c_int + + if hasattr(_c_seam_lib, "reposition_path_seam_c"): + _c_seam_lib.reposition_path_seam_c.argtypes = [ + ctypes.POINTER(ctypes.c_double), + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, + ctypes.c_double, + ctypes.POINTER(ctypes.c_double), + ctypes.POINTER(ctypes.c_double) + ] + _c_seam_lib.reposition_path_seam_c.restype = ctypes.c_int + + if hasattr(_c_seam_lib, "slice_triangles_at_z_c"): + _c_seam_lib.slice_triangles_at_z_c.argtypes = [ + ctypes.POINTER(ctypes.c_double), + ctypes.c_int, + ctypes.c_double, + ctypes.POINTER(ctypes.c_double) + ] + _c_seam_lib.slice_triangles_at_z_c.restype = ctypes.c_int + + if hasattr(_c_seam_lib, "intersect_path_with_2d_segments_c"): + _c_seam_lib.intersect_path_with_2d_segments_c.argtypes = [ + ctypes.POINTER(ctypes.c_double), + ctypes.c_int, + ctypes.c_int, + ctypes.POINTER(ctypes.c_double), + ctypes.c_int, + ctypes.c_double, + ctypes.c_int, + ctypes.POINTER(ctypes.c_double), + ctypes.POINTER(ctypes.c_int), + ctypes.POINTER(ctypes.c_double), + ctypes.POINTER(ctypes.c_double) + ] + _c_seam_lib.intersect_path_with_2d_segments_c.restype = ctypes.c_int except Exception: _c_seam_lib = None @@ -262,6 +314,661 @@ def align_path_seam_sharpest(path: np.ndarray) -> np.ndarray: return res +def extract_mesh_triangles(mesh_input: Any) -> np.ndarray: + """ + Extracts 3D triangle vertices (F, 3, 3) from a SlicingObject, SlicingCollection, + SlicingGroup, list of geometries, bpy Object, or raw numpy array. + """ + if mesh_input is None: + return np.empty((0, 3, 3), dtype=np.float64) + + if isinstance(mesh_input, np.ndarray): + if mesh_input.ndim == 3 and mesh_input.shape[1] >= 3 and mesh_input.shape[2] == 3: + return np.ascontiguousarray(mesh_input[:, :3, :], dtype=np.float64) + return np.empty((0, 3, 3), dtype=np.float64) + + objects = [] + if hasattr(mesh_input, "collections"): # SlicingGroup + for c in mesh_input.collections.values(): + objects.extend(getattr(c, "objects", [])) + elif hasattr(mesh_input, "objects"): # SlicingCollection + objects.extend(mesh_input.objects) + elif hasattr(mesh_input, "mesh"): # SlicingObject + objects.append(mesh_input) + elif isinstance(mesh_input, list): + for item in mesh_input: + if hasattr(item, "collections"): + for c in item.collections.values(): + objects.extend(getattr(c, "objects", [])) + elif hasattr(item, "objects"): + objects.extend(item.objects) + elif hasattr(item, "mesh"): + objects.append(item) + elif hasattr(mesh_input, "type") and getattr(mesh_input, "type") == 'MESH': + from ..infra.blender.blender_mesh_capture import SlicingObject + objects.append(SlicingObject(mesh_input)) + + if not objects: + return np.empty((0, 3, 3), dtype=np.float64) + + valid_meshes = [o.mesh[:, :3, :] for o in objects if hasattr(o, "mesh") and o.mesh is not None and o.mesh.size > 0] + if not valid_meshes: + return np.empty((0, 3, 3), dtype=np.float64) + + return np.ascontiguousarray(np.concatenate(valid_meshes, axis=0), dtype=np.float64) + + +def extract_mesh_transforms(mesh_input: Any) -> list[dict[str, Any]]: + """ + Extracts accumulated transform dicts from a SlicingObject, SlicingCollection, + SlicingGroup, or list of objects. + """ + if mesh_input is None: + return [] + if hasattr(mesh_input, "transforms") and isinstance(mesh_input.transforms, list) and mesh_input.transforms: + return [dict(t) for t in mesh_input.transforms if isinstance(t, dict)] + if hasattr(mesh_input, "collections"): + for c in mesh_input.collections.values(): + if hasattr(c, "transforms") and c.transforms: + return [dict(t) for t in c.transforms if isinstance(t, dict)] + for o in getattr(c, "objects", []): + if hasattr(o, "transforms") and o.transforms: + return [dict(t) for t in o.transforms if isinstance(t, dict)] + if hasattr(mesh_input, "objects"): + for o in mesh_input.objects: + if hasattr(o, "transforms") and o.transforms: + return [dict(t) for t in o.transforms if isinstance(t, dict)] + if isinstance(mesh_input, list): + for item in mesh_input: + trans = extract_mesh_transforms(item) + if trans: + return trans + return [] + + +def compute_delta_transforms( + path_transforms: list[dict[str, Any]] | None, + mesh_transforms: list[dict[str, Any]] | None +) -> list[dict[str, Any]]: + """ + Computes the difference between path_transforms and mesh_transforms, + so that only missing transforms are applied to the cutting mesh. + Prevents double-transformation when the mesh was already transformed upstream. + """ + if not path_transforms: + return [] + if not mesh_transforms: + return path_transforms + + # Consolidate path transforms + p_t = [0.0, 0.0, 0.0] + p_r = [0.0, 0.0, 0.0] + p_s = [1.0, 1.0, 1.0] + p_angle_deg = 0.0 + p_center = [0.0, 0.0, 0.0] + + for t in path_transforms: + if not isinstance(t, dict): + continue + tr = t.get("translation", t.get("offset", (0.0, 0.0, 0.0))) + rot = t.get("rotation", (0.0, 0.0, 0.0)) + sc = t.get("scale", (1.0, 1.0, 1.0)) + p_t[0] += float(tr[0]) + p_t[1] += float(tr[1]) + p_t[2] += float(tr[2]) + p_r[0] += float(rot[0]) + p_r[1] += float(rot[1]) + p_r[2] += float(rot[2]) + p_s[0] *= float(sc[0]) + p_s[1] *= float(sc[1]) + p_s[2] *= float(sc[2]) + if "angle_deg" in t: + p_angle_deg += float(t["angle_deg"]) + if "center" in t: + p_center = [float(v) for v in t["center"]] + + # Consolidate mesh transforms + m_t = [0.0, 0.0, 0.0] + m_r = [0.0, 0.0, 0.0] + m_s = [1.0, 1.0, 1.0] + m_angle_deg = 0.0 + m_center = [0.0, 0.0, 0.0] + + for t in mesh_transforms: + if not isinstance(t, dict): + continue + tr = t.get("translation", t.get("offset", (0.0, 0.0, 0.0))) + rot = t.get("rotation", (0.0, 0.0, 0.0)) + sc = t.get("scale", (1.0, 1.0, 1.0)) + m_t[0] += float(tr[0]) + m_t[1] += float(tr[1]) + m_t[2] += float(tr[2]) + m_r[0] += float(rot[0]) + m_r[1] += float(rot[1]) + m_r[2] += float(rot[2]) + m_s[0] *= float(sc[0]) + m_s[1] *= float(sc[1]) + m_s[2] *= float(sc[2]) + if "angle_deg" in t: + m_angle_deg += float(t["angle_deg"]) + if "center" in t: + m_center = [float(v) for v in t["center"]] + + # Compute deltas + d_t = (p_t[0] - m_t[0], p_t[1] - m_t[1], p_t[2] - m_t[2]) + d_r = (p_r[0] - m_r[0], p_r[1] - m_r[1], p_r[2] - m_r[2]) + d_s = ( + p_s[0] / m_s[0] if abs(m_s[0]) > 1e-12 else 1.0, + p_s[1] / m_s[1] if abs(m_s[1]) > 1e-12 else 1.0, + p_s[2] / m_s[2] if abs(m_s[2]) > 1e-12 else 1.0, + ) + d_angle = p_angle_deg - m_angle_deg + + delta_transforms: list[dict[str, Any]] = [] + + has_scale = any(abs(v - 1.0) > 1e-6 for v in d_s) + has_rot = any(abs(v) > 1e-6 for v in d_r) + has_trans = any(abs(v) > 1e-6 for v in d_t) + has_angle = abs(d_angle) > 1e-6 + + if has_scale or has_rot or has_trans or has_angle: + dt: dict[str, Any] = {"type": "transform"} + if has_scale: + dt["scale"] = d_s + if has_rot: + dt["rotation"] = d_r + if has_trans: + dt["translation"] = d_t + if has_angle: + dt["angle_deg"] = d_angle + dt["center"] = p_center + delta_transforms.append(dt) + + return delta_transforms + + +def apply_transforms_to_triangles( + tris: np.ndarray, + transforms: list[dict[str, Any]] | None +) -> np.ndarray: + """ + Applies a list of transform dicts (translation, rotation, scale, angle_deg) + to a 3D triangle array of shape (F, 3, 3) or (F, 4, 3). + """ + if tris.size == 0 or not transforms: + return tris + + out_tris = tris.copy() + for t in transforms: + if not isinstance(t, dict): + continue + + # 1. Scale + if "scale" in t: + sc = t["scale"] + if any(float(v) != 1.0 for v in sc): + s_arr = np.array(sc, dtype=np.float64) + out_tris[:, :3, :] *= s_arr + + # 2. Rotation + if "angle_deg" in t: + ang_deg = float(t["angle_deg"]) + if ang_deg != 0.0: + rad = math.radians(ang_deg) + cos_a = math.cos(rad) + sin_a = math.sin(rad) + center = t.get("center", (0.0, 0.0, 0.0)) + c_arr = np.array(center, dtype=np.float64)[:2] + + pts_2d = out_tris[:, :3, :2] - c_arr + rot_mat = np.array([[cos_a, -sin_a], [sin_a, cos_a]], dtype=np.float64) + out_tris[:, :3, :2] = (pts_2d @ rot_mat.T) + c_arr + elif "rotation" in t: + rot = t["rotation"] + if any(float(v) != 0.0 for v in rot): + from .geometry import euler_rotation_matrix + R = euler_rotation_matrix(float(rot[0]), float(rot[1]), float(rot[2])) + center = t.get("center", None) + if center is not None: + c_arr = np.array(center, dtype=np.float64) + out_tris[:, :3, :] = (out_tris[:, :3, :] - c_arr) @ R.T + c_arr + else: + out_tris[:, :3, :] = out_tris[:, :3, :] @ R.T + + # 3. Translation / Offset + tr = t.get("translation", t.get("offset", None)) + if tr is not None and any(float(v) != 0.0 for v in tr): + t_arr = np.array(tr, dtype=np.float64) + out_tris[:, :3, :] += t_arr + + return out_tris + + +def _intersect_segments_2d_py(a: tuple[float, float], b: tuple[float, float], c: tuple[float, float], d: tuple[float, float]) -> float | None: + denom = (b[0] - a[0]) * (d[1] - c[1]) - (b[1] - a[1]) * (d[0] - c[0]) + if abs(denom) < 1e-12: + return None + t = ((c[0] - a[0]) * (d[1] - c[1]) - (c[1] - a[1]) * (d[0] - c[0])) / denom + u = ((c[0] - a[0]) * (b[1] - a[1]) - (c[1] - a[1]) * (b[0] - a[0])) / denom + if -1e-6 <= t <= 1.0 + 1e-6 and -1e-6 <= u <= 1.0 + 1e-6: + return max(0.0, min(1.0, t)) + return None + + +def _intersect_segment_triangle_py(p0: np.ndarray, p1: np.ndarray, v0: np.ndarray, v1: np.ndarray, v2: np.ndarray) -> tuple[float, np.ndarray] | None: + e1 = v1 - v0 + e2 = v2 - v0 + n = np.cross(e1, e2) + n_len_sq = float(np.dot(n, n)) + if n_len_sq < 1e-14: + return None + + d0 = float(np.dot(p0 - v0, n)) + d1 = float(np.dot(p1 - v0, n)) + + if (d0 > 1e-6 and d1 > 1e-6) or (d0 < -1e-6 and d1 < -1e-6): + return None + + denom = d0 - d1 + if abs(denom) > 1e-12: + t = d0 / denom + if t < -1e-6 or t > 1.0 + 1e-6: + return None + t = max(0.0, min(1.0, t)) + ipt = p0 + t * (p1 - p0) + + c0 = np.cross(v1 - v0, ipt - v0) + c1 = np.cross(v2 - v1, ipt - v1) + c2 = np.cross(v0 - v2, ipt - v2) + + tol = -1e-6 * n_len_sq + if np.dot(c0, n) >= tol and np.dot(c1, n) >= tol and np.dot(c2, n) >= tol: + return t, ipt + elif abs(d0) < 1e-6: + # Coplanar segment - project along dominant normal axis + ax0, ax1 = 0, 1 + if abs(n[0]) >= abs(n[1]) and abs(n[0]) >= abs(n[2]): + ax0, ax1 = 1, 2 + elif abs(n[1]) >= abs(n[0]) and abs(n[1]) >= abs(n[2]): + ax0, ax1 = 0, 2 + verts = [v0, v1, v2] + best_t = None + for e in range(3): + va = verts[e] + vb = verts[(e + 1) % 3] + t_cand = _intersect_segments_2d_py( + (float(p0[ax0]), float(p0[ax1])), (float(p1[ax0]), float(p1[ax1])), + (float(va[ax0]), float(va[ax1])), (float(vb[ax0]), float(vb[ax1])) + ) + if t_cand is not None: + if best_t is None or t_cand < best_t: + best_t = t_cand + if best_t is not None: + return best_t, p0 + best_t * (p1 - p0) + return None + + +def _slice_triangles_at_z_np(active_tris: np.ndarray, pz: float) -> np.ndarray: + if len(active_tris) == 0: + return np.empty((0, 4), dtype=np.float64) + + v0 = active_tris[:, 0, :] + v1 = active_tris[:, 1, :] + v2 = active_tris[:, 2, :] + + edges = [(v0, v1), (v1, v2), (v2, v0)] + pts_list = [] + for va, vb in edges: + dza = va[:, 2] - pz + dzb = vb[:, 2] - pz + cross = (dza * dzb <= 0) & (np.abs(vb[:, 2] - va[:, 2]) > 1e-12) + denom = np.where(np.abs(vb[:, 2] - va[:, 2]) > 1e-12, vb[:, 2] - va[:, 2], 1.0) + u = np.clip((pz - va[:, 2]) / denom, 0.0, 1.0) + qx = va[:, 0] + u * (vb[:, 0] - va[:, 0]) + qy = va[:, 1] + u * (vb[:, 1] - va[:, 1]) + q = np.stack([qx, qy], axis=-1) + pts_list.append((cross, q)) + + c0, q0 = pts_list[0] + c1, q1 = pts_list[1] + c2, q2 = pts_list[2] + + segs = [] + for idx in range(len(active_tris)): + p_cand = [] + if c0[idx]: + p_cand.append(q0[idx]) + if c1[idx] and (len(p_cand) == 0 or np.linalg.norm(q1[idx] - p_cand[0]) > 1e-6): + p_cand.append(q1[idx]) + if c2[idx] and (len(p_cand) == 0 or (np.linalg.norm(q2[idx] - p_cand[0]) > 1e-6 and (len(p_cand) < 2 or np.linalg.norm(q2[idx] - p_cand[1]) > 1e-6))): + p_cand.append(q2[idx]) + if len(p_cand) >= 2: + segs.append([p_cand[0][0], p_cand[0][1], p_cand[1][0], p_cand[1][1]]) + + if not segs: + return np.empty((0, 4), dtype=np.float64) + return np.array(segs, dtype=np.float64) + + +def _intersect_path_2d_segs_np( + path: np.ndarray, + mesh_segs: np.ndarray, + pz: float, + mode_int: int, + ref_pt: np.ndarray +) -> tuple[int, float, np.ndarray] | None: + if len(path) < 4 or len(mesh_segs) == 0: + return None + pts = path[:, :3] + P_start = pts[:-1, :2] # (M, 2) + P_end = pts[1:, :2] # (M, 2) + + Q_start = mesh_segs[:, :2] # (K, 2) + Q_end = mesh_segs[:, 2:] # (K, 2) + + A = P_start[:, np.newaxis, :] # (M, 1, 2) + B = P_end[:, np.newaxis, :] # (M, 1, 2) + C = Q_start[np.newaxis, :, :] # (1, K, 2) + D = Q_end[np.newaxis, :, :] # (1, K, 2) + + dx_AB = B[..., 0] - A[..., 0] + dy_AB = B[..., 1] - A[..., 1] + dx_CD = D[..., 0] - C[..., 0] + dy_CD = D[..., 1] - C[..., 1] + dx_AC = C[..., 0] - A[..., 0] + dy_AC = C[..., 1] - A[..., 1] + + denom = dx_AB * dy_CD - dy_AB * dx_CD + valid = np.abs(denom) > 1e-12 + + denom_safe = np.where(valid, denom, 1.0) + t_mat = (dx_AC * dy_CD - dy_AC * dx_CD) / denom_safe + u_mat = (dx_AC * dy_AB - dy_AC * dx_AB) / denom_safe + + hit_mask = valid & (t_mat >= -1e-6) & (t_mat <= 1.0 + 1e-6) & (u_mat >= -1e-6) & (u_mat <= 1.0 + 1e-6) + hit_m, hit_k = np.where(hit_mask) + + if len(hit_m) > 0: + t_vals = np.clip(t_mat[hit_m, hit_k], 0.0, 1.0) + p0_hits = pts[hit_m] + p1_hits = pts[hit_m + 1] + ipts = p0_hits + t_vals[:, np.newaxis] * (p1_hits - p0_hits) + + if mode_int == 1: # FIRST + return int(hit_m[0]), float(t_vals[0]), ipts[0] + + dists_sq = np.sum((ipts - ref_pt) ** 2, axis=1) + best_idx = int(np.argmin(dists_sq)) + return int(hit_m[best_idx]), float(t_vals[best_idx]), ipts[best_idx] + + return None + + +def slice_triangles_at_z(tris: np.ndarray, pz: float) -> np.ndarray: + """ + Slices a 3D triangle array (F, 3, 3) or (F, 4, 3) at a specific Z height. + Returns 2D line segments of shape (K, 4) where each segment is [x0, y0, x1, y1]. + """ + if len(tris) == 0: + return np.empty((0, 4), dtype=np.float64) + + if _c_seam_lib is not None and hasattr(_c_seam_lib, "slice_triangles_at_z_c"): + try: + tris_c = np.ascontiguousarray(tris[:, :3, :], dtype=np.float64) + num_tris = len(tris_c) + out_segs = np.empty((num_tris, 4), dtype=np.float64) + num_segs = _c_seam_lib.slice_triangles_at_z_c( + tris_c.ctypes.data_as(ctypes.POINTER(ctypes.c_double)), + num_tris, + ctypes.c_double(pz), + out_segs.ctypes.data_as(ctypes.POINTER(ctypes.c_double)) + ) + return out_segs[:num_segs] + except Exception: + pass + + return _slice_triangles_at_z_np(tris, pz) + + +def intersect_path_with_2d_segments( + path: np.ndarray, + mesh_segs: np.ndarray, + pz: float, + mode: str = "closest", + mesh_center: np.ndarray | None = None +) -> tuple[int, float, np.ndarray] | None: + """ + Intersects a closed planar path with pre-computed 2D line segments at height pz. + Returns (segment_index, t_parameter, intersection_point) or None. + """ + if len(path) < 4 or len(mesh_segs) == 0: + return None + + pts = np.ascontiguousarray(path[:, :3], dtype=np.float64) + segs = np.ascontiguousarray(mesh_segs, dtype=np.float64) + num_pts = len(pts) + num_segs = len(segs) + + mode_int = 0 + if mode.lower() == "first": + mode_int = 1 + elif mode.lower() == "centroid": + mode_int = 2 + + ref_pt = pts[0] + if mode_int == 2: + ref_pt = mesh_center if mesh_center is not None else np.array([0.0, 0.0, pz], dtype=np.float64) + ref_pt_c = np.ascontiguousarray(ref_pt, dtype=np.float64) + + if _c_seam_lib is not None and hasattr(_c_seam_lib, "intersect_path_with_2d_segments_c"): + try: + out_seg = ctypes.c_int(-1) + out_t = ctypes.c_double(0.0) + out_pt = (ctypes.c_double * 3)() + + found = _c_seam_lib.intersect_path_with_2d_segments_c( + pts.ctypes.data_as(ctypes.POINTER(ctypes.c_double)), + num_pts, + 3, + segs.ctypes.data_as(ctypes.POINTER(ctypes.c_double)), + num_segs, + ctypes.c_double(pz), + mode_int, + ref_pt_c.ctypes.data_as(ctypes.POINTER(ctypes.c_double)), + ctypes.byref(out_seg), + ctypes.byref(out_t), + out_pt + ) + if found: + return int(out_seg.value), float(out_t.value), np.array([out_pt[0], out_pt[1], out_pt[2]], dtype=np.float64) + return None + except Exception: + pass + + return _intersect_path_2d_segs_np(pts, segs, pz, mode_int, ref_pt_c) + + +def find_path_mesh_intersection( + path: np.ndarray, + tri_verts: np.ndarray, + mode: str = "closest", + mesh_center: np.ndarray | None = None +) -> tuple[int, float, np.ndarray] | None: + """ + Finds intersection between a closed polygon path and a mesh of triangles. + Returns (segment_index, t_parameter, intersection_point) or None. + """ + if len(path) < 4 or not is_closed_path(path) or len(tri_verts) == 0: + return None + + pts = np.ascontiguousarray(path[:, :3], dtype=np.float64) + tris = np.ascontiguousarray(tri_verts[:, :3, :], dtype=np.float64) + num_pts = len(pts) + num_tris = len(tris) + stride = 3 + + mode_int = 0 + if mode.lower() == "first": + mode_int = 1 + elif mode.lower() == "centroid": + mode_int = 2 + + ref_pt = pts[0] + if mode_int == 2: + ref_pt = mesh_center if mesh_center is not None else np.mean(tris.reshape(-1, 3), axis=0) + ref_pt_c = np.ascontiguousarray(ref_pt, dtype=np.float64) + + if _c_seam_lib is not None and hasattr(_c_seam_lib, "find_path_mesh_intersection_c"): + try: + out_seg = ctypes.c_int(-1) + out_t = ctypes.c_double(0.0) + out_pt = (ctypes.c_double * 3)() + + found = _c_seam_lib.find_path_mesh_intersection_c( + pts.ctypes.data_as(ctypes.POINTER(ctypes.c_double)), + num_pts, + stride, + tris.ctypes.data_as(ctypes.POINTER(ctypes.c_double)), + num_tris, + mode_int, + ref_pt_c.ctypes.data_as(ctypes.POINTER(ctypes.c_double)), + ctypes.byref(out_seg), + ctypes.byref(out_t), + out_pt + ) + if found: + return int(out_seg.value), float(out_t.value), np.array([out_pt[0], out_pt[1], out_pt[2]], dtype=np.float64) + return None + except Exception: + pass + + # 2. Vectorized / Planar Fallback + p_min = np.min(pts, axis=0) - 1e-4 + p_max = np.max(pts, axis=0) + 1e-4 + + t_min = np.min(tris, axis=1) + t_max = np.max(tris, axis=1) + + active_mask = np.all(t_max >= p_min, axis=1) & np.all(t_min <= p_max, axis=1) + if not np.any(active_mask): + return None + + active_tris = tris[active_mask] + pz = pts[0, 2] + is_planar = (p_max[2] - p_min[2]) < 1e-3 + + if is_planar: + segs = slice_triangles_at_z(active_tris, pz) + if len(segs) > 0: + return intersect_path_with_2d_segments(path, segs, pz, mode=mode, mesh_center=mesh_center) + return None + + # 3D fallback for non-planar path + best_hit = None + best_dist = 1e30 + M = len(pts) - 1 + for i in range(M): + p0 = pts[i] + p1 = pts[i + 1] + for tr in active_tris: + hit = _intersect_segment_triangle_py(p0, p1, tr[0], tr[1], tr[2]) + if hit is not None: + t_cand, ipt = hit + if mode_int == 1: + return i, t_cand, ipt + d_sq = float(np.sum((ipt - ref_pt) ** 2)) + if d_sq < best_dist: + best_dist = d_sq + best_hit = (i, t_cand, ipt) + return best_hit + + +def reposition_path_seam( + path: np.ndarray, + seg_idx: int, + t: float, + intersect_pt: np.ndarray, + 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]: + """ + Reposition the seam of a closed path at the given intersection (seg_idx, t, intersect_pt). + Properly rotates/interpolates per-vertex attributes (extrusion_rate, feedrate, extra_attributes). + """ + if len(path) < 4: + return path, extrusion_rate, feedrate, extra_attributes + + M = len(path) - 1 + k = seg_idx + + # Check snapping + if t <= 1e-6: + if k == 0: + return path, extrusion_rate, feedrate, extra_attributes + new_p = np.concatenate([path[k:-1], path[:k+1]], axis=0) + new_ext = np.concatenate([extrusion_rate[k:-1], extrusion_rate[:k+1]]) if extrusion_rate is not None and len(extrusion_rate) == len(path) else extrusion_rate + new_feed = np.concatenate([feedrate[k:-1], feedrate[:k+1]]) if feedrate is not None and len(feedrate) == len(path) else feedrate + new_extra = {} + if extra_attributes: + for attr_name, attr_arr in extra_attributes.items(): + if len(attr_arr) == len(path): + new_extra[attr_name] = np.concatenate([attr_arr[k:-1], attr_arr[:k+1]]) + else: + new_extra[attr_name] = attr_arr + return new_p, new_ext, new_feed, new_extra if extra_attributes else extra_attributes + + if 1.0 - t <= 1e-6: + next_k = (k + 1) % M + if next_k == 0: + return path, extrusion_rate, feedrate, extra_attributes + new_p = np.concatenate([path[next_k:-1], path[:next_k+1]], axis=0) + new_ext = np.concatenate([extrusion_rate[next_k:-1], extrusion_rate[:next_k+1]]) if extrusion_rate is not None and len(extrusion_rate) == len(path) else extrusion_rate + new_feed = np.concatenate([feedrate[next_k:-1], feedrate[:next_k+1]]) if feedrate is not None and len(feedrate) == len(path) else feedrate + new_extra = {} + if extra_attributes: + for attr_name, attr_arr in extra_attributes.items(): + if len(attr_arr) == len(path): + new_extra[attr_name] = np.concatenate([attr_arr[next_k:-1], attr_arr[:next_k+1]]) + else: + new_extra[attr_name] = attr_arr + return new_p, new_ext, new_feed, new_extra if extra_attributes else extra_attributes + + # Insert intersection point I + I_pt = np.array(intersect_pt, dtype=path.dtype) + if I_pt.shape != path[0].shape: + I_full = np.zeros_like(path[0]) + I_full[:min(len(I_full), len(I_pt))] = I_pt[:min(len(I_full), len(I_pt))] + I_pt = I_full + + new_p = np.concatenate([[I_pt], path[k+1:-1], path[:k+1], [I_pt]], axis=0) + + new_ext = None + if extrusion_rate is not None and len(extrusion_rate) == len(path): + ext_I = (1.0 - t) * extrusion_rate[k] + t * extrusion_rate[k + 1] + new_ext = np.concatenate([[ext_I], extrusion_rate[k+1:-1], extrusion_rate[:k+1], [ext_I]]) + else: + new_ext = extrusion_rate + + new_feed = None + if feedrate is not None and len(feedrate) == len(path): + feed_I = (1.0 - t) * feedrate[k] + t * feedrate[k + 1] + new_feed = np.concatenate([[feed_I], feedrate[k+1:-1], feedrate[:k+1], [feed_I]]) + else: + new_feed = feedrate + + new_extra = {} + if extra_attributes: + for attr_name, attr_arr in extra_attributes.items(): + if len(attr_arr) == len(path): + attr_I = (1.0 - t) * attr_arr[k] + t * attr_arr[k + 1] + new_extra[attr_name] = np.concatenate([[attr_I], attr_arr[k+1:-1], attr_arr[:k+1], [attr_I]]) + else: + new_extra[attr_name] = attr_arr + + return new_p, new_ext, new_feed, new_extra if extra_attributes else extra_attributes + + class SlicePaths(list[np.ndarray]): groups: list[int] @@ -728,6 +1435,171 @@ class SlicePaths(list[np.ndarray]): return self.copy_with(new_paths, extrusion_rate=new_ext, feedrate=new_feed, extra_attributes=new_extra) + from ..infra.debug import debug + @debug + def place_seams(self, mesh_input: Any, mode: str = "closest", acquire_transforms: bool = True) -> 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 not self: + return self + + tris = extract_mesh_triangles(mesh_input) + if len(tris) == 0: + 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) + + 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 + + n_paths = len(self) + res_paths: list[np.ndarray | None] = [None] * n_paths + res_ext: list[np.ndarray | None] = [None] * n_paths + res_feed: list[np.ndarray | None] = [None] * n_paths + res_extra: dict[str, list[np.ndarray | None]] = {k: [None] * n_paths for k in self.extra_attributes} + + # Group planar paths by layer integer micron key + z_to_indices: dict[int, list[int]] = {} + non_planar_indices: list[int] = [] + + for i, p in enumerate(self): + if len(p) < 4 or not is_closed_path(p): + 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 + + 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(): + 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] + + 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_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: + for k in self.extra_attributes: + res_extra[k][idx] = extra_mod.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: + 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_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: + for k in self.extra_attributes: + res_extra[k][idx] = extra_mod.get(k, self.extra_attributes[k][idx]) + else: + for k in self.extra_attributes: + res_extra[k][idx] = self.extra_attributes[k][idx] + + return self.copy_with( + res_paths, # type: ignore[arg-type] + extrusion_rate=res_ext, # type: ignore[arg-type] + feedrate=res_feed, # type: ignore[arg-type] + extra_attributes=res_extra # type: ignore[arg-type] + ) + + def _slice_single_mesh(mesh_array: np.ndarray, z_values: np.ndarray | list[float], ignore_holes: bool = False) -> list[np.ndarray]: """ Slices a single 3D triangle mesh (shape (F, 4, 3) or (F, 3, 3)) across multiple Z heights. diff --git a/doc/MODULES.md b/doc/MODULES.md index 35dc015..ae20cc0 100644 --- a/doc/MODULES.md +++ b/doc/MODULES.md @@ -64,3 +64,11 @@ This file provides a mapping of modules to their primary responsibilities and ke - Base classes for NodeSlicer nodes (`SliceNode`) and sockets (`SliceNodeSocket`). - Native Blender socket type mappings (`NodeSocketFloat`, `NodeSocketInt`, `NodeSocketVector`, `NodeSocketRotation`, `NodeSocketString`, `NodeSocketObject`, `NodeSocketGeometry`). - Core evaluation logic (`resolve_input`). + - Profiling tools (`NodeProfiler`, `profile_node_execution`). + +- `debug.py`: + - `@debug`: Decorator for Node classes, `evaluate_output` methods, or functions to dump execution state, input sockets, properties, outputs, and errors. + - `dump_node_execution()`: Serializes node execution state to JSON, formatted text report, and Python pickle. + - `get_last_debug_dump()` / `get_last_debug_text()` / `load_last_inputs()`: Utilities to review and reload the most recent execution dump for debugging. + - `debug_session()`: Context manager enabling execution capture across node evaluations. + - `SLICENODE_OT_DebugDump`: Blender operator (`slicenode.debug_dump`) to dump the active node's inputs on demand. diff --git a/doc/NODES.md b/doc/NODES.md index 5c0c97f..ca25402 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -82,6 +82,9 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **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 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). + - **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. - **Buffer Shells Multiple**: Offsets 2D `SlicePaths` with multiple buffer passes. - **Outputs**: diff --git a/infra/blender/blender_slice_nodes_generic.py b/infra/blender/blender_slice_nodes_generic.py index 011281c..1d998ca 100644 --- a/infra/blender/blender_slice_nodes_generic.py +++ b/infra/blender/blender_slice_nodes_generic.py @@ -121,6 +121,11 @@ class SliceNode(Node, AutoBlIdName): def evaluate_output(self, socket_name: str, **_kwargs) -> Any: ... + def dump_inputs(self, socket_name: str = "Output") -> dict[str, Any]: + """Captures and dumps the node's current input socket values, properties, and execution report.""" + from ..debug import dump_node_execution + return dump_node_execution(self, socket_name, output_val=None, duration_s=0.0) + def resolve(self, fieldname) -> Any: cache = getattr(self, "_current_cache", None) visited = getattr(self, "_current_visited", None) @@ -683,15 +688,41 @@ def _resolve_output( return default assert socket.node.evaluate_output - value = socket.node.evaluate_output( - socket.name, - identifier=socket.identifier, - socket=socket, - cache=cache, - visited=visited - ) - cache[key] = value - return value + start_eval_time = time.perf_counter() + eval_exc = None + eval_tb = None + value = None + try: + value = socket.node.evaluate_output( + socket.name, + identifier=socket.identifier, + socket=socket, + cache=cache, + visited=visited + ) + cache[key] = value + return value + except Exception as e: + eval_exc = e + import traceback + eval_tb = traceback.format_exc() + raise + finally: + try: + from ..debug import _GLOBAL_DEBUG_ENABLED, dump_node_execution + if _GLOBAL_DEBUG_ENABLED or getattr(socket.node, "_debug_dump", False): + dur = time.perf_counter() - start_eval_time + dump_node_execution( + socket.node, + socket.name, + output_val=value if eval_exc is None else None, + duration_s=dur, + exception=eval_exc, + tb_str=eval_tb, + log_console=True + ) + except Exception: + pass finally: socket.node._current_cache = None # type: ignore socket.node._current_visited = None # type: ignore diff --git a/infra/debug.py b/infra/debug.py new file mode 100644 index 0000000..07c1a59 --- /dev/null +++ b/infra/debug.py @@ -0,0 +1,747 @@ +""" +NodeSlicer Debug Module +======================= +Provides tools to capture, serialize, and dump node execution state (inputs, properties, outputs, and errors) +for immediate review, automated diagnostics, and pair-programming debugging. + +Usage: + # 1. Decorate a Node class: + from ...infra.debug import debug + + @debug + @register_class + class SLICENODE_SeamPlacement(SliceNode): + ... + + # 2. Decorate evaluate_output or any method/function: + @debug + def evaluate_output(self, socket_name: str, **kwargs): + ... + + # 3. Retrieve or inspect the last execution dump: + from NodeSlicer.infra.debug import get_last_debug_dump, get_last_debug_text, load_last_inputs + dump = get_last_debug_dump() + print(get_last_debug_text()) +""" + +from __future__ import annotations + +import os +import sys +import json +import time +import pickle +import inspect +import datetime +import traceback +import functools +from pathlib import Path +from typing import Any, Callable, TypeVar, cast +import numpy as np + +# Global configuration +_DEFAULT_DUMP_DIR: Path | None = None +_LAST_DUMP_DICT: dict[str, Any] | None = None +_LAST_DUMP_TEXT: str | None = None +_GLOBAL_DEBUG_ENABLED: bool = False + + +def get_debug_dump_dir() -> Path: + """Returns the directory where debug dumps are stored, creating it if necessary.""" + global _DEFAULT_DUMP_DIR + if _DEFAULT_DUMP_DIR is not None: + _DEFAULT_DUMP_DIR.mkdir(parents=True, exist_ok=True) + return _DEFAULT_DUMP_DIR + + # Try project root + project_root = Path(__file__).resolve().parent.parent + dump_dir = project_root / "debug_dumps" + try: + dump_dir.mkdir(parents=True, exist_ok=True) + _DEFAULT_DUMP_DIR = dump_dir + return _DEFAULT_DUMP_DIR + except Exception: + # Fallback to current working directory or user home + fallback = Path.cwd() / "debug_dumps" + fallback.mkdir(parents=True, exist_ok=True) + _DEFAULT_DUMP_DIR = fallback + return _DEFAULT_DUMP_DIR + + +def set_debug_dump_dir(path: str | Path) -> None: + """Sets a custom directory for debug dumps.""" + global _DEFAULT_DUMP_DIR + p = Path(path).resolve() + p.mkdir(parents=True, exist_ok=True) + _DEFAULT_DUMP_DIR = p + + +def set_global_debug(enabled: bool = True) -> None: + """Enables or disables global debug capture for all nodes.""" + global _GLOBAL_DEBUG_ENABLED + _GLOBAL_DEBUG_ENABLED = enabled + + +class debug_session: + """Context manager to enable debug capture for a block of execution.""" + def __init__(self, dump_dir: str | Path | None = None): + self.prev_enabled = _GLOBAL_DEBUG_ENABLED + self.custom_dir = dump_dir + self.prev_dir = _DEFAULT_DUMP_DIR + + def __enter__(self): + global _GLOBAL_DEBUG_ENABLED + _GLOBAL_DEBUG_ENABLED = True + if self.custom_dir is not None: + set_debug_dump_dir(self.custom_dir) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + global _GLOBAL_DEBUG_ENABLED, _DEFAULT_DUMP_DIR + _GLOBAL_DEBUG_ENABLED = self.prev_enabled + if self.custom_dir is not None: + _DEFAULT_DUMP_DIR = self.prev_dir + + +def _serialize_for_json(val: Any, max_depth: int = 4) -> Any: + """Recursively converts objects to JSON-serializable structures with rich summaries.""" + if max_depth <= 0: + return f"" + + if val is None or isinstance(val, (bool, int, str)): + return val + + if isinstance(val, float): + if np.isnan(val): + return "NaN" + if np.isneginf(val): + return "-Infinity" + if np.isposinf(val): + return "Infinity" + return round(val, 6) + + val_cls_name = type(val).__name__ + + # SlicePaths (which is a list subclass) + if val_cls_name == "SlicePaths" or (hasattr(val, "layer_height") and hasattr(val, "extrusion_rate")): + num_paths = len(val) + total_verts = sum(len(p) for p in val) if num_paths > 0 else 0 + min_bounds: list[float] | None = None + max_bounds: list[float] | None = None + + if total_verts > 0: + try: + all_pts = np.concatenate([p[:, :3] for p in val if len(p) > 0 and p.ndim == 2], axis=0) + if len(all_pts) > 0: + min_bounds = [float(v) for v in np.min(all_pts, axis=0)] + max_bounds = [float(v) for v in np.max(all_pts, axis=0)] + except Exception: + pass + + return { + "__type__": "SlicePaths", + "num_paths": num_paths, + "total_vertices": total_verts, + "layer_height": getattr(val, "layer_height", None), + "first_layer_height": getattr(val, "first_layer_height", None), + "name": getattr(val, "name", ""), + "transforms": getattr(val, "transforms", []), + "groups_count": len(getattr(val, "groups", [])), + "extra_attributes": list(getattr(val, "extra_attributes", {}).keys()), + "bounding_box_min": min_bounds, + "bounding_box_max": max_bounds, + "path_vertex_counts": [len(p) for p in val[:10]] + (["..."] if num_paths > 10 else []) + } + + # SlicingObject / SlicingCollection / SlicingGroup + if val_cls_name in ("SlicingObject", "SlicingCollection", "SlicingGroup") or hasattr(val, "mesh") or hasattr(val, "objects") or hasattr(val, "collections"): + info = { + "__type__": val_cls_name, + "name": getattr(val, "name", ""), + "transforms": getattr(val, "transforms", []), + } + if hasattr(val, "mesh") and isinstance(val.mesh, np.ndarray): + info["triangles_count"] = len(val.mesh) + info["mesh_shape"] = list(val.mesh.shape) + if val.mesh.size > 0: + info["min_bounds"] = [getattr(val, "min_x", None), getattr(val, "min_y", None), getattr(val, "min_z", None)] + info["max_bounds"] = [getattr(val, "max_x", None), getattr(val, "max_y", None), getattr(val, "max_z", None)] + if hasattr(val, "objects") and isinstance(val.objects, list): + info["objects_count"] = len(val.objects) + info["object_names"] = [getattr(o, "name", str(idx)) for idx, o in enumerate(val.objects)] + if hasattr(val, "collections") and isinstance(val.collections, dict): + info["collections_count"] = len(val.collections) + info["collection_names"] = list(val.collections.keys()) + return info + + if isinstance(val, (list, tuple)): + # If it's a small list of simple numbers/strings + if len(val) <= 10 and all(isinstance(x, (int, float, bool, str)) for x in val): + return [_serialize_for_json(x, max_depth - 1) for x in val] + elif len(val) > 20: + return { + "__type__": type(val).__name__, + "length": len(val), + "preview": [_serialize_for_json(x, max_depth - 1) for x in val[:5]], + "summary": f"{type(val).__name__} with {len(val)} items" + } + return [_serialize_for_json(x, max_depth - 1) for x in val] + + if isinstance(val, dict): + return {str(k): _serialize_for_json(v, max_depth - 1) for k, v in val.items()} + + # NumPy Array + if isinstance(val, np.ndarray): + shape = list(val.shape) + dtype = str(val.dtype) + info: dict[str, Any] = { + "__type__": "numpy.ndarray", + "shape": shape, + "dtype": dtype, + "size": int(val.size) + } + if val.size > 0: + try: + if np.issubdtype(val.dtype, np.number): + info["min"] = float(np.min(val)) + info["max"] = float(np.max(val)) + info["mean"] = float(np.mean(val)) + except Exception: + pass + + if val.size <= 12: + info["data"] = val.tolist() + elif val.ndim == 2 and val.shape[1] in (2, 3) and val.shape[0] <= 10: + info["data"] = val.tolist() + else: + flat = val.ravel() + info["sample_first"] = flat[:4].tolist() + info["sample_last"] = flat[-4:].tolist() + return info + + # Blender ID Object + if hasattr(val, "name") and hasattr(val, "type") and hasattr(val, "rna_type"): + return { + "__type__": f"bpy.types.{type(val).__name__}", + "name": getattr(val, "name", ""), + "type": getattr(val, "type", ""), + } + + # Fallback to string representation + return f"<{val_cls_name}: {str(val)[:200]}>" + + +def _sanitize_for_pickle(val: Any) -> Any: + """Ensures complex objects with Blender C-types can be pickled safely.""" + try: + pickle.dumps(val) + return val + except Exception: + # If the object fails to pickle directly, convert to a clean dictionary / numpy structure + return _serialize_for_json(val) + + +def extract_node_properties(node: Any) -> dict[str, Any]: + """Extracts custom user properties and settings defined on a node.""" + props: dict[str, Any] = {} + ignored = { + "bl_idname", "bl_label", "bl_description", "bl_icon", "bl_width_default", "bl_width_min", "bl_width_max", + "bl_height_default", "bl_height_min", "bl_height_max", "inputs", "outputs", "dimensions", "location", + "width", "height", "select", "hide", "mute", "color", "use_custom_color", "show_options", "show_preview", + "show_texture", "type", "rna_type", "internal_links", "parent", "label", "_data", "_current_cache", + "_current_visited", "name" + } + + # Check class annotations + cls = type(node) + annotations = getattr(cls, "__annotations__", {}) + for key in annotations: + if key not in ignored and not key.startswith("_"): + try: + props[key] = getattr(node, key, None) + except Exception: + pass + + # Check custom properties in dict + for key, val in getattr(node, "__dict__", {}).items(): + if key not in ignored and not key.startswith("_"): + try: + props[key] = getattr(node, key, val) + except Exception: + pass + + return props + + +def extract_node_inputs(node: Any, evaluated_socket: str = "") -> dict[str, Any]: + """Resolves and captures all input sockets defined on the node.""" + inputs_data: dict[str, Any] = {} + if not hasattr(node, "inputs") or node.inputs is None: + return inputs_data + + for sock in node.inputs: + sock_name = sock.name + identifier = getattr(sock, "identifier", sock_name) + is_linked = getattr(sock, "is_linked", False) + + source_info = None + if is_linked and hasattr(sock, "links") and sock.links: + try: + from_sock = sock.links[0].from_socket + source_info = { + "node": from_sock.node.name if from_sock.node else "Unknown", + "socket": from_sock.name + } + except Exception: + source_info = "linked" + + # Resolve value + val = None + resolved = False + error_msg = None + + if hasattr(node, "resolve"): + try: + val = node.resolve(sock_name) + resolved = True + except Exception as e: + error_msg = f"Resolution error: {e}" + + if not resolved and hasattr(sock, "value"): + try: + val = sock.value + resolved = True + except Exception: + pass + + if not resolved and hasattr(sock, "default_value"): + try: + val = sock.default_value + resolved = True + except Exception: + pass + + inputs_data[sock_name] = { + "identifier": identifier, + "bl_idname": getattr(sock, "bl_idname", type(sock).__name__), + "is_linked": is_linked, + "source": source_info, + "value": val, + "error": error_msg + } + + return inputs_data + + +def format_debug_report(dump: dict[str, Any]) -> str: + """Formats a debug dump dictionary into a clean, human-readable text report.""" + lines: list[str] = [] + lines.append("=" * 80) + lines.append(f" NODESLICER DEBUG REPORT: {dump.get('node_name', 'Unknown Node')} ({dump.get('node_type', '')})") + lines.append("=" * 80) + lines.append(f"Timestamp: {dump.get('timestamp', '')}") + lines.append(f"Output Evaluated: {dump.get('socket_evaluated', '')}") + lines.append(f"Duration: {dump.get('duration_seconds', 0.0) * 1000.0:.2f} ms") + lines.append(f"Status: {dump.get('status', 'OK')}") + + if dump.get("exception"): + lines.append("") + lines.append("!" * 80) + lines.append(f" EXCEPTION: {dump.get('exception')}") + lines.append("!" * 80) + if dump.get("traceback"): + lines.append(dump["traceback"]) + + lines.append("") + lines.append("-" * 40 + " PROPERTIES " + "-" * 40) + props = dump.get("properties", {}) + if props: + for k, v in props.items(): + lines.append(f" • {k}: {v}") + else: + lines.append(" (None)") + + lines.append("") + lines.append("-" * 40 + " INPUT SOCKETS " + "-" * 40) + inputs = dump.get("inputs", {}) + if inputs: + for sock_name, sinfo in inputs.items(): + linked_str = f" [Linked from {sinfo.get('source', {}).get('node', '')}.{sinfo.get('source', {}).get('socket', '')}]" if sinfo.get("is_linked") else " [Unlinked]" + lines.append(f" ▶ Socket '{sock_name}' ({sinfo.get('bl_idname', '')}){linked_str}:") + if sinfo.get("error"): + lines.append(f" ERROR: {sinfo['error']}") + val_summary = sinfo.get("value_summary") + if isinstance(val_summary, dict): + for vk, vv in val_summary.items(): + lines.append(f" - {vk}: {vv}") + else: + lines.append(f" - Value: {val_summary}") + else: + lines.append(" (No input sockets)") + + lines.append("") + lines.append("-" * 40 + " OUTPUT SUMMARY " + "-" * 40) + out_summary = dump.get("output_summary") + if isinstance(out_summary, dict): + for ok, ov in out_summary.items(): + lines.append(f" • {ok}: {ov}") + else: + lines.append(f" • Result: {out_summary}") + + lines.append("=" * 80) + return "\n".join(lines) + + +def dump_node_execution( + node: Any, + socket_name: str, + output_val: Any = None, + duration_s: float = 0.0, + exception: Exception | None = None, + tb_str: str | None = None, + save_pickle: bool = True, + log_console: bool = True +) -> dict[str, Any]: + """ + Captures complete execution details of a node, writes JSON/TXT/PKL dumps, + and updates the in-memory last execution record. + """ + global _LAST_DUMP_DICT, _LAST_DUMP_TEXT + + now_iso = datetime.datetime.now().isoformat() + now_tag = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:19] + node_name = getattr(node, "name", type(node).__name__) + node_type = getattr(node, "bl_idname", type(node).__name__) + + # Extract properties & inputs + props = extract_node_properties(node) + inputs_raw = extract_node_inputs(node, socket_name) + + # Prepare JSON serializable inputs + inputs_summary: dict[str, Any] = {} + raw_inputs_dict: dict[str, Any] = {} + for sname, sinfo in inputs_raw.items(): + val = sinfo.get("value") + raw_inputs_dict[sname] = val + inputs_summary[sname] = { + "identifier": sinfo.get("identifier"), + "bl_idname": sinfo.get("bl_idname"), + "is_linked": sinfo.get("is_linked"), + "source": sinfo.get("source"), + "error": sinfo.get("error"), + "value_summary": _serialize_for_json(val) + } + + status = "ERROR" if exception is not None else "OK" + + dump_dict: dict[str, Any] = { + "node_name": node_name, + "node_type": node_type, + "socket_evaluated": socket_name, + "timestamp": now_iso, + "duration_seconds": duration_s, + "status": status, + "exception": str(exception) if exception else None, + "traceback": tb_str if exception else None, + "properties": _serialize_for_json(props), + "inputs": inputs_summary, + "output_summary": _serialize_for_json(output_val) if exception is None else None + } + + dump_text = format_debug_report(dump_dict) + + _LAST_DUMP_DICT = dump_dict + _LAST_DUMP_TEXT = dump_text + + # Write files to dump directory + dump_dir = get_debug_dump_dir() + clean_node_type = node_type.replace(" ", "_").replace(".", "_") + + # 1. Write last_execution.json and last_execution.txt + last_json_file = dump_dir / "last_execution.json" + last_txt_file = dump_dir / "last_execution.txt" + last_pkl_file = dump_dir / "last_execution.pkl" + + # 2. Timestamped files for history + hist_json_file = dump_dir / f"{now_tag}_{clean_node_type}.json" + hist_txt_file = dump_dir / f"{now_tag}_{clean_node_type}.txt" + hist_pkl_file = dump_dir / f"{now_tag}_{clean_node_type}.pkl" + + try: + with open(last_json_file, "w", encoding="utf-8") as f: + json.dump(dump_dict, f, indent=2) + with open(hist_json_file, "w", encoding="utf-8") as f: + json.dump(dump_dict, f, indent=2) + + with open(last_txt_file, "w", encoding="utf-8") as f: + f.write(dump_text) + with open(hist_txt_file, "w", encoding="utf-8") as f: + f.write(dump_text) + except Exception as e: + if log_console: + print(f"[NodeSlicer DEBUG] Warning: failed to write text/json debug dump: {e}") + + # 3. Pickle exact objects for full reproducibility + if save_pickle: + raw_dump = { + "node_name": node_name, + "node_type": node_type, + "socket_evaluated": socket_name, + "timestamp": now_iso, + "duration_seconds": duration_s, + "status": status, + "exception": str(exception) if exception else None, + "traceback": tb_str if exception else None, + "properties": props, + "inputs": {k: _sanitize_for_pickle(v) for k, v in raw_inputs_dict.items()}, + "output": _sanitize_for_pickle(output_val) if exception is None else None + } + try: + with open(last_pkl_file, "wb") as f: + pickle.dump(raw_dump, f) + with open(hist_pkl_file, "wb") as f: + pickle.dump(raw_dump, f) + except Exception as e: + if log_console: + print(f"[NodeSlicer DEBUG] Warning: failed to write pickle dump: {e}") + + # Display in Blender Text Editor if available + try: + import bpy + if hasattr(bpy, "data") and hasattr(bpy.data, "texts"): + tb = bpy.data.texts.get("NodeSlicer_Last_Debug.txt") + if tb is None: + tb = bpy.data.texts.new(name="NodeSlicer_Last_Debug.txt") + tb.from_string(dump_text) + except Exception: + pass + + if log_console: + if exception: + print(f"[NodeSlicer DEBUG ERROR] Node '{node_name}' ({node_type}) failed: {exception}") + print(f"[NodeSlicer DEBUG] Review dump at: {last_txt_file}") + else: + print(f"[NodeSlicer DEBUG] Dumped execution of '{node_name}' ({node_type}) -> {last_txt_file}") + + return dump_dict + + +F = TypeVar("F", bound=Callable[..., Any]) + + +def debug( + target: Any = None, + *, + save_pickle: bool = True, + log_console: bool = True +) -> Any: + """ + Decorator for Node classes, evaluate_output methods, or standalone functions to dump inputs and state. + + Examples: + @debug + @register_class + class SLICENODE_SeamPlacement(SliceNode): + ... + + @register_class + class SLICENODE_MyNode(SliceNode): + @debug + def evaluate_output(self, socket_name: str, **kwargs): + ... + + @debug + def my_calculation(a, b): + ... + """ + def decorator(obj: Any) -> Any: + # Case 1: Decorating a Class (e.g. SliceNode subclass) + if inspect.isclass(obj): + orig_eval = getattr(obj, "evaluate_output", None) + if orig_eval is not None and callable(orig_eval): + @functools.wraps(orig_eval) + def wrapped_eval(self, socket_name: str, **kwargs): + start = time.perf_counter() + exc_val = None + tb = None + result = None + try: + result = orig_eval(self, socket_name, **kwargs) + return result + except Exception as e: + exc_val = e + tb = traceback.format_exc() + raise + finally: + dur = time.perf_counter() - start + try: + dump_node_execution( + self, + socket_name, + output_val=result, + duration_s=dur, + exception=exc_val, + tb_str=tb, + save_pickle=save_pickle, + log_console=log_console + ) + except Exception as dump_err: + print(f"[NodeSlicer DEBUG] Dump error: {dump_err}") + + obj.evaluate_output = wrapped_eval + return obj + + # Case 2: Decorating a method or function + if callable(obj): + @functools.wraps(obj) + def wrapped_func(*args, **kwargs): + # Check if first arg is a SliceNode instance + if len(args) > 0 and hasattr(args[0], "inputs") and hasattr(args[0], "resolve"): + node_inst = args[0] + socket_name = args[1] if len(args) > 1 and isinstance(args[1], str) else "Output" + start = time.perf_counter() + exc_val = None + tb = None + result = None + try: + result = obj(*args, **kwargs) + return result + except Exception as e: + exc_val = e + tb = traceback.format_exc() + raise + finally: + dur = time.perf_counter() - start + try: + dump_node_execution( + node_inst, + socket_name, + output_val=result, + duration_s=dur, + exception=exc_val, + tb_str=tb, + save_pickle=save_pickle, + log_console=log_console + ) + except Exception as dump_err: + print(f"[NodeSlicer DEBUG] Dump error: {dump_err}") + else: + # Regular standalone function + func_name = getattr(obj, "__name__", "function") + start = time.perf_counter() + exc_val = None + tb = None + result = None + try: + result = obj(*args, **kwargs) + return result + except Exception as e: + exc_val = e + tb = traceback.format_exc() + raise + finally: + dur = time.perf_counter() - start + now_iso = datetime.datetime.now().isoformat() + status = "ERROR" if exc_val else "OK" + args_summary = [_serialize_for_json(a) for a in args] + kwargs_summary = {k: _serialize_for_json(v) for k, v in kwargs.items()} + dump_dict = { + "node_name": func_name, + "node_type": "Function", + "socket_evaluated": "return", + "timestamp": now_iso, + "duration_seconds": dur, + "status": status, + "exception": str(exc_val) if exc_val else None, + "traceback": tb if exc_val else None, + "properties": {}, + "inputs": { + f"arg_{i}": {"value_summary": a, "is_linked": False} for i, a in enumerate(args_summary) + } | { + f"kwarg_{k}": {"value_summary": v, "is_linked": False} for k, v in kwargs_summary.items() + }, + "output_summary": _serialize_for_json(result) if exc_val is None else None + } + report_text = format_debug_report(dump_dict) + dump_dir = get_debug_dump_dir() + try: + with open(dump_dir / "last_execution.json", "w", encoding="utf-8") as f: + json.dump(dump_dict, f, indent=2) + with open(dump_dir / "last_execution.txt", "w", encoding="utf-8") as f: + f.write(report_text) + except Exception: + pass + return wrapped_func + + return obj + + if target is None: + return decorator + return decorator(target) + + +def get_last_debug_dump() -> dict[str, Any] | None: + """Returns the parsed dictionary of the last execution dump.""" + global _LAST_DUMP_DICT + if _LAST_DUMP_DICT is not None: + return _LAST_DUMP_DICT + + json_file = get_debug_dump_dir() / "last_execution.json" + if json_file.exists(): + try: + with open(json_file, "r", encoding="utf-8") as f: + _LAST_DUMP_DICT = json.load(f) + return _LAST_DUMP_DICT + except Exception: + pass + return None + + +def get_last_debug_text() -> str: + """Returns the formatted text summary of the last execution dump.""" + global _LAST_DUMP_TEXT + if _LAST_DUMP_TEXT is not None: + return _LAST_DUMP_TEXT + + txt_file = get_debug_dump_dir() / "last_execution.txt" + if txt_file.exists(): + try: + with open(txt_file, "r", encoding="utf-8") as f: + _LAST_DUMP_TEXT = f.read() + return _LAST_DUMP_TEXT + except Exception: + pass + + dump = get_last_debug_dump() + if dump: + _LAST_DUMP_TEXT = format_debug_report(dump) + return _LAST_DUMP_TEXT + + return "(No debug dumps found in debug_dumps/)" + + +def load_last_inputs() -> dict[str, Any] | None: + """Loads the unpickled raw Python objects from the last execution dump.""" + pkl_file = get_debug_dump_dir() / "last_execution.pkl" + if pkl_file.exists(): + try: + with open(pkl_file, "rb") as f: + return pickle.load(f) + except Exception as e: + print(f"[NodeSlicer DEBUG] Error loading last execution pickle: {e}") + return None + + +def clear_debug_dumps() -> None: + """Removes all dump files from the debug_dumps directory.""" + global _LAST_DUMP_DICT, _LAST_DUMP_TEXT + _LAST_DUMP_DICT = None + _LAST_DUMP_TEXT = None + dump_dir = get_debug_dump_dir() + if dump_dir.exists(): + for item in dump_dir.glob("*"): + if item.is_file(): + try: + item.unlink() + except Exception: + pass diff --git a/tests/test_debug.py b/tests/test_debug.py new file mode 100644 index 0000000..f86bb38 --- /dev/null +++ b/tests/test_debug.py @@ -0,0 +1,327 @@ +import unittest +from unittest.mock import MagicMock +import sys +import os +import shutil +import tempfile +import numpy as np +from pathlib import Path + +# Add workspace directory to sys.path +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.append(str(PROJECT_ROOT.parent)) + +# Setup dummy bpy +class DummyNodeSocket: + def __init__(self, name="Socket", identifier="Socket", bl_idname="NodeSocketFloat", value=0.0, node=None): + self.name = name + self.identifier = identifier + self.bl_idname = bl_idname + self.value = value + self.default_value = value + self.is_linked = False + self.links = [] + self.node = node + + def as_pointer(self): + return id(self) + +class DummyNode: + def as_pointer(self): + return id(self) + +class DummySocketCollection(list): + def __init__(self, items=None, node=None): + super().__init__(items or []) + self.node = node + if items and node: + for item in items: + item.node = node + + def get(self, key, default=None): + for item in self: + if getattr(item, "name", None) == key or getattr(item, "identifier", None) == key: + return item + return default + + def new(self, bl_idname, name, identifier=None): + sock = DummyNodeSocket(name, identifier or name, bl_idname, node=self.node) + self.append(sock) + return sock + +mock_types = MagicMock() +mock_types.NodeSocket = DummyNodeSocket +mock_types.Node = DummyNode +mock_types.Object = MagicMock +mock_types.Mesh = MagicMock +mock_types.NodeSocketShader = MagicMock +mock_types.NodeCustomGroup = MagicMock +mock_types.NodeTree = MagicMock +mock_types.Operator = MagicMock +mock_types.Menu = MagicMock +mock_types.Panel = MagicMock +mock_types.PropertyGroup = MagicMock + +mock_bpy = MagicMock() +mock_bpy.context.scene = MagicMock() +mock_bpy.types = mock_types + +sys.modules['bpy'] = mock_bpy +sys.modules['bpy.types'] = mock_types +sys.modules['bpy.props'] = MagicMock() +sys.modules['bpy.utils'] = MagicMock() +sys.modules['bpy.utils.previews'] = MagicMock() +sys.modules['bpy.app'] = MagicMock() +sys.modules['bpy.app.handlers'] = MagicMock() + +from NodeSlicer.core.slice import SlicePaths +from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject +from NodeSlicer.infra.blender.blender_slice_nodes_generic import SliceNode +from NodeSlicer.infra.debug import ( + debug, + debug_session, + get_last_debug_dump, + get_last_debug_text, + load_last_inputs, + clear_debug_dumps, + set_debug_dump_dir, + get_debug_dump_dir, + dump_node_execution +) + + +class TestNodeDebugSystem(unittest.TestCase): + + def setUp(self): + self.test_dir = tempfile.mkdtemp(prefix="nodeslicer_debug_test_") + set_debug_dump_dir(self.test_dir) + clear_debug_dumps() + + def tearDown(self): + clear_debug_dumps() + if os.path.exists(self.test_dir): + shutil.rmtree(self.test_dir) + + def test_debug_decorator_on_node_class(self): + @debug + class TestMathNode(SliceNode): + bl_idname = "SLICENODE_TestMath" + multiplier: float = 2.5 + + def __init__(self): + self.name = "Test Math Node" + self.inputs = DummySocketCollection([ + DummyNodeSocket("A", "A", "NodeSocketFloat", value=10.0), + DummyNodeSocket("B", "B", "NodeSocketFloat", value=3.0), + ], node=self) + self.outputs = DummySocketCollection([ + DummyNodeSocket("Result", "Result", "NodeSocketFloat", value=0.0) + ], node=self) + + def evaluate_output(self, socket_name: str, **kwargs): + a = self.resolve("A") or 0.0 + b = self.resolve("B") or 0.0 + return (a + b) * self.multiplier + + node = TestMathNode() + res = node.evaluate_output("Result") + self.assertEqual(res, (10.0 + 3.0) * 2.5) + + # Verify dump files created + dump = get_last_debug_dump() + self.assertIsNotNone(dump) + self.assertEqual(dump["node_name"], "Test Math Node") + self.assertEqual(dump["node_type"], "SLICENODE_TestMath") + self.assertEqual(dump["socket_evaluated"], "Result") + self.assertEqual(dump["status"], "OK") + self.assertEqual(dump["properties"]["multiplier"], 2.5) + self.assertEqual(dump["inputs"]["A"]["value_summary"], 10.0) + self.assertEqual(dump["inputs"]["B"]["value_summary"], 3.0) + self.assertEqual(dump["output_summary"], 32.5) + + # Check formatted report text + txt = get_last_debug_text() + self.assertIn("Test Math Node", txt) + self.assertIn("Result", txt) + self.assertIn("32.5", txt) + + # Check raw unpickled inputs + raw = load_last_inputs() + self.assertIsNotNone(raw) + self.assertEqual(raw["inputs"]["A"], 10.0) + self.assertEqual(raw["inputs"]["B"], 3.0) + self.assertEqual(raw["output"], 32.5) + + def test_debug_complex_slicepaths_and_mesh_dump(self): + @debug + class TestSeamNode(SliceNode): + bl_idname = "SLICENODE_SeamMock" + mode: str = "CLOSEST" + acquire_transforms: bool = True + + def __init__(self, sp, so): + self.name = "Seam Mock Node" + self._sp = sp + self._so = so + self.inputs = DummySocketCollection([ + DummyNodeSocket("Paths", "Paths", "SLICESOCK_Paths"), + DummyNodeSocket("Mesh", "Mesh", "SLICESOCK_Mesh"), + ], node=self) + self.outputs = DummySocketCollection([ + DummyNodeSocket("Paths", "Paths", "SLICESOCK_Paths") + ], node=self) + + def resolve(self, fieldname): + if fieldname == "Paths": + return self._sp + if fieldname == "Mesh": + return self._so + return None + + def evaluate_output(self, socket_name: str, **kwargs): + paths = self.resolve("Paths") + mesh = self.resolve("Mesh") + return paths.place_seams(mesh, mode=self.mode, acquire_transforms=self.acquire_transforms) + + # Create SlicePaths + poly = np.array([[-10.0, -10.0, 0.0], [10.0, -10.0, 0.0], [10.0, 10.0, 0.0], [-10.0, 10.0, 0.0], [-10.0, -10.0, 0.0]]) + sp = SlicePaths([poly], layer_height=0.2) + + # Create SlicingObject + tri = np.zeros((2, 4, 3), dtype=np.float64) + so = SlicingObject() + so.mesh = tri + + node = TestSeamNode(sp, so) + res = node.evaluate_output("Paths") + self.assertIsInstance(res, SlicePaths) + + dump = get_last_debug_dump() + self.assertIsNotNone(dump) + self.assertEqual(dump["node_type"], "SLICENODE_SeamMock") + self.assertEqual(dump["properties"]["mode"], "CLOSEST") + self.assertTrue(dump["properties"]["acquire_transforms"]) + + # Check SlicePaths summary in JSON dump + paths_summary = dump["inputs"]["Paths"]["value_summary"] + self.assertEqual(paths_summary["__type__"], "SlicePaths") + self.assertEqual(paths_summary["num_paths"], 1) + self.assertEqual(paths_summary["total_vertices"], 5) + self.assertEqual(paths_summary["layer_height"], 0.2) + + # Check SlicingObject summary in JSON dump + mesh_summary = dump["inputs"]["Mesh"]["value_summary"] + self.assertEqual(mesh_summary["__type__"], "SlicingObject") + self.assertEqual(mesh_summary["triangles_count"], 2) + + def test_debug_exception_capture(self): + @debug + class CrashingNode(SliceNode): + bl_idname = "SLICENODE_Crash" + + def __init__(self): + self.name = "Crashing Node" + self.inputs = DummySocketCollection([ + DummyNodeSocket("Divisor", "Divisor", "NodeSocketFloat", value=0.0) + ], node=self) + + def evaluate_output(self, socket_name: str, **kwargs): + div = self.resolve("Divisor") + return 100.0 / div + + node = CrashingNode() + with self.assertRaises(ZeroDivisionError): + node.evaluate_output("Result") + + # Verify that debug captured the error and traceback + dump = get_last_debug_dump() + self.assertIsNotNone(dump) + self.assertEqual(dump["status"], "ERROR") + self.assertIn("division by zero", dump["exception"]) + self.assertIsNotNone(dump["traceback"]) + self.assertEqual(dump["inputs"]["Divisor"]["value_summary"], 0.0) + + # Check text report + txt = get_last_debug_text() + self.assertIn("EXCEPTION: float division by zero", txt) + + def test_debug_function_decorator(self): + @debug + def calculate_area(width: float, height: float) -> float: + return width * height + + res = calculate_area(5.0, 10.0) + self.assertEqual(res, 50.0) + + dump = get_last_debug_dump() + self.assertIsNotNone(dump) + self.assertEqual(dump["node_name"], "calculate_area") + self.assertEqual(dump["inputs"]["arg_0"]["value_summary"], 5.0) + self.assertEqual(dump["inputs"]["arg_1"]["value_summary"], 10.0) + self.assertEqual(dump["output_summary"], 50.0) + + def test_node_dump_inputs_direct_method(self): + class DirectNode(SliceNode): + bl_idname = "SLICENODE_Direct" + factor: float = 3.14 + + def __init__(self): + self.name = "Direct Node" + self.inputs = DummySocketCollection([ + DummyNodeSocket("Radius", "Radius", "NodeSocketFloat", value=4.0) + ], node=self) + + node = DirectNode() + dump = node.dump_inputs("Output") + self.assertEqual(dump["node_name"], "Direct Node") + self.assertEqual(dump["properties"]["factor"], 3.14) + self.assertEqual(dump["inputs"]["Radius"]["value_summary"], 4.0) + + def test_debug_decorator_on_method(self): + class MethodDecoratedNode(SliceNode): + bl_idname = "SLICENODE_MethodDec" + + def __init__(self): + self.name = "Method Node" + self.inputs = DummySocketCollection([ + DummyNodeSocket("X", "X", "NodeSocketFloat", value=7.0) + ], node=self) + + @debug + def evaluate_output(self, socket_name: str, **kwargs): + return (self.resolve("X") or 0.0) * 2 + + node = MethodDecoratedNode() + res = node.evaluate_output("Result") + self.assertEqual(res, 14.0) + + dump = get_last_debug_dump() + self.assertIsNotNone(dump) + self.assertEqual(dump["node_name"], "Method Node") + self.assertEqual(dump["inputs"]["X"]["value_summary"], 7.0) + self.assertEqual(dump["output_summary"], 14.0) + + def test_debug_session_context_manager(self): + class NormalNode(SliceNode): + bl_idname = "SLICENODE_Normal" + + def __init__(self): + self.name = "Normal Node" + self.inputs = DummySocketCollection([ + DummyNodeSocket("Val", "Val", "NodeSocketFloat", value=42.0) + ], node=self) + + def evaluate_output(self, socket_name: str, **kwargs): + return self.resolve("Val") + + node = NormalNode() + + # Inside debug_session, resolve_input or direct calls capture dumps + with debug_session(): + dump = node.dump_inputs("Val") + self.assertEqual(dump["inputs"]["Val"]["value_summary"], 42.0) + + +if __name__ == '__main__': + unittest.main() + diff --git a/tests/test_seam_placement.py b/tests/test_seam_placement.py new file mode 100644 index 0000000..cf67b33 --- /dev/null +++ b/tests/test_seam_placement.py @@ -0,0 +1,619 @@ +import numpy as np +import unittest +from unittest.mock import MagicMock +import sys +from pathlib import Path + +# Add workspace directory to sys.path +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.append(str(PROJECT_ROOT.parent)) + +# Define dummy classes to avoid metaclass conflicts with bpy +class DummyNodeSocket: pass +class DummyNode: pass +class DummyObject: pass +class DummyMesh: pass +class DummyNodeSocketShader: pass +class DummyNodeCustomGroup: pass +class DummyNodeTree: pass +class DummyOperator: pass +class DummyMenu: pass +class DummyPanel: pass +class DummyPropertyGroup: pass + +mock_types = MagicMock() +mock_types.NodeSocket = DummyNodeSocket +mock_types.Node = DummyNode +mock_types.Object = DummyObject +mock_types.Mesh = DummyMesh +mock_types.NodeSocketShader = DummyNodeSocketShader +mock_types.NodeCustomGroup = DummyNodeCustomGroup +mock_types.NodeTree = DummyNodeTree +mock_types.Operator = DummyOperator +mock_types.Menu = DummyMenu +mock_types.Panel = DummyPanel +mock_types.PropertyGroup = DummyPropertyGroup + +mock_bpy = MagicMock() +mock_bpy.context.scene = MagicMock() +mock_bpy.context.scene.unit_settings.scale_length = 0.001 +mock_bpy.types = mock_types + +sys.modules['bpy'] = mock_bpy +sys.modules['bpy.types'] = mock_types +sys.modules['bpy.props'] = MagicMock() +sys.modules['bpy.utils'] = MagicMock() +sys.modules['bpy.utils.previews'] = MagicMock() +sys.modules['bpy.app'] = MagicMock() +sys.modules['bpy.app.handlers'] = MagicMock() + +from NodeSlicer.core.slice import ( + SlicePaths, + is_closed_path, + find_path_mesh_intersection, + reposition_path_seam, + extract_mesh_triangles, +) +from NodeSlicer.infra.blender.blender_mesh_capture import ( + SlicingObject, + SlicingCollection, + SlicingGroup, +) +from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_SeamPlacement + + +def create_triangle_mesh(v0, v1, v2) -> np.ndarray: + """Helper to create a single triangle mesh with shape (1, 4, 3).""" + e1 = np.array(v1) - np.array(v0) + e2 = np.array(v2) - np.array(v0) + n = np.cross(e1, e2) + norm = np.linalg.norm(n) + if norm > 0: + n = n / norm + tri = np.zeros((1, 4, 3), dtype=np.float64) + tri[0, 0] = v0 + tri[0, 1] = v1 + tri[0, 2] = v2 + tri[0, 3] = n + return tri + + +def create_quad_mesh(p0, p1, p2, p3) -> np.ndarray: + """Helper to create a quad mesh (2 triangles) with shape (2, 4, 3).""" + t1 = create_triangle_mesh(p0, p1, p2) + t2 = create_triangle_mesh(p0, p2, p3) + return np.concatenate([t1, t2], axis=0) + + +class TestSeamPlacement(unittest.TestCase): + + def setUp(self): + # Base square path at Z=0: from (-10,-10) -> (10,-10) -> (10,10) -> (-10,10) -> (-10,-10) + self.square_path = np.array([ + [-10.0, -10.0, 0.0], + [10.0, -10.0, 0.0], + [10.0, 10.0, 0.0], + [-10.0, 10.0, 0.0], + [-10.0, -10.0, 0.0] + ], dtype=np.float64) + + def test_single_intersection_edge_midpoint(self): + # Vertical cutting plane through top edge at X=0, Y=10 + # Triangle cutting only the top edge: X in [-0.5, 0.5], Y in [9, 11], Z in [-5, 5] + 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] + ) + + sp = SlicePaths([self.square_path]) + res = sp.place_seams(cutting_plane) + + self.assertEqual(len(res), 1) + mod_p = res[0] + + # Should be a valid closed loop + self.assertTrue(is_closed_path(mod_p)) + # Seam should now be at (0, 10, 0) + np.testing.assert_allclose(mod_p[0], [0.0, 10.0, 0.0], atol=1e-4) + np.testing.assert_allclose(mod_p[-1], [0.0, 10.0, 0.0], atol=1e-4) + + # Total points should have increased by 1 (inserted vertex) + self.assertEqual(len(mod_p), 6) + + def test_intersection_snap_to_existing_vertex(self): + # Cutting plane passing right through corner vertex (10, 10, 0) + cutting_plane = create_quad_mesh( + [9.0, 9.0, -5.0], + [11.0, 11.0, -5.0], + [11.0, 11.0, 5.0], + [9.0, 9.0, 5.0] + ) + + sp = SlicePaths([self.square_path]) + res = sp.place_seams(cutting_plane) + + self.assertEqual(len(res), 1) + mod_p = res[0] + + self.assertTrue(is_closed_path(mod_p)) + np.testing.assert_allclose(mod_p[0], [10.0, 10.0, 0.0], atol=1e-4) + np.testing.assert_allclose(mod_p[-1], [10.0, 10.0, 0.0], atol=1e-4) + # Should snap to existing vertex without increasing point count + self.assertEqual(len(mod_p), 5) + + def test_no_intersection_preserves_path(self): + # Guide mesh far away at Z=100 + mesh_far = create_quad_mesh( + [0.0, 5.0, 95.0], + [0.0, 15.0, 95.0], + [0.0, 15.0, 105.0], + [0.0, 5.0, 105.0] + ) + + sp = SlicePaths([self.square_path]) + res = sp.place_seams(mesh_far) + + self.assertEqual(len(res), 1) + np.testing.assert_allclose(res[0], self.square_path, atol=1e-7) + + def test_open_path_untouched(self): + # Open path (3 vertices, not closed) + open_p = np.array([ + [-10.0, -10.0, 0.0], + [0.0, 0.0, 0.0], + [10.0, 10.0, 0.0] + ], dtype=np.float64) + + cutting_plane = create_quad_mesh( + [0.0, -5.0, -5.0], + [0.0, 5.0, -5.0], + [0.0, 5.0, 5.0], + [0.0, -5.0, 5.0] + ) + + sp = SlicePaths([open_p]) + res = sp.place_seams(cutting_plane) + + self.assertEqual(len(res), 1) + np.testing.assert_allclose(res[0], open_p, atol=1e-7) + + def test_holes_handling(self): + # Outer boundary square: 40x40 from (-20,-20) to (20,20) + outer_p = np.array([ + [-20.0, -20.0, 0.0], + [20.0, -20.0, 0.0], + [20.0, 20.0, 0.0], + [-20.0, 20.0, 0.0], + [-20.0, -20.0, 0.0] + ], dtype=np.float64) + + # Hole 1 in top-left: square 6x6 from (-15, 5) to (-9, 11) (clockwise) + hole1_p = np.array([ + [-15.0, 11.0, 0.0], + [-9.0, 11.0, 0.0], + [-9.0, 5.0, 0.0], + [-15.0, 5.0, 0.0], + [-15.0, 11.0, 0.0] + ], dtype=np.float64) + + # Hole 2 in bottom-right: square 6x6 from (9, -15) to (15, -9) + hole2_p = np.array([ + [9.0, -9.0, 0.0], + [15.0, -9.0, 0.0], + [15.0, -15.0, 0.0], + [9.0, -15.0, 0.0], + [9.0, -9.0, 0.0] + ], dtype=np.float64) + + # Cutting plane cutting through outer top edge (at X=-12, Y=20) and Hole 1 (at X=-12, Y=11 and Y=5) + # Plane is at X = -12, spanning Y in [0, 25], Z in [-5, 5] + cutting_plane = create_quad_mesh( + [-12.0, 0.0, -5.0], + [-12.0, 25.0, -5.0], + [-12.0, 25.0, 5.0], + [-12.0, 0.0, 5.0] + ) + + sp = SlicePaths([outer_p, hole1_p, hole2_p]) + res = sp.place_seams(cutting_plane) + + self.assertEqual(len(res), 3) + + # 1. Outer boundary seam should be moved to (-12, 20, 0) + np.testing.assert_allclose(res[0][0], [-12.0, 20.0, 0.0], atol=1e-4) + np.testing.assert_allclose(res[0][-1], [-12.0, 20.0, 0.0], atol=1e-4) + self.assertTrue(is_closed_path(res[0])) + + # 2. Hole 1 seam should be moved to (-12, 11, 0) (or intersection on hole1) + self.assertTrue(is_closed_path(res[1])) + np.testing.assert_allclose(res[1][0, 0], -12.0, atol=1e-4) + np.testing.assert_allclose(res[1][-1, 0], -12.0, atol=1e-4) + + # 3. Hole 2 does NOT intersect the plane at X=-12, so its seam stays unchanged! + np.testing.assert_allclose(res[2], hole2_p, atol=1e-7) + + def test_mesh_input_types_agnostic(self): + # Test SlicingObject, SlicingCollection, SlicingGroup, and list + tri_data = 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] + ) + + so = SlicingObject() + so.mesh = tri_data + + sc = SlicingCollection([], "test_coll") + sc.objects = [so] + + sg = SlicingGroup([]) + sg.collections["default"] = sc + + sp = SlicePaths([self.square_path]) + + # 1. SlicingObject + res_so = sp.place_seams(so) + np.testing.assert_allclose(res_so[0][0], [0.0, 10.0, 0.0], atol=1e-4) + + # 2. SlicingCollection + res_sc = sp.place_seams(sc) + np.testing.assert_allclose(res_sc[0][0], [0.0, 10.0, 0.0], atol=1e-4) + + # 3. SlicingGroup + res_sg = sp.place_seams(sg) + np.testing.assert_allclose(res_sg[0][0], [0.0, 10.0, 0.0], atol=1e-4) + + # 4. List of objects + res_list = sp.place_seams([so, sc]) + np.testing.assert_allclose(res_list[0][0], [0.0, 10.0, 0.0], atol=1e-4) + + # 5. None / Empty + res_none = sp.place_seams(None) + np.testing.assert_allclose(res_none[0], self.square_path, atol=1e-7) + + def test_attribute_rotation_and_interpolation(self): + # Square path with non-uniform extrusion rate and feedrate + ext = np.array([1.0, 2.0, 3.0, 4.0, 1.0], dtype=np.float64) + feed = np.array([100.0, 200.0, 300.0, 400.0, 100.0], dtype=np.float64) + extra = {"temp": [np.array([210.0, 215.0, 220.0, 225.0, 210.0], dtype=np.float64)]} + + sp = SlicePaths([self.square_path], extrusion_rate=[ext], feedrate=[feed], extra_attributes=extra) + + # Plane cutting segment 2 (between (10, 10) and (-10, 10)) at midpoint X=0, Y=10 (t=0.5) + 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] + ) + + res = sp.place_seams(cutting_plane) + self.assertEqual(len(res), 1) + + # Interpolated value at t=0.5 on segment 2: + # segment 2 is between vertex 2 (ext=3.0) and vertex 3 (ext=4.0) + # Expected interpolated ext at new seam: 3.5 + expected_seam_ext = 3.5 + self.assertAlmostEqual(res.extrusion_rate[0][0], expected_seam_ext, places=3) + self.assertAlmostEqual(res.extrusion_rate[0][-1], expected_seam_ext, places=3) + + # Feedrate at t=0.5: between 300.0 and 400.0 -> 350.0 + self.assertAlmostEqual(res.feedrate[0][0], 350.0, places=3) + self.assertAlmostEqual(res.feedrate[0][-1], 350.0, places=3) + + # Extra attribute at t=0.5: between 220.0 and 225.0 -> 222.5 + self.assertAlmostEqual(res.extra_attributes["temp"][0][0], 222.5, places=3) + self.assertAlmostEqual(res.extra_attributes["temp"][0][-1], 222.5, places=3) + + def test_seam_placement_modes(self): + # Plane that cuts square path at two places: + # 1. Top edge at (0, 10, 0) + # 2. Bottom edge at (0, -10, 0) + full_plane = create_quad_mesh( + [0.0, -15.0, -5.0], + [0.0, 15.0, -5.0], + [0.0, 15.0, 5.0], + [0.0, -15.0, 5.0] + ) + + # Original square starts at (-10, -10, 0) (closer to (0, -10, 0)) + sp = SlicePaths([self.square_path]) + + # Mode: CLOSEST -> should pick (0, -10, 0) because original seam is at (-10, -10, 0) + res_closest = sp.place_seams(full_plane, mode="closest") + np.testing.assert_allclose(res_closest[0][0], [0.0, -10.0, 0.0], atol=1e-4) + + # Mode: FIRST -> segment 0 goes from (-10, -10) to (10, -10), so first intersection is (0, -10, 0) + res_first = sp.place_seams(full_plane, mode="first") + np.testing.assert_allclose(res_first[0][0], [0.0, -10.0, 0.0], atol=1e-4) + + def test_seam_placement_node_execution(self): + # Test SLICENODE_SeamPlacement evaluate_output method + node = SLICENODE_SeamPlacement() + node.mode = 'CLOSEST' + + 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] + ) + + sp = SlicePaths([self.square_path]) + so = SlicingObject() + so.mesh = cutting_plane + + # Mock resolve method + def mock_resolve(socket_name): + if socket_name == "Paths": + return sp + elif socket_name == "Mesh": + return so + return None + + node.resolve = mock_resolve + out = node.evaluate_output("Paths") + + self.assertIsInstance(out, SlicePaths) + self.assertEqual(len(out), 1) + np.testing.assert_allclose(out[0][0], [0.0, 10.0, 0.0], atol=1e-4) + + def test_acquire_transforms(self): + # Shifted square path by +50 in X and +30 in Y + shifted_square = self.square_path + np.array([50.0, 30.0, 0.0]) + transforms = [{ + "type": "transform", + "translation": (50.0, 30.0, 0.0), + "rotation": (0.0, 0.0, 0.0), + "scale": (1.0, 1.0, 1.0) + }] + + sp = SlicePaths([shifted_square], transforms=transforms) + + # Cutting plane at original untransformed location at X=0, Y=10 + 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] + ) + + # 1. With acquire_transforms=True (default): + # The cutting plane should be transformed by +50 X and +30 Y, intersecting at (50, 40, 0) + res_acquired = sp.place_seams(cutting_plane, acquire_transforms=True) + self.assertEqual(len(res_acquired), 1) + np.testing.assert_allclose(res_acquired[0][0], [50.0, 40.0, 0.0], atol=1e-4) + np.testing.assert_allclose(res_acquired[0][-1], [50.0, 40.0, 0.0], atol=1e-4) + + # 2. With acquire_transforms=False: + # The cutting plane remains at (0, 10, 0) and does not intersect the path at (50, 30, 0) + res_no_acquire = sp.place_seams(cutting_plane, acquire_transforms=False) + self.assertEqual(len(res_no_acquire), 1) + np.testing.assert_allclose(res_no_acquire[0], shifted_square, atol=1e-7) + + def test_node_acquire_transforms_checkbox(self): + node = SLICENODE_SeamPlacement() + self.assertTrue(node.acquire_transforms) + + shifted_square = self.square_path + np.array([50.0, 30.0, 0.0]) + transforms = [{ + "type": "transform", + "translation": (50.0, 30.0, 0.0), + "rotation": (0.0, 0.0, 0.0), + "scale": (1.0, 1.0, 1.0) + }] + sp = SlicePaths([shifted_square], transforms=transforms) + + 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] + ) + so = SlicingObject() + so.mesh = cutting_plane + + def mock_resolve(socket_name): + if socket_name == "Paths": + return sp + elif socket_name == "Mesh": + return so + return None + + node.resolve = mock_resolve + + # When checkbox is True + node.acquire_transforms = True + out_true = node.evaluate_output("Paths") + np.testing.assert_allclose(out_true[0][0], [50.0, 40.0, 0.0], atol=1e-4) + + # When checkbox is False + node.acquire_transforms = False + out_false = node.evaluate_output("Paths") + np.testing.assert_allclose(out_false[0], shifted_square, atol=1e-7) + + def test_large_dataset_performance(self): + # 500 layers of complex circular paths (100 vertices per circle) + num_layers = 500 + angles = np.linspace(0, 2 * np.pi, 100) + circle_2d = np.stack([10.0 * np.cos(angles), 10.0 * np.sin(angles)], axis=-1) + + paths = [] + for i in range(num_layers): + z = i * 0.2 + p = np.empty((len(circle_2d), 3), dtype=np.float64) + p[:, :2] = circle_2d + p[:, 2] = z + paths.append(p) + + sp = SlicePaths(paths) + + # Mesh with 1,000 triangles spanning Z in [0, 100] + # Cutting quad at X=0, Y in [5, 15], Z in [-5, 105] + cutting_plane = create_quad_mesh( + [0.0, 5.0, -5.0], + [0.0, 15.0, -5.0], + [0.0, 15.0, 105.0], + [0.0, 5.0, 105.0] + ) + + import time + start_time = time.perf_counter() + res = sp.place_seams(cutting_plane) + duration = time.perf_counter() - start_time + + self.assertEqual(len(res), num_layers) + for i in range(num_layers): + self.assertTrue(is_closed_path(res[i])) + # Should have seam on positive Y axis at X=0 + np.testing.assert_allclose(res[i][0, 0], 0.0, atol=1e-3) + self.assertGreater(res[i][0, 1], 9.0) + + # 500 layers must complete in well under 1 second (typically < 50ms) + self.assertLess(duration, 1.0, f"Execution took too long: {duration:.3f}s") + + def test_complex_high_poly_mesh_performance(self): + # 500 layers, each with an outer contour and an inner hole (1000 paths total) + num_layers = 500 + angles = np.linspace(0, 2 * np.pi, 80) + outer_2d = np.stack([20.0 * np.cos(angles), 20.0 * np.sin(angles)], axis=-1) + inner_2d = np.stack([10.0 * np.cos(angles[::-1]), 10.0 * np.sin(angles[::-1])], axis=-1) + + paths = [] + for i in range(num_layers): + z = i * 0.2 + po = np.empty((len(outer_2d), 3), dtype=np.float64) + po[:, :2] = outer_2d + po[:, 2] = z + paths.append(po) + + pi = np.empty((len(inner_2d), 3), dtype=np.float64) + pi[:, :2] = inner_2d + pi[:, 2] = z + paths.append(pi) + + sp = SlicePaths(paths) + + # Generate a high-poly mesh of 25,000 quads (50,000 triangles) + # spanning Z in [-10, 110] + n_quads = 25000 + z_grid = np.linspace(-10, 110, n_quads + 1) + tris = np.zeros((n_quads * 2, 4, 3), dtype=np.float64) + for q in range(n_quads): + z0 = z_grid[q] + z1 = z_grid[q + 1] + # Quad cutting at X=0, Y in [5, 25] + p0 = np.array([0.0, 5.0, z0]) + p1 = np.array([0.0, 25.0, z0]) + p2 = np.array([0.0, 25.0, z1]) + p3 = np.array([0.0, 5.0, z1]) + + tris[q * 2, 0] = p0 + tris[q * 2, 1] = p1 + tris[q * 2, 2] = p2 + + tris[q * 2 + 1, 0] = p0 + tris[q * 2 + 1, 1] = p2 + tris[q * 2 + 1, 2] = p3 + + import time + start_time = time.perf_counter() + res = sp.place_seams(tris) + duration = time.perf_counter() - start_time + + self.assertEqual(len(res), num_layers * 2) + # Even with 50,000 triangles and 1,000 paths across 500 layers, + # layer batching must finish in under 0.5s + self.assertLess(duration, 0.5, f"High-poly seam placement took {duration:.3f}s (must be < 0.5s)") + + def test_acquire_transforms_with_rotate_path_center(self): + # Square centered at (100, 100): from (90, 90) to (110, 110) + center = np.array([100.0, 100.0, 0.0]) + square_offset = np.array([ + [90.0, 90.0, 0.0], + [110.0, 90.0, 0.0], + [110.0, 110.0, 0.0], + [90.0, 110.0, 0.0], + [90.0, 90.0, 0.0] + ], dtype=np.float64) + + sp = SlicePaths([square_offset]) + + # Cutting plane cutting top edge at (100, 110, 0) + cutting_plane = create_quad_mesh( + [100.0, 105.0, -5.0], + [100.0, 115.0, -5.0], + [100.0, 115.0, 5.0], + [100.0, 105.0, 5.0] + ) + + # Rotate path by 90 degrees around center (100, 100, 0) + # Top edge (100, 110) -> rotates by +90 deg (CCW) around (100, 100) to (90, 100) + rotated_sp = sp.rotate(90.0, center=center) + + # When acquire_transforms=True, the cutting plane must rotate around the SAME center (100, 100) + # So the cutting plane at (100, 110) rotates to (90, 100) and cuts the rotated path at (90, 100, 0) + res = rotated_sp.place_seams(cutting_plane, acquire_transforms=True) + self.assertEqual(len(res), 1) + self.assertTrue(is_closed_path(res[0])) + np.testing.assert_allclose(res[0][0], [90.0, 100.0, 0.0], atol=1e-4) + np.testing.assert_allclose(res[0][-1], [90.0, 100.0, 0.0], atol=1e-4) + + def test_acquire_transforms_when_mesh_already_transformed(self): + # Transformed path and transformed SlicingCollection + # Both already have transforms: translation +142.93 in Z, rotation -90 deg around Y + trans_record = [{ + "type": "transform", + "translation": (0.0, 0.0, 142.93), + "rotation": (0.0, -np.pi / 2.0, 0.0), + "scale": (1.0, 1.0, 1.0) + }] + + # Create original path and cutting plane + orig_p = np.array([ + [-10.0, -10.0, 0.0], + [10.0, -10.0, 0.0], + [10.0, 10.0, 0.0], + [-10.0, 10.0, 0.0], + [-10.0, -10.0, 0.0] + ], dtype=np.float64) + + 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] + ) + + from NodeSlicer.core.pdf_exporter.geometry import transform_slice_paths + from NodeSlicer.core.slice import apply_transforms_to_triangles + + # Transform both the paths and the cutting mesh + sp = transform_slice_paths(SlicePaths([orig_p]), (0.0, 0.0, 142.93), (0.0, -np.pi / 2.0, 0.0), (1.0, 1.0, 1.0)) + sp.transforms = [dict(t) for t in trans_record] + + transformed_tris = apply_transforms_to_triangles(cutting_plane, trans_record) + so = SlicingObject() + so.mesh = transformed_tris + so.transforms = [dict(t) for t in trans_record] + + # When acquire_transforms=True is passed with a mesh that is ALREADY transformed, + # it must NOT double-transform the mesh! + res = sp.place_seams(so, acquire_transforms=True) + self.assertEqual(len(res), 1) + self.assertTrue(is_closed_path(res[0])) + + # The seam must be placed at the intersection (not left unchanged) + # Transformed seam should NOT equal the unmodified transformed start point + self.assertFalse(np.allclose(res[0][0], sp[0][0])) + + +if __name__ == '__main__': + unittest.main() + + + +