conserve attributes
This commit is contained in:
parent
62dd49c6d2
commit
2b98e9bc69
3 changed files with 250 additions and 94 deletions
|
|
@ -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)
|
||||
|
|
|
|||
206
core/slice.py
206
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue