From 2b98e9bc69c12e467f348f406571b5bd9885401a Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Mon, 20 Jul 2026 17:08:22 +0300 Subject: [PATCH] conserve attributes --- core/nodeslicer/nodes/path.py | 78 +------------ core/slice.py | 206 ++++++++++++++++++++++++++++++---- tests/test_slice.py | 60 ++++++++++ 3 files changed, 250 insertions(+), 94 deletions(-) diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index 8a051a7..e065fc7 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -325,32 +325,7 @@ class SLICENODE_BufferShellsMultiple(SliceNode): if not paths: return SlicePaths() - all_buffers: list[SlicePaths] = [] - - for i in range(count): - dist = base_distance + (i * width_step) - all_buffers.append(paths.buffer(dist, miter=self.shape_option, rotation=rotation)) - - # Zip Logic: Interleave paths by group (Z-layer) - all_groups = set() - grouped_inputs = [] - for p_list in all_buffers: - d = {} - for i, g in enumerate(p_list.groups): - all_groups.add(g) - d.setdefault(g, []).append(p_list[i]) - grouped_inputs.append(d) - - sorted_groups = sorted(list(all_groups)) - - merged = SlicePaths() - for g in sorted_groups: - for d in grouped_inputs: - group_paths = d.get(g, []) - for p in group_paths: - merged.append_with_group(p, g) - - return paths.copy_with(merged, merged.groups) + return paths.buffer_multiple(base_distance, count, width_step, miter=self.shape_option, rotation=rotation) @register_class class SLICENODE_Infill(SliceNode): @@ -478,12 +453,8 @@ class SLICENODE_JoinSlices(SliceNodeJoiner): accumulated = current continue - # Boolean operation loses specific per-segment grouping as it merges polygons. - # We return a new SlicePaths from the result. accumulated = accumulated.boolean(current, self.operation, connect_along_boundary=self.connect_along_boundary) - pass - return accumulated @register_class @@ -511,41 +482,15 @@ class SLICENODE_JoinPaths(SliceNodeJoiner): for input in self.inputs: paths: SlicePaths = resolve_input(input) if paths: - # paths.normalize_groups() inputs_data.append(paths) if not inputs_data: return SlicePaths() if self.mode == 'EXTEND': - merged = SlicePaths() - for paths in inputs_data: - merged.extend_with_groups(paths) - - return merged - + return SlicePaths.extend_paths(inputs_data) elif self.mode == 'ZIP': - # Group-based Zip: sequentially add each group from all inputs. - # E.g. Group 1 (In1), Group 1 (In2), Group 2 (In1), Group 2 (In2)... - all_groups = set() - grouped_inputs = [] - for p_list in inputs_data: - d = {} - for i, g in enumerate(p_list.groups): - all_groups.add(g) - d.setdefault(g, []).append(p_list[i]) - grouped_inputs.append(d) - - sorted_groups = sorted(all_groups) - - merged = SlicePaths() - for g in sorted_groups: - for d in grouped_inputs: - group_paths = d.get(g, []) - for p in group_paths: - merged.append_with_group(p, g) - - return merged + return SlicePaths.zip_paths(inputs_data) return SlicePaths() @@ -579,19 +524,4 @@ class SLICENODE_SimplifyPaths(SliceNode): if not paths or tolerance <= 0: return paths - from shapely.geometry import LineString - import numpy as np - - simplified = [] - for p in paths: - if len(p) < 3: - simplified.append(p) - continue - line = LineString(p) - simplified_line = line.simplify(tolerance, preserve_topology=self.preserve_topology) - if simplified_line.geom_type == 'LineString' and len(simplified_line.coords) >= 2: - simplified.append(np.array(simplified_line.coords, dtype=p.dtype)) - else: - simplified.append(p) - - return paths.copy_with(simplified) + return paths.simplify(tolerance, preserve_topology=self.preserve_topology) diff --git a/core/slice.py b/core/slice.py index 572a50f..b2b16d5 100644 --- a/core/slice.py +++ b/core/slice.py @@ -10,6 +10,84 @@ from .geometry import ndarray_lru_cache # Scaling factor for pyclipper (it works with integers) SCALE = 1_000_000 +def _transfer_path_attr(src_attr: np.ndarray, orig_pts: np.ndarray, new_pts: np.ndarray) -> np.ndarray: + """ + Transfers 1D per-vertex attribute from orig_pts (shape N, 3) to new_pts (shape M, 3). + If src_attr is uniform (all values equal), returns array of shape (M,) filled with that value. + Otherwise, interpolates along normalized cumulative length. + """ + N = len(orig_pts) + M = len(new_pts) + if M == 0: + 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 np.allclose(orig_pts, new_pts): + return src_attr.copy() + if len(src_attr) > 0 and 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) + + orig_diffs = np.diff(orig_pts[:, :3], axis=0) + orig_dists = np.sqrt((orig_diffs ** 2).sum(axis=1)) + orig_cum = np.concatenate(([0.0], np.cumsum(orig_dists))) + total_orig = orig_cum[-1] + if total_orig > 1e-7: + orig_t = orig_cum / total_orig + else: + orig_t = np.linspace(0.0, 1.0, N) + + new_diffs = np.diff(new_pts[:, :3], axis=0) + new_dists = np.sqrt((new_diffs ** 2).sum(axis=1)) + new_cum = np.concatenate(([0.0], np.cumsum(new_dists))) + total_new = new_cum[-1] + if total_new > 1e-7: + new_t = new_cum / total_new + else: + new_t = np.linspace(0.0, 1.0, M) + + return np.interp(new_t, orig_t, src_attr) + + +def _transfer_group_attributes(src_sp: SlicePaths, res_paths: list[np.ndarray], res_groups: list[int]) -> tuple[list[np.ndarray], list[np.ndarray], dict[str, list[np.ndarray]]]: + """ + Given input SlicePaths src_sp and output res_paths/res_groups, + constructs extrusion_rate, feedrate, and extra_attributes for res_paths + by matching groups or nearest paths in src_sp. + """ + out_ext: list[np.ndarray] = [] + out_feed: list[np.ndarray] = [] + out_extra: dict[str, list[np.ndarray]] = {k: [] for k in getattr(src_sp, "extra_attributes", {})} + + src_groups = getattr(src_sp, "groups", []) + src_group_map: dict[int, list[int]] = {} + for idx, g in enumerate(src_groups): + src_group_map.setdefault(g, []).append(idx) + + 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 = p[0, 2] if len(p) > 0 else 0.0 + src_indices = [min(range(len(src_sp)), key=lambda i: abs(src_sp[i][0, 2] - z) if len(src_sp[i]) > 0 else float('inf'))] + + if src_indices: + src_i = src_indices[0] + ext = _transfer_path_attr(src_sp.extrusion_rate[src_i], src_sp[src_i], p) + feed = _transfer_path_attr(src_sp.feedrate[src_i], src_sp[src_i], p) + out_ext.append(ext) + out_feed.append(feed) + for k in src_sp.extra_attributes: + attr_arr = _transfer_path_attr(src_sp.extra_attributes[k][src_i], src_sp[src_i], p) + out_extra[k].append(attr_arr) + else: + out_ext.append(np.ones(M)) + out_feed.append(np.zeros(M)) + for k in src_sp.extra_attributes: + out_extra[k].append(np.zeros(M)) + + return out_ext, out_feed, out_extra + + class SlicePaths(list[np.ndarray]): groups: list[int] extrusion_rate: list[np.ndarray] @@ -38,22 +116,19 @@ class SlicePaths(list[np.ndarray]): self.extra_attributes = extra_attributes or {} def copy_with(self, paths: list[np.ndarray], groups: list[int] | None = None, extrusion_rate: list[np.ndarray] | None = None, feedrate: list[np.ndarray] | None = None, extra_attributes: dict[str, list[np.ndarray]] | None = None) -> SlicePaths: - """Returns a new SlicePaths object with new paths/groups but preserving all other metadata.""" - if extrusion_rate is None: - # Only reuse extrusion_rate if path lengths match exactly - if len(paths) == len(self) and all(len(p) == len(op) for p, op in zip(paths, self)): - extrusion_rate = self.extrusion_rate - - if feedrate is None: - if len(paths) == len(self) and all(len(p) == len(op) for p, op in zip(paths, self)): - feedrate = self.feedrate - - if extra_attributes is None: - if len(paths) == len(self) and all(len(p) == len(op) for p, op in zip(paths, self)): - extra_attributes = self.extra_attributes + """Returns a new SlicePaths object with new paths/groups, preserving all metadata and instance attributes.""" + target_groups = groups if groups is not None else (self.groups if len(self.groups) == len(paths) else list(range(len(paths)))) - new_obj = SlicePaths(paths, groups if groups is not None else self.groups, extrusion_rate, feedrate, extra_attributes) - # Copy any other attributes that might have been added + if extrusion_rate is None or feedrate is None or extra_attributes is None: + calc_ext, calc_feed, calc_extra = _transfer_group_attributes(self, paths, target_groups) + if extrusion_rate is None: + extrusion_rate = calc_ext + if feedrate is None: + feedrate = calc_feed + if extra_attributes is None: + extra_attributes = calc_extra + + new_obj = SlicePaths(paths, target_groups, extrusion_rate, feedrate, extra_attributes) for k, v in self.__dict__.items(): if k not in ["groups", "extrusion_rate", "feedrate", "extra_attributes"]: setattr(new_obj, k, v) @@ -85,6 +160,18 @@ class SlicePaths(list[np.ndarray]): return new_paths + def buffer_multiple(self, base_distance: float, count: int, width_step: float, miter: str = 'round', rotation: Any = None) -> SlicePaths: + """Generates multiple concentric buffers and interleaves them by group (Z-layer), preserving all metadata.""" + 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)) + + return SlicePaths.zip_paths(all_buffers, primary_source=self) + 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.""" p = self @@ -121,6 +208,69 @@ class SlicePaths(list[np.ndarray]): return new_paths + def simplify(self, tolerance: float = 0.1, preserve_topology: bool = True) -> SlicePaths: + """Simplifies paths using Shapely while conserving all per-path metadata and attributes.""" + if not self or tolerance <= 0: + return self + + from shapely.geometry import LineString + + simplified_paths = [] + for p in self: + if len(p) < 3: + simplified_paths.append(p) + continue + line = LineString(p) + simplified_line = line.simplify(tolerance, preserve_topology=preserve_topology) + if simplified_line.geom_type == 'LineString' and len(simplified_line.coords) >= 2: + simplified_paths.append(np.array(simplified_line.coords, dtype=p.dtype)) + else: + simplified_paths.append(p) + + return self.copy_with(simplified_paths) + + @classmethod + def extend_paths(cls, paths_list: list[SlicePaths], primary_source: SlicePaths | None = None) -> SlicePaths: + """Combines multiple SlicePaths sequentially into one SlicePaths, conserving all metadata and attributes.""" + if not paths_list: + return SlicePaths() + + base = primary_source if primary_source is not None else paths_list[0] + merged = base.copy_with([]) + + for sp in paths_list: + merged.extend_with_groups(sp) + + return merged + + @classmethod + def zip_paths(cls, paths_list: list[SlicePaths], primary_source: SlicePaths | None = None) -> SlicePaths: + """Interleaves multiple SlicePaths layer by layer (by group ID), conserving all metadata and attributes.""" + if not paths_list: + return SlicePaths() + + base = primary_source if primary_source is not None else paths_list[0] + + all_groups = set() + grouped_inputs = [] + for p_list in paths_list: + d = {} + for i, g in enumerate(p_list.groups): + all_groups.add(g) + d.setdefault(g, []).append((p_list[i], p_list.extrusion_rate[i], p_list.feedrate[i], {k: v[i] for k, v in p_list.extra_attributes.items()})) + grouped_inputs.append(d) + + sorted_groups = sorted(list(all_groups)) + + merged = base.copy_with([]) + for g in sorted_groups: + for d in grouped_inputs: + items = d.get(g, []) + for path, ext, feed, extra in items: + merged.append_with_group(path, group=g, extrusion_rate=ext, feedrate=feed, extra_attributes=extra) + + return merged + def get_infill_mask(self) -> SlicePaths: """Computes the intersection of all paths in each group using shapely.""" res_paths, res_groups = get_infill_mask_slice(self) @@ -173,6 +323,14 @@ class SlicePaths(list[np.ndarray]): return self.copy_with(new_paths) + new_paths = [] + for p in self: + # Shift to center, rotate, shift back + new_p = (p - center) @ rot_mat.T + center + new_paths.append(new_p) + + return self.copy_with(new_paths) + def get_first_layer_midpoint(self) -> np.ndarray: if not self: return np.zeros(3) @@ -664,11 +822,19 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI results.groups.extend([group] * len(layer_results)) group += 1 - for attr in ["layer_height", "layer_width"]: - if hasattr(subject, attr): - setattr(results, attr, getattr(subject, attr)) - elif hasattr(object, attr): - setattr(results, attr, getattr(object, attr)) + # Transfer per-group/path metadata (extrusion_rate, feedrate, extra_attributes) from subject + calc_ext, calc_feed, calc_extra = _transfer_group_attributes(subject, results, results.groups) + results.extrusion_rate = calc_ext + results.feedrate = calc_feed + results.extra_attributes = calc_extra + + for attr, val in subject.__dict__.items(): + if attr not in ["groups", "extrusion_rate", "feedrate", "extra_attributes"]: + setattr(results, attr, val) + for attr, val in object.__dict__.items(): + if attr not in ["groups", "extrusion_rate", "feedrate", "extra_attributes"]: + if not hasattr(results, attr): + setattr(results, attr, val) return results diff --git a/tests/test_slice.py b/tests/test_slice.py index 266fbd1..152dec2 100644 --- a/tests/test_slice.py +++ b/tests/test_slice.py @@ -534,5 +534,65 @@ class TestSlice(unittest.TestCase): # First layer extrusion rates should be multiplied by 0.3 / 0.2 = 1.5 np.testing.assert_allclose(slices.extrusion_rate[0], 1.5) + def test_slicepaths_attribute_conservation(self): + from NodeSlicer.core.slice import SlicePaths + + path1 = np.array([ + [0.0, 0.0, 0.2], + [10.0, 0.0, 0.2], + [10.0, 10.0, 0.2], + [0.0, 10.0, 0.2], + [0.0, 0.0, 0.2] + ]) + + sp = SlicePaths([path1], groups=[0], extrusion_rate=[np.array([1.5, 1.5, 1.5, 1.5, 1.5])]) + sp.layer_height = 0.2 + sp.first_layer_height = 0.3 + sp.custom_meta = "test_value" + + # Test offset + off = sp.offset(np.array([1.0, 1.0, 0.0])) + self.assertEqual(getattr(off, "layer_height", None), 0.2) + self.assertEqual(getattr(off, "first_layer_height", None), 0.3) + self.assertEqual(getattr(off, "custom_meta", None), "test_value") + np.testing.assert_allclose(off.extrusion_rate[0], 1.5) + + # Test simplify + simp = sp.simplify(0.05) + self.assertEqual(getattr(simp, "layer_height", None), 0.2) + self.assertEqual(getattr(simp, "first_layer_height", None), 0.3) + self.assertEqual(getattr(simp, "custom_meta", None), "test_value") + self.assertEqual(len(simp.extrusion_rate[0]), len(simp[0])) + np.testing.assert_allclose(simp.extrusion_rate[0], 1.5) + + # Test buffer + buf = sp.buffer(0.5) + self.assertEqual(getattr(buf, "layer_height", None), 0.2) + self.assertEqual(getattr(buf, "first_layer_height", None), 0.3) + self.assertEqual(getattr(buf, "custom_meta", None), "test_value") + self.assertTrue(len(buf) > 0) + np.testing.assert_allclose(buf.extrusion_rate[0], 1.5) + + # Test buffer_multiple + buf_mult = sp.buffer_multiple(0.2, count=2, width_step=0.2) + self.assertEqual(getattr(buf_mult, "layer_height", None), 0.2) + self.assertEqual(getattr(buf_mult, "first_layer_height", None), 0.3) + self.assertEqual(getattr(buf_mult, "custom_meta", None), "test_value") + self.assertTrue(len(buf_mult) >= 2) + for ext in buf_mult.extrusion_rate: + np.testing.assert_allclose(ext, 1.5) + + # Test zip_paths & extend_paths + sp2 = SlicePaths([path1], groups=[1], extrusion_rate=[np.array([1.0, 1.0, 1.0, 1.0, 1.0])]) + zipped = SlicePaths.zip_paths([sp, sp2]) + self.assertEqual(getattr(zipped, "layer_height", None), 0.2) + self.assertEqual(getattr(zipped, "first_layer_height", None), 0.3) + self.assertEqual(getattr(zipped, "custom_meta", None), "test_value") + + extended = SlicePaths.extend_paths([sp, sp2]) + self.assertEqual(getattr(extended, "layer_height", None), 0.2) + self.assertEqual(getattr(extended, "first_layer_height", None), 0.3) + self.assertEqual(getattr(extended, "custom_meta", None), "test_value") + if __name__ == '__main__': unittest.main()