custom seams
This commit is contained in:
parent
f1f686dbab
commit
d54349594e
11 changed files with 3298 additions and 9 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,3 +8,4 @@ release/
|
||||||
*.so
|
*.so
|
||||||
*.dylib
|
*.dylib
|
||||||
*.dll
|
*.dll
|
||||||
|
debug_dumps
|
||||||
|
|
|
||||||
594
core/c_seam.c
594
core/c_seam.c
|
|
@ -154,3 +154,597 @@ EXPORT double polygon_signed_area_2d_c(const double* pts, int N, int stride) {
|
||||||
}
|
}
|
||||||
return area * 0.5;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
872
core/slice.py
872
core/slice.py
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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**:
|
||||||
|
|
|
||||||
|
|
@ -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
747
infra/debug.py
Normal 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
327
tests/test_debug.py
Normal 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()
|
||||||
|
|
||||||
619
tests/test_seam_placement.py
Normal file
619
tests/test_seam_placement.py
Normal 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()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Loading…
Add table
Reference in a new issue