custom seams

This commit is contained in:
Nicolas Predella 2026-10-09 16:49:22 +03:00
parent f1f686dbab
commit d54349594e
11 changed files with 3298 additions and 9 deletions

1
.gitignore vendored
View file

@ -8,3 +8,4 @@ release/
*.so *.so
*.dylib *.dylib
*.dll *.dll
debug_dumps

View file

@ -154,3 +154,597 @@ EXPORT double polygon_signed_area_2d_c(const double* pts, int N, int stride) {
} }
return area * 0.5; 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;
}

View file

@ -579,3 +579,53 @@ class SLICENODE_SimplifyPaths(SliceNode):
return paths return paths
return paths.simplify(tolerance, preserve_topology=self.preserve_topology) 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)

View file

@ -463,6 +463,42 @@ class SLICENODE_OT_Execute(Operator):
return {"FINISHED"} 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 @register_class
class SLICENODE_MT_DataInput(Menu): class SLICENODE_MT_DataInput(Menu):
bl_idname = "SLICENODE_MT_DataInput" 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.operator("node.add_node", text="Get Paths").type = "SLICENODE_GetPaths"
layout.separator() layout.separator()
layout.operator("node.add_node", text="Slice Shells").type = "SLICENODE_SliceShells" 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").type = "SLICENODE_BufferShells"
layout.operator("node.add_node", text="Buffer Shells Multiple").type = "SLICENODE_BufferShellsMultiple" layout.operator("node.add_node", text="Buffer Shells Multiple").type = "SLICENODE_BufferShellsMultiple"
layout.operator("node.add_node", text="Buffer Curve").type = "SLICENODE_BufferCurve" layout.operator("node.add_node", text="Buffer Curve").type = "SLICENODE_BufferCurve"

View file

@ -152,6 +152,58 @@ try:
ctypes.c_int ctypes.c_int
] ]
_c_seam_lib.polygon_signed_area_2d_c.restype = ctypes.c_double _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: except Exception:
_c_seam_lib = None _c_seam_lib = None
@ -262,6 +314,661 @@ def align_path_seam_sharpest(path: np.ndarray) -> np.ndarray:
return res 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]): class SlicePaths(list[np.ndarray]):
groups: list[int] 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) 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]: 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. Slices a single 3D triangle mesh (shape (F, 4, 3) or (F, 3, 3)) across multiple Z heights.

View file

@ -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`). - Base classes for NodeSlicer nodes (`SliceNode`) and sockets (`SliceNodeSocket`).
- Native Blender socket type mappings (`NodeSocketFloat`, `NodeSocketInt`, `NodeSocketVector`, `NodeSocketRotation`, `NodeSocketString`, `NodeSocketObject`, `NodeSocketGeometry`). - Native Blender socket type mappings (`NodeSocketFloat`, `NodeSocketInt`, `NodeSocketVector`, `NodeSocketRotation`, `NodeSocketString`, `NodeSocketObject`, `NodeSocketGeometry`).
- Core evaluation logic (`resolve_input`). - 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.

View file

@ -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`). - **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. - **Slice Shells**: Slices a `SlicingObject` into 2D `SlicePaths` at a given layer height. Automatically aligns path seams to the back (+Y) of each layer. Supports rotation for slicing at an angle.
- **Ignore Holes**: Boolean option to omit interior hole perimeters and generate only the outer shell boundaries. - **Ignore Holes**: Boolean option to omit interior hole perimeters and generate only the outer shell boundaries.
- **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**: Offsets/buffers 2D `SlicePaths`. Supports rotated offsetting and multiple miter types.
- **Buffer Shells Multiple**: Offsets 2D `SlicePaths` with multiple buffer passes. - **Buffer Shells Multiple**: Offsets 2D `SlicePaths` with multiple buffer passes.
- **Outputs**: - **Outputs**:

View file

@ -121,6 +121,11 @@ class SliceNode(Node, AutoBlIdName):
def evaluate_output(self, socket_name: str, **_kwargs) -> Any: ... 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: def resolve(self, fieldname) -> Any:
cache = getattr(self, "_current_cache", None) cache = getattr(self, "_current_cache", None)
visited = getattr(self, "_current_visited", None) visited = getattr(self, "_current_visited", None)
@ -683,15 +688,41 @@ def _resolve_output(
return default return default
assert socket.node.evaluate_output assert socket.node.evaluate_output
value = socket.node.evaluate_output( start_eval_time = time.perf_counter()
socket.name, eval_exc = None
identifier=socket.identifier, eval_tb = None
socket=socket, value = None
cache=cache, try:
visited=visited value = socket.node.evaluate_output(
) socket.name,
cache[key] = value identifier=socket.identifier,
return value 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: finally:
socket.node._current_cache = None # type: ignore socket.node._current_cache = None # type: ignore
socket.node._current_visited = None # type: ignore socket.node._current_visited = None # type: ignore

747
infra/debug.py Normal file
View file

@ -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"<max_depth_reached: {type(val).__name__}>"
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

327
tests/test_debug.py Normal file
View file

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

View file

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