diff --git a/.gitignore b/.gitignore index 0a887ad..0515e60 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ release/ *.blend *.blend1 *.bgcode +*.so +*.dylib +*.dll diff --git a/build.sh b/build.sh index c110376..eb40833 100755 --- a/build.sh +++ b/build.sh @@ -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" diff --git a/core/c_seam.c b/core/c_seam.c new file mode 100644 index 0000000..72c5edb --- /dev/null +++ b/core/c_seam.c @@ -0,0 +1,88 @@ +#include +#include +#include +#include + +#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)); +} diff --git a/core/geometry.py b/core/geometry.py index 791081a..31752fd 100644 --- a/core/geometry.py +++ b/core/geometry.py @@ -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: diff --git a/core/slice.py b/core/slice.py index abc1431..246070e 100644 --- a/core/slice.py +++ b/core/slice.py @@ -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): - continue - p3d = np.column_stack([poly.astype(np.float64), np.full(len(poly), zf, dtype=np.float64)]) - p3d = np.vstack([p3d, p3d[0]]) + 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.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,41 @@ 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(path_pts) < 3: continue - scaled_paths.append((path_pts * SCALE).round().astype(np.int64).tolist()) + 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 + 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) @@ -1967,6 +2060,80 @@ def buffer_slice(slice_paths: list[np.ndarray], distance: float, miter='round', 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) + + group_id += 1 + + return paths_list, groups_list + @ndarray_lru_cache(maxsize=32) def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float = 0.0, infill_type: str = 'GRID') -> tuple[list[np.ndarray], list[int]]: """Generates a grid or rectilinear infill clipped by the provided paths. Consistent across layers using 1-micron integer grid.""" diff --git a/doc/MODULES.md b/doc/MODULES.md index 0c7d4e7..d29e8df 100644 --- a/doc/MODULES.md +++ b/doc/MODULES.md @@ -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`). diff --git a/gcode_snippets/ginger_start.gcode b/gcode_snippets/ginger_start.gcode index 088bd26..de52691 100644 --- a/gcode_snippets/ginger_start.gcode +++ b/gcode_snippets/ginger_start.gcode @@ -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 diff --git a/tests/test_slice.py b/tests/test_slice.py index 3fbc7ef..9f7924e 100644 --- a/tests/test_slice.py +++ b/tests/test_slice.py @@ -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()