conserve attributes

This commit is contained in:
Nicolas Predella 2026-07-20 17:08:22 +03:00
parent 62dd49c6d2
commit 2b98e9bc69
3 changed files with 250 additions and 94 deletions

View file

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

View file

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

View file

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