This commit is contained in:
Nicolas Predella 2026-10-07 14:00:18 +03:00
parent 8ba3ea16c4
commit 8ceec61e76
8 changed files with 367 additions and 69 deletions

3
.gitignore vendored
View file

@ -5,3 +5,6 @@ release/
*.blend
*.blend1
*.bgcode
*.so
*.dylib
*.dll

View file

@ -8,18 +8,32 @@ TMPDIR="$(mktemp -d)"
cleanup() { rm -rf "$TMPDIR"; }
trap cleanup EXIT
if command -v gcc >/dev/null 2>&1; then
gcc -O3 -fPIC -shared "$SCRIPT_DIR/core/c_seam.c" -o "$SCRIPT_DIR/core/libseam_opt.so" -lm 2>/dev/null || true
elif command -v clang >/dev/null 2>&1; then
clang -O3 -fPIC -shared "$SCRIPT_DIR/core/c_seam.c" -o "$SCRIPT_DIR/core/libseam_opt.so" -lm 2>/dev/null || true
fi
mapfile -t FILES < <(git ls-files -co --exclude-standard \
| grep -v -xE '(\.gitignore|build\.sh)' \
| while read -r f; do [ -e "$f" ] && echo "$f"; done)
tar -cf - "${FILES[@]}" | tar -xf - -C "$TMPDIR"
if [ -f "$SCRIPT_DIR/core/libseam_opt.so" ]; then
cp "$SCRIPT_DIR/core/libseam_opt.so" "$TMPDIR/core/libseam_opt.so"
fi
if [ -d wheels ]; then
mkdir -p "$TMPDIR/wheels"
cp -r wheels/* "$TMPDIR/wheels/" 2>/dev/null || true
fi
BLENDER="${BLENDER:-/home/nicolas/Applications/blender-5.1.0-linux-x64/blender}"
if [ ! -x "$BLENDER" ]; then
BLENDER="$(command -v blender)"
fi
cd "$TMPDIR"
/home/nicolas/Applications/blender-5.1.0-linux-x64/blender \
"$BLENDER" \
--command extension build --split-platforms \
--output-filepath "$SCRIPT_DIR/release/NodeSlicer_0.0.1.zip"

88
core/c_seam.c Normal file
View file

@ -0,0 +1,88 @@
#include <stdint.h>
#include <math.h>
#include <stdlib.h>
#include <string.h>
#ifdef _WIN32
#define EXPORT __declspec(dllexport)
#else
#define EXPORT __attribute__((visibility("default")))
#endif
EXPORT int find_sharpest_seam_c(const double* pts, int M, int stride, double scale) {
if (M < 3) return 0;
int64_t x_buf[1024];
int64_t y_buf[1024];
int64_t* x = (M <= 1024) ? x_buf : (int64_t*)malloc(M * sizeof(int64_t));
int64_t* y = (M <= 1024) ? y_buf : (int64_t*)malloc(M * sizeof(int64_t));
for (int i = 0; i < M; ++i) {
x[i] = (int64_t)llround(pts[i * stride + 0] * scale);
y[i] = (int64_t)llround(pts[i * stride + 1] * scale);
}
int64_t signed_area = (x[M - 1] * y[0] - x[0] * y[M - 1]);
for (int i = 0; i < M - 1; ++i) {
signed_area += (x[i] * y[i + 1] - x[i + 1] * y[i]);
}
if (signed_area == 0) {
if (M > 1024) { free(x); free(y); }
return 0;
}
int best_idx = 0;
double min_cos_concave = 2.0;
int best_concave_idx = -1;
double min_cos_overall = 2.0;
for (int i = 0; i < M; ++i) {
int prev = (i == 0) ? M - 1 : i - 1;
int next = (i == M - 1) ? 0 : i + 1;
int64_t vin_x = x[i] - x[prev];
int64_t vin_y = y[i] - y[prev];
int64_t vout_x = x[next] - x[i];
int64_t vout_y = y[next] - y[i];
int64_t l2_in = vin_x * vin_x + vin_y * vin_y;
int64_t l2_out = vout_x * vout_x + vout_y * vout_y;
if (l2_in == 0 || l2_out == 0) continue;
int64_t cross = vin_x * vout_y - vin_y * vout_x;
int is_concave = (signed_area > 0) ? (cross < 0) : (cross > 0);
double dot = (double)(vin_x * vout_x + vin_y * vout_y);
double denom = sqrt((double)l2_in * (double)l2_out);
double cos_ang = dot / denom;
if (is_concave) {
if (cos_ang < min_cos_concave) {
min_cos_concave = cos_ang;
best_concave_idx = i;
}
}
if (cos_ang < min_cos_overall) {
min_cos_overall = cos_ang;
best_idx = i;
}
}
if (M > 1024) { free(x); free(y); }
if (best_concave_idx >= 0) return best_concave_idx;
return best_idx;
}
EXPORT void align_path_seam_sharpest_c(const double* in_pts, int M, int stride, double* out_pts, double scale) {
int best_idx = find_sharpest_seam_c(in_pts, M, stride, scale);
if (best_idx == 0) {
memcpy(out_pts, in_pts, (M + 1) * stride * sizeof(double));
return;
}
int split = M - best_idx;
memcpy(out_pts, in_pts + (best_idx * stride), split * stride * sizeof(double));
memcpy(out_pts + (split * stride), in_pts, (best_idx + 1) * stride * sizeof(double));
}

View file

@ -13,7 +13,9 @@ def crc32_list(l: list[np.ndarray]) -> int:
if not l: return 0
h = 0
for a in l:
h = zlib.adler32(struct.pack(">I", crc32_array(a)), h)
if a.size:
ac = np.ascontiguousarray(a)
h = zlib.adler32(ac.data, h)
return h
def euler_rotation_matrix(rx: float, ry: float, rz: float) -> np.ndarray:

View file

@ -20,7 +20,8 @@ def is_closed_path(path: np.ndarray) -> bool:
"""Checks if a 2D or 3D path forms a closed loop in integer micron space (1 micron unit)."""
if len(path) < 3:
return False
return bool(np.all(np.round(path[0, :2] * SCALE) == np.round(path[-1, :2] * SCALE)))
return (int(round(float(path[0, 0]) * SCALE)) == int(round(float(path[-1, 0]) * SCALE)) and
int(round(float(path[0, 1]) * SCALE)) == int(round(float(path[-1, 1]) * SCALE)))
def _transfer_path_attr(src_attr: np.ndarray, orig_pts: np.ndarray, new_pts: np.ndarray) -> np.ndarray:
"""
@ -34,10 +35,10 @@ def _transfer_path_attr(src_attr: np.ndarray, orig_pts: np.ndarray, new_pts: np.
return np.zeros(0, dtype=src_attr.dtype if hasattr(src_attr, 'dtype') else float)
if N == 0:
return np.zeros(M, dtype=src_attr.dtype if hasattr(src_attr, 'dtype') else float)
if N == M and is_closed_path(orig_pts) == is_closed_path(new_pts) and np.all(np.round(orig_pts * SCALE) == np.round(new_pts * SCALE)):
return src_attr.copy()
if len(src_attr) > 0 and np.all(src_attr == src_attr[0]):
if len(src_attr) > 0 and (len(src_attr) == 1 or np.all(src_attr == src_attr[0])):
return np.full(M, src_attr[0], dtype=src_attr.dtype if hasattr(src_attr, 'dtype') else float)
if N == M and is_closed_path(orig_pts) == is_closed_path(new_pts) and np.all(np.rint(orig_pts * SCALE) == np.rint(new_pts * SCALE)):
return src_attr.copy()
orig_diffs = np.diff(orig_pts[:, :3], axis=0)
orig_dists = np.sqrt((orig_diffs ** 2).sum(axis=1))
@ -75,12 +76,16 @@ def _transfer_group_attributes(src_sp: SlicePaths, res_paths: list[np.ndarray],
for idx, g in enumerate(src_groups):
src_group_map.setdefault(g, []).append(idx)
src_z_keys: list[int] | None = None
if len(src_sp) > 0:
src_z_keys = [int(round(float(src_sp[i][0, 2]) * SCALE)) if len(src_sp[i]) > 0 and src_sp[i].shape[1] > 2 else 0 for i in range(len(src_sp))]
for idx, (p, g) in enumerate(zip(res_paths, res_groups)):
M = len(p)
src_indices = src_group_map.get(g, [])
if not src_indices and len(src_sp) > 0:
z_um = int(round(p[0, 2] * SCALE)) if len(p) > 0 else 0
src_indices = [min(range(len(src_sp)), key=lambda i: abs(int(round(src_sp[i][0, 2] * SCALE)) - z_um) if len(src_sp[i]) > 0 else float('inf'))]
if not src_indices and len(src_sp) > 0 and src_z_keys is not None:
z_um = int(round(float(p[0, 2]) * SCALE)) if len(p) > 0 and p.shape[1] > 2 else 0
src_indices = [min(range(len(src_sp)), key=lambda i: abs(src_z_keys[i] - z_um))]
if src_indices:
src_i = src_indices[0]
@ -100,6 +105,36 @@ def _transfer_group_attributes(src_sp: SlicePaths, res_paths: list[np.ndarray],
return out_ext, out_feed, out_extra
import ctypes
import os
_c_seam_lib = None
try:
_so_dir = os.path.dirname(os.path.abspath(__file__))
_so_path = os.path.join(_so_dir, "libseam_opt.so")
_c_src = os.path.join(_so_dir, "c_seam.c")
if not os.path.exists(_so_path) and os.path.exists(_c_src):
import subprocess
for compiler in ("gcc", "clang"):
try:
subprocess.run([compiler, "-O3", "-fPIC", "-shared", _c_src, "-o", _so_path, "-lm"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if os.path.exists(_so_path):
break
except Exception:
pass
if os.path.exists(_so_path):
_c_seam_lib = ctypes.CDLL(_so_path)
_c_seam_lib.find_sharpest_seam_c.argtypes = [
ctypes.POINTER(ctypes.c_double),
ctypes.c_int,
ctypes.c_int,
ctypes.c_double
]
_c_seam_lib.find_sharpest_seam_c.restype = ctypes.c_int
except Exception:
_c_seam_lib = None
def align_path_seam_sharpest(path: np.ndarray) -> np.ndarray:
"""
Reorders a closed 2D/3D polygon path (N, 3) or (N, 2) so that its starting vertex (seam)
@ -120,40 +155,62 @@ def align_path_seam_sharpest(path: np.ndarray) -> np.ndarray:
if M < 3:
return path
pts_um = np.round(pts[:, :2] * SCALE).astype(np.int64)
if _c_seam_lib is not None:
try:
c_pts = np.ascontiguousarray(pts, dtype=np.float64)
stride = int(c_pts.shape[1])
ptr = c_pts.ctypes.data_as(ctypes.POINTER(ctypes.c_double))
best_idx = int(_c_seam_lib.find_sharpest_seam_c(ptr, M, stride, float(SCALE)))
if best_idx == 0:
return path
res = np.empty_like(path)
split = M - best_idx
res[:split] = path[best_idx:-1]
res[split:] = path[:best_idx + 1]
return res
except Exception:
pass
pts_um = np.rint(pts[:, :2] * SCALE).astype(np.int64)
x = pts_um[:, 0]
y = pts_um[:, 1]
x_next = np.roll(x, -1)
y_next = np.roll(y, -1)
signed_area_um = np.sum(x * y_next - x_next * y)
signed_area_um = np.sum(x[:-1] * y[1:] - x[1:] * y[:-1]) + (x[-1] * y[0] - x[0] * y[-1])
if signed_area_um == 0:
return path
prev_pts = np.roll(pts_um, 1, axis=0)
next_pts = np.roll(pts_um, -1, axis=0)
v_in = np.empty((M, 2), dtype=np.int64)
v_in[0, 0] = x[0] - x[-1]
v_in[0, 1] = y[0] - y[-1]
v_in[1:, 0] = x[1:] - x[:-1]
v_in[1:, 1] = y[1:] - y[:-1]
v_in = pts_um - prev_pts
v_out = next_pts - pts_um
l2_in = np.sum(v_in**2, axis=1)
l2_out = np.sum(v_out**2, axis=1)
valid = (l2_in > 0) & (l2_out > 0)
l2_in = v_in[:, 0]**2 + v_in[:, 1]**2
valid_in = l2_in > 0
valid_out = np.empty_like(valid_in)
valid_out[:-1] = valid_in[1:]
valid_out[-1] = valid_in[0]
valid = valid_in & valid_out
if not np.any(valid):
return path
cross_2d = v_in[:, 0] * v_out[:, 1] - v_in[:, 1] * v_out[:, 0]
if signed_area_um > 0:
concave_mask = valid & (cross_2d < 0)
else:
concave_mask = valid & (cross_2d > 0)
cross_2d = np.empty(M, dtype=np.int64)
cross_2d[:-1] = v_in[:-1, 0] * v_in[1:, 1] - v_in[:-1, 1] * v_in[1:, 0]
cross_2d[-1] = v_in[-1, 0] * v_in[0, 1] - v_in[-1, 1] * v_in[0, 0]
l_in = np.sqrt(l2_in.astype(np.float64))
l_out = np.sqrt(l2_out.astype(np.float64))
concave_mask = valid & (cross_2d < 0) if signed_area_um > 0 else valid & (cross_2d > 0)
l_in = np.sqrt(l2_in, dtype=np.float64)
l_out = np.empty_like(l_in)
l_out[:-1] = l_in[1:]
l_out[-1] = l_in[0]
dot_prods = np.empty(M, dtype=np.float64)
dot_prods[:-1] = v_in[:-1, 0] * v_in[1:, 0] + v_in[:-1, 1] * v_in[1:, 1]
dot_prods[-1] = v_in[-1, 0] * v_in[0, 0] + v_in[-1, 1] * v_in[0, 1]
cos_angles = np.full(M, 1.0)
dot_prods = np.sum(v_in[valid] * v_out[valid], axis=1).astype(np.float64)
cos_angles[valid] = dot_prods / (l_in[valid] * l_out[valid])
denom = l_in[valid] * l_out[valid]
cos_angles[valid] = dot_prods[valid] / denom
if np.any(concave_mask):
concave_indices = np.where(concave_mask)[0]
@ -164,7 +221,11 @@ def align_path_seam_sharpest(path: np.ndarray) -> np.ndarray:
if best_idx == 0:
return path
return np.vstack([path[best_idx:-1], path[:best_idx + 1]])
res = np.empty_like(path)
split = M - best_idx
res[:split] = path[best_idx:-1]
res[split:] = path[:best_idx + 1]
return res
class SlicePaths(list[np.ndarray]):
@ -260,12 +321,21 @@ class SlicePaths(list[np.ndarray]):
if not self or count <= 0:
return self.copy_with([])
all_buffers: list[SlicePaths] = []
for i in range(count):
dist = base_distance + (i * width_step)
all_buffers.append(self.buffer(dist, miter=miter, rotation=rotation))
p = self
if rotation and any(rotation):
from mathutils import Euler
mat = Euler(rotation).to_matrix().inverted()
p = self.transform(np.array(mat))
return SlicePaths.zip_paths(all_buffers, primary_source=self)
res_paths, res_groups = buffer_multiple_slice(p, base_distance, count, width_step, miter=miter)
new_paths = self.copy_with(res_paths, res_groups)
if rotation and any(rotation):
from mathutils import Euler
mat_inv = Euler(rotation).to_matrix()
new_paths = new_paths.transform(np.array(mat_inv))
return new_paths
def buffer_curve(self, distance: float, cap_style: str = 'round', join_style: str = 'round', single_sided: bool = False, rotation: Any = None) -> SlicePaths:
"""Buffers open curves using shapely. Handles optional rotation for non-planar buffering."""
@ -709,13 +779,19 @@ def _slice_single_mesh(mesh_array: np.ndarray, z_values: np.ndarray | list[float
cs = m.slice(slice_z)
polys = cs.to_polygons()
for poly in polys:
if len(poly) < 3:
N = len(poly)
if N < 3:
continue
pts_scaled = (poly * SCALE).round().astype(np.int64).tolist()
if ignore_holes and not pyclipper.Orientation(pts_scaled):
if ignore_holes:
p_x = poly[:, 0]
p_y = poly[:, 1]
signed_area = np.sum(p_x[:-1] * p_y[1:] - p_x[1:] * p_y[:-1]) + (p_x[-1] * p_y[0] - p_x[0] * p_y[-1])
if signed_area <= 0:
continue
p3d = np.column_stack([poly.astype(np.float64), np.full(len(poly), zf, dtype=np.float64)])
p3d = np.vstack([p3d, p3d[0]])
p3d = np.empty((N + 1, 3), dtype=np.float64)
p3d[:N, :2] = poly
p3d[:N, 2] = zf
p3d[N] = p3d[0]
p3d = align_path_seam_sharpest(p3d)
all_paths.append(p3d)
return all_paths
@ -1590,12 +1666,18 @@ def geometry_to_paths(geom: Any, z: float) -> list[np.ndarray]:
return paths
if isinstance(geom, Polygon):
ext_coords = np.array(geom.exterior.coords)
ext_coords_3d = np.column_stack([ext_coords, np.full(len(ext_coords), z)])
ext_coords = np.asarray(geom.exterior.coords, dtype=np.float64)
N = len(ext_coords)
ext_coords_3d = np.empty((N, 3), dtype=np.float64)
ext_coords_3d[:, :2] = ext_coords
ext_coords_3d[:, 2] = z
paths.append(ext_coords_3d)
for interior in geom.interiors:
int_coords = np.array(interior.coords)
int_coords_3d = np.column_stack([int_coords, np.full(len(int_coords), z)])
int_coords = np.asarray(interior.coords, dtype=np.float64)
M = len(int_coords)
int_coords_3d = np.empty((M, 3), dtype=np.float64)
int_coords_3d[:, :2] = int_coords
int_coords_3d[:, 2] = z
paths.append(int_coords_3d)
elif isinstance(geom, (MultiPolygon, GeometryCollection)):
for poly in geom.geoms:
@ -1663,13 +1745,19 @@ def offset_geometry_to_paths(geom: Any, z: float) -> list[np.ndarray]:
return paths
if isinstance(geom, (LineString, LinearRing)):
coords = np.array(geom.coords)
coords_3d = np.column_stack([coords, np.full(len(coords), z)])
coords = np.asarray(geom.coords, dtype=np.float64)
N = len(coords)
coords_3d = np.empty((N, 3), dtype=np.float64)
coords_3d[:, :2] = coords
coords_3d[:, 2] = z
paths.append(coords_3d)
elif isinstance(geom, MultiLineString):
for line in geom.geoms:
coords = np.array(line.coords)
coords_3d = np.column_stack([coords, np.full(len(coords), z)])
coords = np.asarray(line.coords, dtype=np.float64)
N = len(coords)
coords_3d = np.empty((N, 3), dtype=np.float64)
coords_3d[:, :2] = coords
coords_3d[:, 2] = z
paths.append(coords_3d)
elif isinstance(geom, GeometryCollection):
for sub_geom in geom.geoms:
@ -1929,36 +2017,115 @@ def buffer_slice(slice_paths: list[np.ndarray], distance: float, miter='round',
paths_list: list[np.ndarray] = []
groups_list: list[int] = []
pco = pyclipper.PyclipperOffset(arc_tolerance=int(round(tolerance * SCALE))) # pyright: ignore[reportAttributeAccessIssue]
scale_inv = 1.0 / SCALE
delta_scaled = int(round(distance * SCALE))
# Buffer each layer independently
group_id = 0
for z_key in sorted(groups.keys()):
layer_paths = groups[z_key]
z = float(z_key) / SCALE
z = float(z_key) * scale_inv
pco.Clear()
scaled_paths = []
for p in layer_paths:
path_pts = p[:, :2]
# Strip redundant closing points
if is_closed_path(p):
path_pts = path_pts[:-1]
if len(p) < 3:
continue
path_pts = p[:-1, :2] if is_closed_path(p) else p[:, :2]
if len(path_pts) < 3:
continue
scaled_paths.append(np.rint(path_pts * SCALE).astype(np.int64).tolist())
if len(path_pts) < 3: continue
scaled_paths.append((path_pts * SCALE).round().astype(np.int64).tolist())
if not scaled_paths: continue
if not scaled_paths:
continue
# Add all paths for this specific Z layer
pco.AddPaths(scaled_paths, jt, pyclipper.ET_CLOSEDPOLYGON) # pyright: ignore[reportAttributeAccessIssue]
solution = pco.Execute(int(round(distance * SCALE)))
solution = pco.Execute(delta_scaled)
for path in solution:
if not path: continue
fpath = np.array(path, dtype=np.float64) / SCALE
fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)])
# Re-close loop
fpath_3d = np.vstack([fpath_3d, fpath_3d[0]])
if not path:
continue
N = len(path)
fpath_3d = np.empty((N + 1, 3), dtype=np.float64)
arr = np.asarray(path, dtype=np.float64)
fpath_3d[:N, :2] = arr * scale_inv
fpath_3d[:N, 2] = z
fpath_3d[N] = fpath_3d[0]
fpath_3d = align_path_seam_sharpest(fpath_3d)
paths_list.append(fpath_3d)
groups_list.append(group_id)
group_id += 1
return paths_list, groups_list
@ndarray_lru_cache(maxsize=64)
def buffer_multiple_slice(
slice_paths: list[np.ndarray],
base_distance: float,
count: int,
width_step: float,
miter: str = 'round',
tolerance: float = 0.01
) -> tuple[list[np.ndarray], list[int]]:
"""Generates multiple concentric buffers per layer in a single pass."""
if not slice_paths or count <= 0:
return [], []
groups: dict[int, list[np.ndarray]] = {}
for p in slice_paths:
z = p[0, 2]
z_key = int(round(z * SCALE))
groups.setdefault(z_key, []).append(p)
join_types = {
'round': pyclipper.JT_ROUND, # pyright: ignore[reportAttributeAccessIssue]
'square': pyclipper.JT_SQUARE, # pyright: ignore[reportAttributeAccessIssue]
'flat': pyclipper.JT_SQUARE, # pyright: ignore[reportAttributeAccessIssue]
'miter': pyclipper.JT_MITER # pyright: ignore[reportAttributeAccessIssue]
}
jt = join_types.get(miter, pyclipper.JT_ROUND) # pyright: ignore[reportAttributeAccessIssue]
paths_list: list[np.ndarray] = []
groups_list: list[int] = []
pco = pyclipper.PyclipperOffset(arc_tolerance=int(round(tolerance * SCALE))) # pyright: ignore[reportAttributeAccessIssue]
scaled_dists = [int(round((base_distance + i * width_step) * SCALE)) for i in range(count)]
scale_inv = 1.0 / SCALE
group_id = 0
for z_key in sorted(groups.keys()):
layer_paths = groups[z_key]
z = float(z_key) * scale_inv
pco.Clear()
scaled_paths = []
for p in layer_paths:
if len(p) < 3:
continue
path_pts = p[:-1, :2] if is_closed_path(p) else p[:, :2]
if len(path_pts) < 3:
continue
scaled_paths.append(np.rint(path_pts * SCALE).astype(np.int64).tolist())
if not scaled_paths:
continue
pco.AddPaths(scaled_paths, jt, pyclipper.ET_CLOSEDPOLYGON) # pyright: ignore[reportAttributeAccessIssue]
for d_scaled in scaled_dists:
solution = pco.Execute(d_scaled)
for path in solution:
if not path:
continue
N = len(path)
fpath_3d = np.empty((N + 1, 3), dtype=np.float64)
arr = np.asarray(path, dtype=np.float64)
fpath_3d[:N, :2] = arr * scale_inv
fpath_3d[:N, 2] = z
fpath_3d[N] = fpath_3d[0]
fpath_3d = align_path_seam_sharpest(fpath_3d)
paths_list.append(fpath_3d)
groups_list.append(group_id)

View file

@ -34,6 +34,7 @@ This file provides a mapping of modules to their primary responsibilities and ke
- `segments_to_slice()`: Utility to convert raw intersection segments into continuous paths (closed polygons are processed via Pyclipper, supports `ignore_holes`, open paths remain open).
- `link_segments()`: Logic for joining loose segments into continuous paths.
- `buffer_slice()`: Offset utility using PyclipperOffset. Supports rotated offsetting.
- `buffer_multiple_slice()`: High-performance single-pass multi-shell concentric offset utility for slicing layers.
- `infill_slice()`: Generates rotated grid or rectilinear infill patterns, ensuring layer-to-layer consistency.
- `boolean_slice()`: Performs 2D boolean operations (Union, Intersection, Subtract, XOR) on slice sets with hole support.
- `path_operations_slice()`: Unified dispatcher for multi-input path operations (`ZIP`, `EXTEND`, `MAX_COVERAGE`, `MIN_COVERAGE`, `SUBTRACT`, `XOR`).

View file

@ -1,6 +1,6 @@
M73 P0 R19
EXCLUDE_OBJECT_DEFINE NAME=model CENTER=509.338,496.519 POLYGON=[[300.000,300.000],[700.000,300.000],[700.000,700.000],[300.000,700.000]]
START_PRINT BED_TEMPERATURE=80 KAMP_LEVELING=1 EXTRUDER_ROTATION_VOLUME=755 MIXING_STEPPER_ROTATION_VOLUME=12000 PURGE_LAYER_HEIGHT=2 PURGE_PARKING_SPEED=10000 PURGE_LENGHT=500 PURGE_SPEED=500 PURGE_MATERIAL_QUANTITY=10000 EXTRUDER_TEMPERATURE=240 EXTRUDER_TEMPERATURE_INITIAL_LAYER=240 PRESSURE_ADVANCE=0.2 PRESSURE_ADVANCE_SMOOTH_TIME=0.5 ZONE_1_TEMPERATURE=210 ZONE_2_TEMPERATURE=220 ZONE_3_TEMPERATURE=230
START_PRINT BED_TEMPERATURE=80 KAMP_LEVELING=1 EXTRUDER_ROTATION_VOLUME=755 MIXING_STEPPER_ROTATION_VOLUME=12000 PURGE_LAYER_HEIGHT=2 PURGE_PARKING_SPEED=10000 PURGE_LENGHT=500 PURGE_SPEED=500 PURGE_MATERIAL_QUANTITY=10000 EXTRUDER_TEMPERATURE=220 EXTRUDER_TEMPERATURE_INITIAL_LAYER=220 PRESSURE_ADVANCE=0.2 PRESSURE_ADVANCE_SMOOTH_TIME=0.5 ZONE_1_TEMPERATURE=210 ZONE_2_TEMPERATURE=210 ZONE_3_TEMPERATURE=220
G90
G21
M83
@ -19,4 +19,4 @@ M140 S80
G1 E-135.000 F1800
G1 X351.485016 Y677.991119 Z1.848387 F9000
G1 E135.000 F1800
M106 S255
M106 S170

View file

@ -1968,6 +1968,29 @@ class TestCodeReviewBugFixes(unittest.TestCase):
self.assertEqual(len(res[0]), 1)
self.assertEqual(len(res[0][0]), 2) # 1 line segment with 2 endpoints
def test_buffer_multiple_multilayer(self):
from NodeSlicer.core.slice import SlicePaths, buffer_multiple_slice
# Two layers: z=0.2 (square 10x10) and z=0.4 (square 10x10)
p1 = np.array([[0, 0, 0.2], [10, 0, 0.2], [10, 10, 0.2], [0, 10, 0.2], [0, 0, 0.2]])
p2 = np.array([[0, 0, 0.4], [10, 0, 0.4], [10, 10, 0.4], [0, 10, 0.4], [0, 0, 0.4]])
sp = SlicePaths([p1, p2], groups=[0, 1])
# 3 shells with base -1.0, step -1.0
buffered = sp.buffer_multiple(-1.0, count=3, width_step=-1.0)
self.assertEqual(len(buffered), 6) # 3 shells on layer 0, 3 shells on layer 1
self.assertEqual(buffered.groups, [0, 0, 0, 1, 1, 1])
# Check Z height consistency
for i in range(3):
self.assertAlmostEqual(buffered[i][0, 2], 0.2)
for i in range(3, 6):
self.assertAlmostEqual(buffered[i][0, 2], 0.4)
# Check buffer_multiple_slice directly
raw_paths, raw_groups = buffer_multiple_slice([p1, p2], -1.0, 3, -1.0)
self.assertEqual(len(raw_paths), 6)
self.assertEqual(raw_groups, [0, 0, 0, 1, 1, 1])
if __name__ == '__main__':
unittest.main()