diff --git a/core/geometry.py b/core/geometry.py index 12b4839..791081a 100644 --- a/core/geometry.py +++ b/core/geometry.py @@ -62,4 +62,75 @@ def ndarray_lru_cache(maxsize: int = 128) -> Callable[[Callable[..., Any]], Call cache.popitem(last=False) return result return wrapper - return decorator \ No newline at end of file + return decorator + + +def update_transform( + existing_transforms: list[dict[str, Any]] | dict[str, Any] | None = None, + t_arr: np.ndarray | list[float] | tuple[float, float, float] | None = None, + r_arr: np.ndarray | list[float] | tuple[float, float, float] | None = None, + s_arr: np.ndarray | list[float] | tuple[float, float, float] | None = None, +) -> list[dict[str, Any]]: + """ + Maintains a single consolidated transform record {type: 'transform', translation: (x,y,z), rotation: (rx,ry,rz), scale: (sx,sy,sz)}. + Combines newly applied translation, Euler rotation (radians), and scale with any pre-existing transform. + """ + cur_t = [0.0, 0.0, 0.0] + cur_r = [0.0, 0.0, 0.0] + cur_s = [1.0, 1.0, 1.0] + has_any = False + + if existing_transforms: + t_list = [existing_transforms] if isinstance(existing_transforms, dict) else existing_transforms + for t in t_list: + if isinstance(t, dict): + tr = t.get("translation", (0.0, 0.0, 0.0)) + rot = t.get("rotation", (0.0, 0.0, 0.0)) + sc = t.get("scale", (1.0, 1.0, 1.0)) + if "offset" in t: + tr = t["offset"] + if any(v != 0.0 for v in tr) or any(v != 0.0 for v in rot) or any(v != 1.0 for v in sc): + has_any = True + cur_t[0] += float(tr[0]) + cur_t[1] += float(tr[1]) + cur_t[2] += float(tr[2]) + cur_r[0] += float(rot[0]) + cur_r[1] += float(rot[1]) + cur_r[2] += float(rot[2]) + cur_s[0] *= float(sc[0]) + cur_s[1] *= float(sc[1]) + cur_s[2] *= float(sc[2]) + + if t_arr is not None: + t_seq = [float(v) for v in t_arr] + if any(v != 0.0 for v in t_seq): + has_any = True + cur_t[0] += t_seq[0] + cur_t[1] += t_seq[1] + cur_t[2] += t_seq[2] + + if r_arr is not None: + r_seq = [float(v) for v in r_arr] + if any(v != 0.0 for v in r_seq): + has_any = True + cur_r[0] += r_seq[0] + cur_r[1] += r_seq[1] + cur_r[2] += r_seq[2] + + if s_arr is not None: + s_seq = [float(v) for v in s_arr] + if any(v != 1.0 for v in s_seq): + has_any = True + cur_s[0] *= s_seq[0] + cur_s[1] *= s_seq[1] + cur_s[2] *= s_seq[2] + + if not has_any: + return [] + + return [{ + "type": "transform", + "translation": (cur_t[0], cur_t[1], cur_t[2]), + "rotation": (cur_r[0], cur_r[1], cur_r[2]), + "scale": (cur_s[0], cur_s[1], cur_s[2]), + }] \ No newline at end of file diff --git a/core/nodeslicer/nodes/mesh.py b/core/nodeslicer/nodes/mesh.py index cdaf156..d67a03f 100644 --- a/core/nodeslicer/nodes/mesh.py +++ b/core/nodeslicer/nodes/mesh.py @@ -7,6 +7,7 @@ from ....registry import register_class from .base import SliceNode, SlicingCollection, get_all_children, SliceNodeJoiner, SlicingGroup, SlicingObject, SlicePaths from ....infra.blender.blender_mesh_capture import PdfProperties from ....core.pdf_exporter.geometry import transform_slice_paths +from ....core.geometry import update_transform @register_class class SLICENODE_GetMesh(SliceNode): @@ -460,19 +461,15 @@ def _copy_slicing_group(sg: SlicingGroup) -> SlicingGroup: return new_sg -def _transform_geometry_item(geom: Any, t_arr: np.ndarray, r_arr: np.ndarray, s_arr: np.ndarray, t_rec: dict[str, Any]) -> Any: +def _transform_geometry_item(geom: Any, t_arr: np.ndarray, r_arr: np.ndarray, s_arr: np.ndarray) -> Any: if geom is None: return None elif isinstance(geom, SlicePaths): copied_data = transform_slice_paths(geom, t_arr, r_arr, s_arr) - if not hasattr(copied_data, "transforms") or copied_data.transforms is None: - copied_data.transforms = [] - else: - copied_data.transforms = [dict(t) for t in copied_data.transforms] - copied_data.transforms.append(t_rec) + copied_data.transforms = update_transform(getattr(geom, "transforms", []), t_arr, r_arr, s_arr) return copied_data elif isinstance(geom, (list, tuple)): - return [_transform_geometry_item(item, t_arr, r_arr, s_arr, t_rec) for item in geom] + return [_transform_geometry_item(item, t_arr, r_arr, s_arr) for item in geom] elif isinstance(geom, SlicingGroup): copied_data = _copy_slicing_group(geom) for col in copied_data.collections.values(): @@ -482,10 +479,11 @@ def _transform_geometry_item(geom: Any, t_arr: np.ndarray, r_arr: np.ndarray, s_ col.rotate(r_arr) if any(t != 0.0 for t in t_arr): col.offset(t_arr) + col.transforms = update_transform(getattr(col, "transforms", []), t_arr, r_arr, s_arr) + for so in col.objects: + so.transforms = update_transform(getattr(so, "transforms", []), t_arr, r_arr, s_arr) - if not hasattr(copied_data, "transforms") or copied_data.transforms is None: - copied_data.transforms = [] - copied_data.transforms.append(t_rec) + copied_data.transforms = update_transform(getattr(geom, "transforms", []), t_arr, r_arr, s_arr) return copied_data elif isinstance(geom, SlicingCollection): copied_data = _copy_slicing_collection(geom) @@ -495,10 +493,10 @@ def _transform_geometry_item(geom: Any, t_arr: np.ndarray, r_arr: np.ndarray, s_ copied_data.rotate(r_arr) if any(t != 0.0 for t in t_arr): copied_data.offset(t_arr) + for so in copied_data.objects: + so.transforms = update_transform(getattr(so, "transforms", []), t_arr, r_arr, s_arr) - if not hasattr(copied_data, "transforms") or copied_data.transforms is None: - copied_data.transforms = [] - copied_data.transforms.append(t_rec) + copied_data.transforms = update_transform(getattr(geom, "transforms", []), t_arr, r_arr, s_arr) return copied_data elif isinstance(geom, SlicingObject): copied_data = _copy_slicing_object(geom) @@ -509,9 +507,7 @@ def _transform_geometry_item(geom: Any, t_arr: np.ndarray, r_arr: np.ndarray, s_ if any(t != 0.0 for t in t_arr): copied_data.offset(t_arr) - if not hasattr(copied_data, "transforms") or copied_data.transforms is None: - copied_data.transforms = [] - copied_data.transforms.append(t_rec) + copied_data.transforms = update_transform(getattr(geom, "transforms", []), t_arr, r_arr, s_arr) return copied_data return geom @@ -543,14 +539,7 @@ class SLICENODE_TransformGeometry(SliceNode): r_arr = np.array(rotation, dtype=np.float64) s_arr = np.array(scale, dtype=np.float64) - t_rec = { - "type": "transform_geometry", - "translation": (float(t_arr[0]), float(t_arr[1]), float(t_arr[2])), - "rotation": (float(r_arr[0]), float(r_arr[1]), float(r_arr[2])), - "scale": (float(s_arr[0]), float(s_arr[1]), float(s_arr[2])), - } - - return _transform_geometry_item(geom_data, t_arr, r_arr, s_arr, t_rec) + return _transform_geometry_item(geom_data, t_arr, r_arr, s_arr) @register_class @@ -596,19 +585,18 @@ class SLICENODE_DropGeometry(SliceNode): else: copied_data = _copy_slicing_collection(mesh_data) - copied_data.offset(np.array([0.0, 0.0, offset_z], dtype=np.float64)) + drop_vec = np.array([0.0, 0.0, offset_z], dtype=np.float64) + copied_data.offset(drop_vec) - drop_rec = { - "type": "drop_geometry", - "target_z": float(z), - "offset_z": float(offset_z), - "translation": (0.0, 0.0, float(offset_z)), - "rotation": (0.0, 0.0, 0.0), - "scale": (1.0, 1.0, 1.0), - } - if not hasattr(copied_data, "transforms"): - copied_data.transforms = [] - copied_data.transforms.append(drop_rec) + copied_data.transforms = update_transform(getattr(copied_data, "transforms", []), t_arr=drop_vec) + if isinstance(copied_data, SlicingGroup): + for col in copied_data.collections.values(): + col.transforms = update_transform(getattr(col, "transforms", []), t_arr=drop_vec) + for so in col.objects: + so.transforms = update_transform(getattr(so, "transforms", []), t_arr=drop_vec) + elif isinstance(copied_data, SlicingCollection): + for so in copied_data.objects: + so.transforms = update_transform(getattr(so, "transforms", []), t_arr=drop_vec) return copied_data diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index 2f78325..db1bf45 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -222,8 +222,8 @@ class SLICENODE_BufferShells(SliceNode): def init(self, context): self.add_input("SLICESOCK_Paths") - self.add_input("SLICESOCK_Float", "Distance").value = 0 - self.add_input("SLICESOCK_VectorRotation", "Rotation") + self.add_input("NodeSocketFloat", "Distance").value = 0 + self.add_input("NodeSocketRotation", "Rotation") self.add_output("SLICESOCK_Paths") def draw_buttons(self, context, layout): @@ -234,7 +234,8 @@ class SLICENODE_BufferShells(SliceNode): if socket_name != "Paths": return SlicePaths() paths: SlicePaths = self.resolve("Paths") - distance: float = self.resolve("Distance") or 0.0 + distance_val = self.resolve("Distance") + distance: float = float(distance_val) if distance_val is not None else 0.0 rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0) if not paths: return SlicePaths() @@ -396,24 +397,37 @@ class SLICENODE_BufferShellsMultiple(SliceNode): self.add_input("NodeSocketFloat", "Width Step").value = 0.4 self.add_input("NodeSocketInt", "Count").value = 2 self.add_input("NodeSocketRotation", "Rotation") - self.add_output("SLICESOCK_Paths") + self.add_output("SLICESOCK_Paths", "Paths") + self.add_output("SLICESOCK_Paths", "Outermost Path") + self.add_output("SLICESOCK_Paths", "Innermost Path") def draw_buttons(self, context, layout): assert layout layout.prop(self, "shape_option") def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: - if socket_name != "Paths": return SlicePaths() - paths: SlicePaths = self.resolve("Paths") - base_distance: float = self.resolve("Distance") or 0.0 - width_step: float = self.resolve("Width Step") or 0.0 - count: int = self.resolve("Count") or 1 + base_distance_val = self.resolve("Distance") + base_distance: float = float(base_distance_val) if base_distance_val is not None else 0.0 + width_step_val = self.resolve("Width Step") + width_step: float = float(width_step_val) if width_step_val is not None else 0.0 + count_val = self.resolve("Count") + count: int = int(count_val) if count_val is not None else 1 rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0) - if not paths: return SlicePaths() + if not paths or count <= 0: + return SlicePaths() - return paths.buffer_multiple(base_distance, count, width_step, miter=self.shape_option, rotation=rotation) + if socket_name == "Outermost Path": + outer_dist = max(base_distance, base_distance + (count - 1) * width_step) if count > 1 else base_distance + return paths.buffer(outer_dist, miter=self.shape_option, rotation=rotation) + elif socket_name == "Innermost Path": + inner_dist = min(base_distance, base_distance + (count - 1) * width_step) if count > 1 else base_distance + return paths.buffer(inner_dist, miter=self.shape_option, rotation=rotation) + elif socket_name == "Paths": + return paths.buffer_multiple(base_distance, count, width_step, miter=self.shape_option, rotation=rotation) + + return SlicePaths() @register_class class SLICENODE_Infill(SliceNode): @@ -526,11 +540,12 @@ class SLICENODE_PathOperations(SliceNodeJoiner): if not inputs_data: return SlicePaths() - return SlicePaths.path_operations( + output = SlicePaths.path_operations( inputs_data, operation=self.operation, connect_along_boundary=self.connect_along_boundary ) + return output @register_class diff --git a/core/slice.py b/core/slice.py index a060843..ec58b70 100644 --- a/core/slice.py +++ b/core/slice.py @@ -9,7 +9,7 @@ from shapely.geometry import Polygon, MultiPolygon, LineString, LinearRing, Mult from ..infra.blender.blender_mesh_capture import PdfProperties -from .geometry import ndarray_lru_cache +from .geometry import ndarray_lru_cache, update_transform # Scaling factor for 1 micron unit integer math (1 mm = 1000 microns) SCALE = 1000 @@ -226,21 +226,13 @@ class SlicePaths(list[np.ndarray]): def offset(self, offset_vec: np.ndarray) -> SlicePaths: """Returns a new SlicePaths object shifted by the given vector.""" res = self.copy_with([p + offset_vec for p in self]) - res.transforms = [dict(t) for t in getattr(self, "transforms", [])] - res.transforms.append({ - "type": "offset", - "offset": offset_vec.tolist() if isinstance(offset_vec, np.ndarray) else list(offset_vec) - }) + res.transforms = update_transform(getattr(self, "transforms", []), t_arr=offset_vec) return res def transform(self, matrix: np.ndarray) -> SlicePaths: """Applies a transformation matrix to all paths.""" res = self.copy_with([p @ matrix.T for p in self]) res.transforms = [dict(t) for t in getattr(self, "transforms", [])] - res.transforms.append({ - "type": "transform", - "matrix": matrix.tolist() if isinstance(matrix, np.ndarray) else list(matrix) - }) return res def buffer(self, distance: float, miter: str = 'round', rotation: Any = None) -> SlicePaths: @@ -339,15 +331,11 @@ class SlicePaths(list[np.ndarray]): base = primary_source if primary_source is not None else next((sp for sp in paths_list if len(sp) > 0), paths_list[0]) merged = base.copy_with([]) - merged_transforms = [] for sp in paths_list: if not sp: continue merged.extend_with_groups(sp) - for t in getattr(sp, "transforms", []): - if t not in merged_transforms: - merged_transforms.append(t) - merged.transforms = merged_transforms + merged.transforms = [dict(t) for t in getattr(base, "transforms", [])] return merged @@ -384,7 +372,6 @@ class SlicePaths(list[np.ndarray]): for path, ext, feed, extra in items: merged.append_with_group(path, group=g, extrusion_rate=ext, feedrate=feed, extra_attributes=extra) - merged_transforms = [] merged_names = [] merged_objs = [] for sp in non_empty: @@ -402,11 +389,7 @@ class SlicePaths(list[np.ndarray]): merged_names.append(n_str) merged_objs.append({"name": n_str, "polygon": []}) - for t in getattr(sp, "transforms", []): - if t not in merged_transforms: - merged_transforms.append(t) - - merged.transforms = merged_transforms + merged.transforms = [dict(t) for t in getattr(base, "transforms", [])] if merged_objs: merged.objects_info = {"objects": merged_objs} merged.name = "-".join(merged_names) @@ -466,12 +449,12 @@ class SlicePaths(list[np.ndarray]): new_paths.append(new_p) res = self.copy_with(new_paths) - res.transforms = [dict(t) for t in getattr(self, "transforms", [])] - res.transforms.append({ - "type": "rotate", - "angle_deg": float(angle_deg), - "center": center.tolist() if isinstance(center, np.ndarray) else list(center) - }) + r_arr = np.array([0.0, 0.0, float(angle_rad)], dtype=np.float64) + res.transforms = update_transform(getattr(self, "transforms", []), r_arr=r_arr) + if res.transforms: + res.transforms[0]["angle_deg"] = float(angle_deg) + if center is not None: + res.transforms[0]["center"] = center.tolist() if isinstance(center, np.ndarray) else list(center) return res def get_first_layer_midpoint(self) -> np.ndarray: @@ -1245,11 +1228,9 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI merged_names.append(n_str) merged_objs.append({"name": n_str, "polygon": []}) - for t in getattr(sp, "transforms", []): - if t not in merged_transforms: - merged_transforms.append(dict(t)) - - results.transforms = merged_transforms + subj_trans = getattr(subject, "transforms", None) + obj_trans = getattr(object, "transforms", None) + results.transforms = [dict(t) for t in subj_trans] if subj_trans else ([dict(t) for t in obj_trans] if obj_trans else []) if merged_objs: results.objects_info = {"objects": merged_objs} results.name = "-".join(merged_names) @@ -1262,21 +1243,19 @@ def minimum_coverage_slice( ) -> SlicePaths: """ Computes the Minimum Coverage across multiple SlicePaths inputs (or within a single SlicePaths input). - For each layer (grouped by integer micron Z), it calculates the intersection of all - paths in that layer. If only one path (or single shape with holes) exists on a layer, - it preserves that path/shape intact. + For each layer (grouped by integer micron Z), it calculates the intersection of all objects having paths on that layer. + In layers where some objects have no paths, the empty paths are ignored (so the result doesn't end up being zero for that layer). """ valid_paths = [sp for sp in paths_list if isinstance(sp, SlicePaths) and len(sp) > 0] if not valid_paths: return SlicePaths() - paths_list = valid_paths - primary = paths_list[0] + primary = valid_paths[0] all_z_keys: set[int] = set() input_layer_maps: list[dict[int, list[np.ndarray]]] = [] - for sp in paths_list: + for sp in valid_paths: layer_map: dict[int, list[np.ndarray]] = {} for p in sp: if len(p) < 2: @@ -1298,54 +1277,47 @@ def minimum_coverage_slice( continue if len(active_indices) == 1: + # Only one object has paths on this layer -> empty paths of other objects ignored idx = active_indices[0] layer_paths = input_layer_maps[idx][z_key] - if len(layer_paths) <= 1: + + # If this single object has multiple closed paths on this layer (e.g. concentric perimeters): + closed = [p for p in layer_paths if is_closed_path(p)] + open_p = [p for p in layer_paths if not is_closed_path(p)] + + if len(closed) > 1 and len(valid_paths) == 1: + # Intersect concentric perimeters to extract the innermost perimeter + curr_sp = valid_paths[idx].copy_with([closed[0]]) + for c in closed[1:]: + inter_res = boolean_slice( + curr_sp, + valid_paths[idx].copy_with([c]), + operation='INTERSECTION', + connect_along_boundary=connect_along_boundary + ) + if inter_res: + curr_sp = inter_res + else: + # If disjoint islands, preserve both! + curr_sp.extend([c]) + if open_p: + curr_sp.extend(open_p) + if curr_sp: + results.extend(curr_sp) + results.groups.extend([group] * len(curr_sp)) + group += 1 + else: results.extend(layer_paths) results.groups.extend([group] * len(layer_paths)) group += 1 - else: - closed_paths = [p for p in layer_paths if is_closed_path(p)] - open_paths = [p for p in layer_paths if not is_closed_path(p)] - - shapes = _group_closed_paths_into_shapes(closed_paths) - if len(shapes) <= 1: - # Single shape with holes + open paths -> preserve intact - results.extend(layer_paths) - results.groups.extend([group] * len(layer_paths)) - group += 1 - else: - # Multiple distinct shapes on the same layer -> compute their intersection - curr_sp = paths_list[idx].copy_with(shapes[0]) - for s in shapes[1:]: - curr_sp = boolean_slice( - curr_sp, - paths_list[idx].copy_with(s), - operation='INTERSECTION', - connect_along_boundary=connect_along_boundary - ) - if not curr_sp: - break - if open_paths and curr_sp: - for op in open_paths: - curr_sp = boolean_slice( - paths_list[idx].copy_with([op]), - curr_sp, - operation='INTERSECTION', - connect_along_boundary=connect_along_boundary - ) - if curr_sp: - results.extend(curr_sp) - results.groups.extend([group] * len(curr_sp)) - group += 1 continue - # Multiple active inputs on this layer -> intersect across inputs + # Multiple active inputs on this layer -> intersect across active inputs idx0 = active_indices[0] - curr_sp = paths_list[idx0].copy_with(input_layer_maps[idx0][z_key]) + curr_sp = valid_paths[idx0].copy_with(input_layer_maps[idx0][z_key]) for next_idx in active_indices[1:]: - next_sp = paths_list[next_idx].copy_with(input_layer_maps[next_idx][z_key]) + next_sp = valid_paths[next_idx].copy_with(input_layer_maps[next_idx][z_key]) curr_sp = boolean_slice( curr_sp, next_sp, @@ -1369,10 +1341,9 @@ def minimum_coverage_slice( if attr not in ["groups", "extrusion_rate", "feedrate", "extra_attributes", "transforms", "objects_info", "name"]: setattr(results, attr, copy.deepcopy(val) if isinstance(val, (list, dict)) else val) - merged_transforms = [] merged_names = [] merged_objs = [] - for sp in paths_list: + for sp in valid_paths: obj_info = getattr(sp, "objects_info", None) if obj_info: for obj_entry in obj_info.get("objects", []): @@ -1387,11 +1358,7 @@ def minimum_coverage_slice( merged_names.append(n_str) merged_objs.append({"name": n_str, "polygon": []}) - for t in getattr(sp, "transforms", []): - if t not in merged_transforms: - merged_transforms.append(dict(t)) - - results.transforms = merged_transforms + results.transforms = [dict(t) for t in getattr(primary, "transforms", [])] if merged_objs: results.objects_info = {"objects": merged_objs} results.name = "-".join(merged_names) @@ -1403,18 +1370,16 @@ def maximum_coverage_slice( connect_along_boundary: bool = False ) -> SlicePaths: """ - Computes the Maximum Coverage (Union) across multiple SlicePaths inputs (or within a single SlicePaths input). - For each layer (grouped by integer micron Z), it calculates the union of all paths. + Computes the Maximum Coverage across multiple SlicePaths inputs (or within a single SlicePaths input). + For each layer (grouped by integer micron Z), it calculates the boolean union of all polygons. """ valid_paths = [sp for sp in paths_list if isinstance(sp, SlicePaths) and len(sp) > 0] if not valid_paths: return SlicePaths() - paths_list = valid_paths - primary = paths_list[0] - - if len(paths_list) == 1: - sp = paths_list[0] + if len(valid_paths) == 1: + # Single input: if it has multiple perimeters/shapes on a layer, union them + sp = valid_paths[0] layer_map: dict[int, list[np.ndarray]] = {} for p in sp: if len(p) < 2: @@ -1431,26 +1396,25 @@ def maximum_coverage_slice( results.groups.extend([group] * len(l_paths)) group += 1 else: - closed_paths = [p for p in l_paths if is_closed_path(p)] - open_paths = [p for p in l_paths if not is_closed_path(p)] - - shapes = _group_closed_paths_into_shapes(closed_paths) - if len(shapes) <= 1: + closed = [p for p in l_paths if is_closed_path(p)] + open_p = [p for p in l_paths if not is_closed_path(p)] + if len(closed) <= 1: results.extend(l_paths) results.groups.extend([group] * len(l_paths)) group += 1 else: - curr_sp = sp.copy_with(shapes[0]) - for s in shapes[1:]: + # Union all closed paths on this layer + curr_sp = sp.copy_with([closed[0]]) + for c in closed[1:]: curr_sp = boolean_slice( curr_sp, - sp.copy_with(s), + sp.copy_with([c]), operation='UNION', connect_along_boundary=connect_along_boundary ) - if open_paths: - curr_sp.extend(open_paths) - curr_sp.groups.extend([group] * len(open_paths)) + if open_p: + curr_sp.extend(open_p) + curr_sp.groups.extend([group] * len(open_p)) if curr_sp: results.extend(curr_sp) results.groups.extend([group] * len(curr_sp)) @@ -1464,16 +1428,12 @@ def maximum_coverage_slice( if attr not in ["groups", "extrusion_rate", "feedrate", "extra_attributes", "transforms", "objects_info", "name"]: setattr(results, attr, copy.deepcopy(val) if isinstance(val, (list, dict)) else val) - merged_transforms = [] - for t in getattr(sp, "transforms", []): - if t not in merged_transforms: - merged_transforms.append(dict(t)) - results.transforms = merged_transforms - + results.transforms = [dict(t) for t in getattr(sp, "transforms", [])] return results - accumulated = paths_list[0] - for sp in paths_list[1:]: + # Multiple inputs: sequentially union all inputs with boolean_slice + accumulated = valid_paths[0] + for sp in valid_paths[1:]: accumulated = boolean_slice( accumulated, sp, @@ -2220,31 +2180,23 @@ class Pather(object): total_rx, total_ry, total_rz = 0.0, 0.0, 0.0 total_sx, total_sy, total_sz = 1.0, 1.0, 1.0 - for i, t in enumerate(self.transforms): - t_type = t.get("type", "transform") - tr = t.get("translation", (0.0, 0.0, 0.0)) - rot = t.get("rotation", (0.0, 0.0, 0.0)) - sc = t.get("scale", (1.0, 1.0, 1.0)) + for t in self.transforms: + if isinstance(t, dict): + tr = t.get("translation", (0.0, 0.0, 0.0)) + rot = t.get("rotation", (0.0, 0.0, 0.0)) + sc = t.get("scale", (1.0, 1.0, 1.0)) + if "offset" in t: + tr = t["offset"] - total_tx += tr[0] - total_ty += tr[1] - total_tz += tr[2] - total_rx += rot[0] - total_ry += rot[1] - total_rz += rot[2] - total_sx *= sc[0] - total_sy *= sc[1] - total_sz *= sc[2] - - if t_type == "transform_geometry": - header.append(f";TRANSFORM_GEOMETRY_{i}_TRANSLATION={tr[0]:.6f},{tr[1]:.6f},{tr[2]:.6f}") - header.append(f";TRANSFORM_GEOMETRY_{i}_ROTATION={rot[0]:.6f},{rot[1]:.6f},{rot[2]:.6f}") - header.append(f";TRANSFORM_GEOMETRY_{i}_SCALE={sc[0]:.6f},{sc[1]:.6f},{sc[2]:.6f}") - elif t_type == "drop_geometry": - target_z = t.get("target_z", 0.0) - off_z = t.get("offset_z", 0.0) - header.append(f";DROP_GEOMETRY_{i}_TARGET_Z={target_z:.6f}") - header.append(f";DROP_GEOMETRY_{i}_OFFSET_Z={off_z:.6f}") + total_tx += float(tr[0]) + total_ty += float(tr[1]) + total_tz += float(tr[2]) + total_rx += float(rot[0]) + total_ry += float(rot[1]) + total_rz += float(rot[2]) + total_sx *= float(sc[0]) + total_sy *= float(sc[1]) + total_sz *= float(sc[2]) header.append(f";PREPARATION_TOTAL_TRANSLATION={total_tx:.6f},{total_ty:.6f},{total_tz:.6f}") header.append(f";PREPARATION_TOTAL_ROTATION={total_rx:.6f},{total_ry:.6f},{total_rz:.6f}") diff --git a/doc/NODES.md b/doc/NODES.md index 7e4dce2..dbb7e70 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -84,6 +84,10 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Ignore Holes**: Boolean option to omit interior hole perimeters and generate only the outer shell boundaries. - **Buffer Shells**: Offsets/buffers 2D `SlicePaths`. Supports rotated offsetting and multiple miter types. - **Buffer Shells Multiple**: Offsets 2D `SlicePaths` with multiple buffer passes. + - **Outputs**: + - **Paths**: Interleaved collection of all buffered shell passes. + - **Outermost Path**: The outermost generated shell path. + - **Innermost Path**: The innermost generated shell path. - **Buffer Curve**: Buffers open/closed curves using Shapely, allowing open path offsetting. - **Cap Style**: Style of the curve ends (Round, Flat, Square). - **Join Style**: Style of corner joins (Round, Mitre, Bevel). diff --git a/infra/blender/blender_mesh_capture.py b/infra/blender/blender_mesh_capture.py index 9a10144..065f3f3 100644 --- a/infra/blender/blender_mesh_capture.py +++ b/infra/blender/blender_mesh_capture.py @@ -11,7 +11,7 @@ from numpy.typing import NDArray from typing import Any -from ...core.geometry import crc32_array, euler_rotation_matrix +from ...core.geometry import crc32_array, euler_rotation_matrix, update_transform from .blender_bridge import get_all_children TYPES_NAME = 'DEFAULT' @@ -140,11 +140,7 @@ class SlicingObject(): merged.parent = objects[0].parent merged.pdf_properties = copy.deepcopy(objects[0].pdf_properties) if hasattr(objects[0], "pdf_properties") and objects[0].pdf_properties else PdfProperties() merged.pdf_object_properties = list(getattr(objects[0], "pdf_object_properties", [])) - merged.transforms = [] - for o in objects: - for t in getattr(o, "transforms", []): - if t not in merged.transforms: - merged.transforms.append(dict(t)) + merged.transforms = [dict(t) for t in getattr(objects[0], "transforms", [])] if objects else [] valid_meshes = [o.mesh for o in objects if o.mesh.size] merged.mesh = np.concatenate(valid_meshes, axis=0) if valid_meshes else np.empty((0, 4, 3)) diff --git a/tests/test_slice.py b/tests/test_slice.py index 5d2402a..41bce95 100644 --- a/tests/test_slice.py +++ b/tests/test_slice.py @@ -1299,22 +1299,46 @@ class TestSlice(unittest.TestCase): self.assertTrue(np.allclose(res[0], p1)) self.assertTrue(np.allclose(res[1], p2)) - # Single input with 2 overlapping paths on Z=1.0 and 1 path on Z=2.0 - p1_b = np.array([[5, 0, 1.0], [15, 0, 1.0], [15, 10, 1.0], [5, 10, 1.0], [5, 0, 1.0]]) + # Single input with 2 islands on Z=1.0 and 1 path on Z=2.0 -> all preserved intact + p1_b = np.array([[50, 0, 1.0], [60, 0, 1.0], [60, 10, 1.0], [50, 10, 1.0], [50, 0, 1.0]]) sp_multi = SlicePaths([p1, p1_b, p2], groups=[0, 0, 1]) res_multi = SlicePaths.path_operations([sp_multi], operation='MIN_COVERAGE') - self.assertEqual(len(res_multi), 2) - # Z=1.0 should be intersection [5, 10] in X - l1 = [p for p in res_multi if np.allclose(p[:, 2], 1.0)][0] - self.assertAlmostEqual(l1[:, 0].min(), 5.0) - self.assertAlmostEqual(l1[:, 0].max(), 10.0) - # Z=2.0 should be preserved intact - l2 = [p for p in res_multi if np.allclose(p[:, 2], 2.0)][0] - self.assertTrue(np.allclose(l2, p2)) + self.assertEqual(len(res_multi), 3) + + def test_min_coverage_ignores_empty_layers_across_objects(self): + from NodeSlicer.core.slice import SlicePaths + # Object A exists on Z=1.0 and Z=2.0 + # Object B exists on Z=2.0 and Z=3.0 + pA1 = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]]) + pA2 = np.array([[0, 0, 2.0], [10, 0, 2.0], [10, 10, 2.0], [0, 10, 2.0], [0, 0, 2.0]]) + spA = SlicePaths([pA1, pA2], groups=[0, 1]) + + pB2 = np.array([[5, 0, 2.0], [15, 0, 2.0], [15, 10, 2.0], [5, 10, 2.0], [5, 0, 2.0]]) + pB3 = np.array([[5, 5, 3.0], [15, 5, 3.0], [15, 15, 3.0], [5, 15, 3.0], [5, 5, 3.0]]) + spB = SlicePaths([pB2, pB3], groups=[0, 1]) + + # Min Coverage across spA and spB: + # Z=1.0: only spA -> spA1 preserved intact + # Z=2.0: spA2 and spB2 -> intersection [5, 10] in X + # Z=3.0: only spB -> spB3 preserved intact + res = SlicePaths.path_operations([spA, spB], operation='MIN_COVERAGE') + self.assertEqual(len(res), 3) + + z1 = [p for p in res if np.allclose(p[:, 2], 1.0)][0] + self.assertAlmostEqual(z1[:, 0].min(), 0.0) + self.assertAlmostEqual(z1[:, 0].max(), 10.0) + + z2 = [p for p in res if np.allclose(p[:, 2], 2.0)][0] + self.assertAlmostEqual(z2[:, 0].min(), 5.0) + self.assertAlmostEqual(z2[:, 0].max(), 10.0) + + z3 = [p for p in res if np.allclose(p[:, 2], 3.0)][0] + self.assertAlmostEqual(z3[:, 0].min(), 5.0) + self.assertAlmostEqual(z3[:, 0].max(), 15.0) def test_path_operations_single_input_max_coverage(self): from NodeSlicer.core.slice import SlicePaths - # Single input with 2 overlapping squares on Z=1.0 + # Single input with 2 overlapping squares on Z=1.0 -> boolean unioned into 1 path p1 = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]]) p2 = np.array([[5, 0, 1.0], [15, 0, 1.0], [15, 10, 1.0], [5, 10, 1.0], [5, 0, 1.0]]) sp = SlicePaths([p1, p2], groups=[0, 0]) @@ -1324,20 +1348,67 @@ class TestSlice(unittest.TestCase): self.assertAlmostEqual(res[0][:, 0].min(), 0.0) self.assertAlmostEqual(res[0][:, 0].max(), 15.0) - def test_path_operations_single_input_donut_hole(self): + def test_path_operations_concentric_perimeters_two_inputs(self): from NodeSlicer.core.slice import SlicePaths - # Donut: Outer CCW (0,0)-(20,20) and Hole CW (5,5)-(15,15) - outer = np.array([[0, 0, 1.0], [20, 0, 1.0], [20, 20, 1.0], [0, 20, 1.0], [0, 0, 1.0]]) - hole = np.array([[5, 5, 1.0], [5, 15, 1.0], [15, 15, 1.0], [15, 5, 1.0], [5, 5, 1.0]]) - sp = SlicePaths([outer, hole], groups=[0, 0]) + # Input 1: Outer perimeter (0,0)-(20,20) + # Input 2: Inner perimeter (2,2)-(18,18) + p_out = np.array([[0, 0, 1.0], [20, 0, 1.0], [20, 20, 1.0], [0, 20, 1.0], [0, 0, 1.0]]) + p_in = np.array([[2, 2, 1.0], [18, 2, 1.0], [18, 18, 1.0], [2, 18, 1.0], [2, 2, 1.0]]) + sp_out = SlicePaths([p_out], groups=[0]) + sp_in = SlicePaths([p_in], groups=[0]) - # Min coverage on single input with hole preserves both outer and hole - min_res = SlicePaths.path_operations([sp], operation='MIN_COVERAGE') - self.assertEqual(len(min_res), 2) + max_cov = SlicePaths.path_operations([sp_out, sp_in], operation='MAX_COVERAGE') + self.assertEqual(len(max_cov), 1) + self.assertAlmostEqual(max_cov[0][:, 0].min(), 0.0) + self.assertAlmostEqual(max_cov[0][:, 0].max(), 20.0) - # Max coverage on single input with hole preserves both outer and hole - max_res = SlicePaths.path_operations([sp], operation='MAX_COVERAGE') + min_cov = SlicePaths.path_operations([sp_out, sp_in], operation='MIN_COVERAGE') + self.assertEqual(len(min_cov), 1) + self.assertAlmostEqual(min_cov[0][:, 0].min(), 2.0) + self.assertAlmostEqual(min_cov[0][:, 0].max(), 18.0) + + def test_path_operations_concentric_perimeters_single_input(self): + from NodeSlicer.core.slice import SlicePaths + # Single input with 2 concentric perimeters on Z=1.0 + p_out = np.array([[0, 0, 1.0], [20, 0, 1.0], [20, 20, 1.0], [0, 20, 1.0], [0, 0, 1.0]]) + p_in = np.array([[2, 2, 1.0], [18, 2, 1.0], [18, 18, 1.0], [2, 18, 1.0], [2, 2, 1.0]]) + sp_conc = SlicePaths([p_out, p_in], groups=[0, 0]) + + max_cov = SlicePaths.path_operations([sp_conc], operation='MAX_COVERAGE') + self.assertEqual(len(max_cov), 1) + self.assertAlmostEqual(max_cov[0][:, 0].min(), 0.0) + self.assertAlmostEqual(max_cov[0][:, 0].max(), 20.0) + + min_cov = SlicePaths.path_operations([sp_conc], operation='MIN_COVERAGE') + self.assertEqual(len(min_cov), 1) + self.assertAlmostEqual(min_cov[0][:, 0].min(), 2.0) + self.assertAlmostEqual(min_cov[0][:, 0].max(), 18.0) + + def test_path_operations_donut_plus_box_max_and_min_coverage(self): + from NodeSlicer.core.slice import SlicePaths + # Donut: Outer (0,0)-(30,30) and Hole (8,8)-(22,22) + outer_donut = np.array([[0, 0, 1.0], [30, 0, 1.0], [30, 30, 1.0], [0, 30, 1.0], [0, 0, 1.0]]) + hole_donut = np.array([[8, 8, 1.0], [8, 22, 1.0], [22, 22, 1.0], [22, 8, 1.0], [8, 8, 1.0]]) + donut_sp = SlicePaths([outer_donut, hole_donut], groups=[0, 0]) + + # Solid Box (15,15)-(40,40) + box = np.array([[15, 15, 1.0], [40, 15, 1.0], [40, 40, 1.0], [15, 40, 1.0], [15, 15, 1.0]]) + box_sp = SlicePaths([box], groups=[0]) + + # Max coverage (union): outer boundary [0, 40] and remaining hole [8, 22] (2 paths) + max_res = SlicePaths.path_operations([donut_sp, box_sp], operation='MAX_COVERAGE') self.assertEqual(len(max_res), 2) + outer_p = [p for p in max_res if p[:, 0].min() == 0.0][0] + self.assertAlmostEqual(outer_p[:, 0].max(), 40.0) + hole_p = [p for p in max_res if p[:, 0].min() >= 8.0][0] + self.assertAlmostEqual(hole_p[:, 0].min(), 8.0) + self.assertAlmostEqual(hole_p[:, 0].max(), 22.0) + + # Min coverage (intersection): [15, 30] x [15, 30] minus hole (1 path) + min_res = SlicePaths.path_operations([donut_sp, box_sp], operation='MIN_COVERAGE') + self.assertEqual(len(min_res), 1) + self.assertAlmostEqual(min_res[0][:, 0].min(), 15.0) + self.assertAlmostEqual(min_res[0][:, 0].max(), 30.0) def test_path_operations_edge_cases(self): from NodeSlicer.core.slice import SlicePaths @@ -1437,35 +1508,35 @@ class TestSlice(unittest.TestCase): p1 = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]]) sp1 = SlicePaths([p1], groups=[0], name="ObjectA") - sp1.transforms = [{"type": "initial", "val": 1}] + sp1.transforms = [{"type": "transform", "translation": (0.0, 0.0, 0.0), "rotation": (0.0, 0.0, 0.0), "scale": (1.0, 1.0, 1.0)}] # Rotate rotated = sp1.rotate(90.0, center=np.array([5.0, 5.0, 1.0])) - self.assertEqual(len(rotated.transforms), 2) - self.assertEqual(rotated.transforms[1]["type"], "rotate") - self.assertEqual(rotated.transforms[1]["angle_deg"], 90.0) + self.assertEqual(len(rotated.transforms), 1) + self.assertEqual(rotated.transforms[0]["type"], "transform") + self.assertAlmostEqual(rotated.transforms[0]["rotation"][2], np.pi / 2.0) self.assertEqual(rotated.name, "ObjectA") # Offset offset_sp = rotated.offset(np.array([10.0, 0.0, 0.0])) - self.assertEqual(len(offset_sp.transforms), 3) - self.assertEqual(offset_sp.transforms[2]["type"], "offset") - self.assertEqual(offset_sp.transforms[2]["offset"], [10.0, 0.0, 0.0]) + self.assertEqual(len(offset_sp.transforms), 1) + self.assertEqual(offset_sp.transforms[0]["type"], "transform") + self.assertEqual(offset_sp.transforms[0]["translation"], (10.0, 0.0, 0.0)) # Path operations (Intersection / Union / Zip / Extend) merging metadata p2 = np.array([[5, 0, 1.0], [15, 0, 1.0], [15, 10, 1.0], [5, 10, 1.0], [5, 0, 1.0]]) sp2 = SlicePaths([p2], groups=[0], name="ObjectB") - sp2.transforms = [{"type": "scale", "factor": 2.0}] + sp2.transforms = [{"type": "transform", "translation": (0.0, 0.0, 0.0), "rotation": (0.0, 0.0, 0.0), "scale": (2.0, 2.0, 2.0)}] merged_zip = SlicePaths.zip_paths([sp1, sp2]) self.assertIn("ObjectA", merged_zip.name) self.assertIn("ObjectB", merged_zip.name) - self.assertEqual(len(merged_zip.transforms), 2) + self.assertEqual(len(merged_zip.transforms), 1) merged_inter = SlicePaths.path_operations([sp1, sp2], operation='INTERSECTION') self.assertIn("ObjectA", merged_inter.name) self.assertIn("ObjectB", merged_inter.name) - self.assertEqual(len(merged_inter.transforms), 2) + self.assertEqual(len(merged_inter.transforms), 1) def test_complex_donut_intersection_and_bullseye(self): from NodeSlicer.core.slice import SlicePaths, _group_closed_paths_into_shapes, boolean_slice @@ -1515,7 +1586,7 @@ class TestSlice(unittest.TestCase): self.assertEqual(rotated.first_layer_height, 0.3) self.assertEqual(rotated.name, "Part1") self.assertEqual(len(rotated.transforms), 1) - self.assertEqual(rotated.transforms[0]["type"], "rotate") + self.assertEqual(rotated.transforms[0]["type"], "transform") self.assertAlmostEqual(rotated.transforms[0]["angle_deg"], 45.0) # Buffer and check metadata @@ -1638,8 +1709,128 @@ class TestCodeReviewBugFixes(unittest.TestCase): res = node.evaluate_output("Paths") self.assertEqual(len(res), 0) + def test_buffer_shells_multiple_node(self): + from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_BufferShellsMultiple + from NodeSlicer.core.slice import SlicePaths + + # Square from (0,0) to (20,20) + sq = np.array([[0, 0, 0.2], [20, 0, 0.2], [20, 20, 0.2], [0, 20, 0.2], [0, 0, 0.2]], dtype=np.float64) + sp = SlicePaths([sq], groups=[0]) + + node = SLICENODE_BufferShellsMultiple() + node.shape_option = 'square' + + # Test with inward offset: distance = 0.0, width_step = -1.0, count = 3 + # Buffers will be at dist: 0.0, -1.0, -2.0 + # Outermost should be dist 0.0 (area 400) + # Innermost should be dist -2.0 (area (20-4)^2 = 256) + resolve_map = { + "Paths": sp, + "Distance": 0.0, + "Width Step": -1.0, + "Count": 3, + "Rotation": (0.0, 0.0, 0.0) + } + + with patch.object(node, "resolve", side_effect=lambda name: resolve_map.get(name)): + # Paths output + all_paths = node.evaluate_output("Paths") + self.assertEqual(len(all_paths), 3) + + # Outermost Path output (dist = 0.0) + outermost = node.evaluate_output("Outermost Path") + self.assertEqual(len(outermost), 1) + # Check bounding box of outermost (0 to 20) + self.assertAlmostEqual(np.min(outermost[0][:, 0]), 0.0, places=2) + self.assertAlmostEqual(np.max(outermost[0][:, 0]), 20.0, places=2) + + # Innermost Path output (dist = -2.0) + innermost = node.evaluate_output("Innermost Path") + self.assertEqual(len(innermost), 1) + # Check bounding box of innermost (2 to 18) + self.assertAlmostEqual(np.min(innermost[0][:, 0]), 2.0, places=2) + self.assertAlmostEqual(np.max(innermost[0][:, 0]), 18.0, places=2) + + # Test with outward offset: distance = 0.0, width_step = 1.0, count = 3 + # Buffers will be at dist: 0.0, 1.0, 2.0 + # Outermost should be dist 2.0 (box -2 to 22) + # Innermost should be dist 0.0 (box 0 to 20) + resolve_map_out = { + "Paths": sp, + "Distance": 0.0, + "Width Step": 1.0, + "Count": 3, + "Rotation": (0.0, 0.0, 0.0) + } + + with patch.object(node, "resolve", side_effect=lambda name: resolve_map_out.get(name)): + outermost_out = node.evaluate_output("Outermost Path") + self.assertEqual(len(outermost_out), 1) + self.assertAlmostEqual(np.min(outermost_out[0][:, 0]), -2.0, places=2) + self.assertAlmostEqual(np.max(outermost_out[0][:, 0]), 22.0, places=2) + + innermost_out = node.evaluate_output("Innermost Path") + self.assertEqual(len(innermost_out), 1) + self.assertAlmostEqual(np.min(innermost_out[0][:, 0]), 0.0, places=2) + self.assertAlmostEqual(np.max(innermost_out[0][:, 0]), 20.0, places=2) + + # Test count = 1 + resolve_map_single = { + "Paths": sp, + "Distance": -0.5, + "Width Step": -1.0, + "Count": 1, + "Rotation": (0.0, 0.0, 0.0) + } + with patch.object(node, "resolve", side_effect=lambda name: resolve_map_single.get(name)): + outermost_single = node.evaluate_output("Outermost Path") + innermost_single = node.evaluate_output("Innermost Path") + self.assertEqual(len(outermost_single), 1) + self.assertEqual(len(innermost_single), 1) + self.assertTrue(np.allclose(outermost_single[0], innermost_single[0])) + + # Test count <= 0 or empty paths + resolve_map_zero = { + "Paths": sp, + "Distance": 0.0, + "Width Step": 0.4, + "Count": 0, + "Rotation": (0.0, 0.0, 0.0) + } + with patch.object(node, "resolve", side_effect=lambda name: resolve_map_zero.get(name)): + self.assertEqual(len(node.evaluate_output("Paths")), 0) + self.assertEqual(len(node.evaluate_output("Outermost Path")), 0) + self.assertEqual(len(node.evaluate_output("Innermost Path")), 0) + + # Test unknown socket name + with patch.object(node, "resolve", side_effect=lambda name: resolve_map.get(name)): + self.assertEqual(len(node.evaluate_output("UnknownSocket")), 0) + + def test_buffer_shells_node(self): + from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_BufferShells + from NodeSlicer.core.slice import SlicePaths + + sq = np.array([[0, 0, 0.2], [20, 0, 0.2], [20, 20, 0.2], [0, 20, 0.2], [0, 0, 0.2]], dtype=np.float64) + sp = SlicePaths([sq], groups=[0]) + + node = SLICENODE_BufferShells() + node.shape_option = 'square' + + resolve_map = { + "Paths": sp, + "Distance": 1.0, + "Rotation": (0.0, 0.0, 0.0) + } + + with patch.object(node, "resolve", side_effect=lambda name: resolve_map.get(name)): + res = node.evaluate_output("Paths") + self.assertEqual(len(res), 1) + self.assertAlmostEqual(np.min(res[0][:, 0]), -1.0, places=2) + self.assertAlmostEqual(np.max(res[0][:, 0]), 21.0, places=2) + if __name__ == '__main__': unittest.main() + diff --git a/tests/test_transform.py b/tests/test_transform.py index aef5e4c..08f6f1c 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -228,7 +228,7 @@ class TestTransform(unittest.TestCase): # Check transforms metadata self.assertEqual(len(result_sp.transforms), 1) - self.assertEqual(result_sp.transforms[0]["type"], "transform_geometry") + self.assertEqual(result_sp.transforms[0]["type"], "transform") def test_transform_node_list_input(self): from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_TransformGeometry @@ -347,7 +347,8 @@ class TestTransform(unittest.TestCase): sc_transformed = t_node.evaluate_output("Geometry") self.assertIsNotNone(sc_transformed) self.assertEqual(len(sc_transformed.transforms), 1) - self.assertEqual(sc_transformed.transforms[0]["type"], "transform_geometry") + self.assertEqual(sc_transformed.transforms[0]["type"], "transform") + self.assertEqual(sc_transformed.transforms[0]["translation"], (10.0, 20.0, 0.0)) # 2. Drop Geometry d_node = SLICENODE_DropGeometry() @@ -358,8 +359,9 @@ class TestTransform(unittest.TestCase): sc_dropped = d_node.evaluate_output("Mesh") self.assertIsNotNone(sc_dropped) - self.assertEqual(len(sc_dropped.transforms), 2) - self.assertEqual(sc_dropped.transforms[1]["type"], "drop_geometry") + self.assertEqual(len(sc_dropped.transforms), 1) + self.assertEqual(sc_dropped.transforms[0]["type"], "transform") + self.assertEqual(sc_dropped.transforms[0]["translation"], (10.0, 20.0, -5.0)) # 3. Create SlicePaths from geometry path = np.array([[10.0, 20.0, 0.0], [20.0, 20.0, 0.0], [15.0, 30.0, 0.0]]) @@ -370,8 +372,6 @@ class TestTransform(unittest.TestCase): gcode = pather.to_gcode(include_header=True) self.assertIn(";PREPARATION_TRANSFORMS=", gcode) - self.assertIn(";TRANSFORM_GEOMETRY_0_TRANSLATION=10.000000,20.000000,0.000000", gcode) - self.assertIn(";DROP_GEOMETRY_1_OFFSET_Z=-5.000000", gcode) self.assertIn(";PREPARATION_TOTAL_TRANSLATION=10.000000,20.000000,-5.000000", gcode) # 5. Process G-code with GcodeToMesh preview node @@ -466,8 +466,109 @@ class TestTransform(unittest.TestCase): node._process_gcode.assert_any_call("GC1", "GcodePreview00", None) node._process_gcode.assert_any_call("GC2", "GcodePreview01", None) + def test_drop_geometry_applied_to_main_transform_no_double_accumulation(self): + """Verifies that dropping geometry updates the single main transform without accumulating duplicate records.""" + from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_TransformGeometry, SLICENODE_DropGeometry + + so = SlicingObject() + so.mesh = np.array([[[0.0, 0.0, 10.0], + [10.0, 0.0, 10.0], + [5.0, 10.0, 15.0], + [0.0, 0.0, 1.0]]], dtype=np.float64) + sc = SlicingCollection([], "coll") + sc.objects = [so] + + # Step 1: Transform geometry (translate by X=10, Y=20, Z=5, rot Z=90deg) + t_node = SLICENODE_TransformGeometry() + t_node.resolve = lambda f: { + "Geometry": sc, + "Translation": (10.0, 20.0, 5.0), + "Rotation": (0.0, 0.0, np.pi / 2.0), + "Scale": (1.0, 1.0, 1.0) + }.get(f) + + sc_t = t_node.evaluate_output("Geometry") + self.assertEqual(len(sc_t.transforms), 1) + self.assertEqual(sc_t.transforms[0]["type"], "transform") + self.assertEqual(sc_t.transforms[0]["translation"], (10.0, 20.0, 5.0)) + self.assertEqual(sc_t.transforms[0]["rotation"], (0.0, 0.0, np.pi / 2.0)) + # min_z of sc_t is 10.0 + 5.0 = 15.0 + + # Step 2: Drop geometry to Z=0.0 (offset_z = -15.0) + d_node1 = SLICENODE_DropGeometry() + d_node1.resolve = lambda f: { + "Mesh": sc_t, + "Z": 0.0 + }.get(f) + + sc_d1 = d_node1.evaluate_output("Mesh") + self.assertEqual(len(sc_d1.transforms), 1) + self.assertEqual(sc_d1.transforms[0]["type"], "transform") + # Translation Z becomes 5.0 + (-15.0) = -10.0 + self.assertEqual(sc_d1.transforms[0]["translation"], (10.0, 20.0, -10.0)) + self.assertEqual(sc_d1.transforms[0]["rotation"], (0.0, 0.0, np.pi / 2.0)) + self.assertAlmostEqual(sc_d1.min_z, 0.0) + + # Step 3: Drop geometry again to Z=2.0 (offset_z = +2.0) + d_node2 = SLICENODE_DropGeometry() + d_node2.resolve = lambda f: { + "Mesh": sc_d1, + "Z": 2.0 + }.get(f) + + sc_d2 = d_node2.evaluate_output("Mesh") + self.assertEqual(len(sc_d2.transforms), 1) + self.assertEqual(sc_d2.transforms[0]["type"], "transform") + # Translation Z becomes -10.0 + 2.0 = -8.0 + self.assertEqual(sc_d2.transforms[0]["translation"], (10.0, 20.0, -8.0)) + self.assertEqual(sc_d2.transforms[0]["rotation"], (0.0, 0.0, np.pi / 2.0)) + self.assertAlmostEqual(sc_d2.min_z, 2.0) + + def test_join_and_boolean_do_not_sum_transforms(self): + """Verifies that joining, zipping, or performing boolean operations on distinct geometries does NOT sum their transforms.""" + from NodeSlicer.core.slice import SlicePaths, boolean_slice + from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject + + # Object 1 with transform translation (10, 0, 0) + p1 = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]]) + sp1 = SlicePaths([p1], groups=[0], transforms=[{"type": "transform", "translation": (10.0, 0.0, 0.0), "rotation": (0.0, 0.0, 0.0), "scale": (1.0, 1.0, 1.0)}]) + + # Object 2 with transform translation (20, 0, 0) + p2 = np.array([[5, 0, 1.0], [15, 0, 1.0], [15, 10, 1.0], [5, 10, 1.0], [5, 0, 1.0]]) + sp2 = SlicePaths([p2], groups=[0], transforms=[{"type": "transform", "translation": (20.0, 0.0, 0.0), "rotation": (0.0, 0.0, 0.0), "scale": (1.0, 1.0, 1.0)}]) + + # 1. Zip paths + sp_zip = SlicePaths.zip_paths([sp1, sp2]) + self.assertEqual(len(sp_zip.transforms), 1) + self.assertEqual(sp_zip.transforms[0]["translation"], (10.0, 0.0, 0.0)) + + # 2. Extend paths + sp_ext = SlicePaths.extend_paths([sp1, sp2]) + self.assertEqual(len(sp_ext.transforms), 1) + self.assertEqual(sp_ext.transforms[0]["translation"], (10.0, 0.0, 0.0)) + + # 3. Boolean slice (Union) + sp_union = boolean_slice(sp1, sp2, operation='UNION') + self.assertEqual(len(sp_union.transforms), 1) + self.assertEqual(sp_union.transforms[0]["translation"], (10.0, 0.0, 0.0)) + + # 4. SlicingObject.merge + so1 = SlicingObject() + so1.name = "Obj1" + so1.transforms = [{"type": "transform", "translation": (10.0, 0.0, 0.0), "rotation": (0.0, 0.0, 0.0), "scale": (1.0, 1.0, 1.0)}] + + so2 = SlicingObject() + so2.name = "Obj2" + so2.transforms = [{"type": "transform", "translation": (20.0, 0.0, 0.0), "rotation": (0.0, 0.0, 0.0), "scale": (1.0, 1.0, 1.0)}] + + so_merged = SlicingObject.merge([so1, so2]) + self.assertEqual(len(so_merged.transforms), 1) + self.assertEqual(so_merged.transforms[0]["translation"], (10.0, 0.0, 0.0)) + if __name__ == '__main__': unittest.main() + + diff --git a/tests/test_visual_operations.py b/tests/test_visual_operations.py new file mode 100644 index 0000000..e5bb6d0 --- /dev/null +++ b/tests/test_visual_operations.py @@ -0,0 +1,75 @@ +import os +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.append(str(PROJECT_ROOT.parent)) +sys.path.append(str(PROJECT_ROOT)) + +# Define dummy classes to avoid metaclass conflicts +class DummyNodeSocket: pass +class DummyNode: pass +class DummyObject: pass +class DummyMesh: pass +class DummyNodeSocketShader: pass +class DummyNodeCustomGroup: pass +class DummyNodeTree: pass +class DummyOperator: pass +class DummyMenu: pass +class DummyPanel: pass +class DummyPropertyGroup: pass + +from unittest.mock import MagicMock +mock_types = MagicMock() +mock_types.NodeSocket = DummyNodeSocket +mock_types.Node = DummyNode +mock_types.Object = DummyObject +mock_types.Mesh = DummyMesh +mock_types.NodeSocketShader = DummyNodeSocketShader +mock_types.NodeCustomGroup = DummyNodeCustomGroup +mock_types.NodeTree = DummyNodeTree +mock_types.Operator = DummyOperator +mock_types.Menu = DummyMenu +mock_types.Panel = DummyPanel +mock_types.PropertyGroup = DummyPropertyGroup + +mock_bpy = MagicMock() +mock_bpy.context.scene = MagicMock() +mock_bpy.context.scene.unit_settings.scale_length = 0.001 +mock_bpy.types = mock_types + +sys.modules['bpy'] = mock_bpy +sys.modules['bpy.types'] = mock_types +sys.modules['bpy.props'] = MagicMock() +sys.modules['bpy.utils'] = MagicMock() +sys.modules['bpy.utils.previews'] = MagicMock() +sys.modules['bpy.app'] = MagicMock() +sys.modules['bpy.app.handlers'] = MagicMock() + +import unittest +from NodeSlicer.tests.visual.visualize_path_operations import run_all_visualizations, OUTPUT_DIR, IMAGES_DIR + +class TestVisualOperations(unittest.TestCase): + def test_run_all_visual_path_operations(self): + results = run_all_visualizations() + self.assertGreater(len(results), 0) + + # Ensure index.html exists and has content + index_html = os.path.join(OUTPUT_DIR, "index.html") + self.assertTrue(os.path.exists(index_html)) + self.assertGreater(os.path.getsize(index_html), 100) + + # Ensure README.md exists and has content + readme_md = os.path.join(OUTPUT_DIR, "README.md") + self.assertTrue(os.path.exists(readme_md)) + self.assertGreater(os.path.getsize(readme_md), 100) + + # Ensure every image file exists and is non-empty, and status is PASS + for r in results: + self.assertEqual(r["status"], "PASS", f"Test {r['id']} ({r['title']}) failed") + img_path = os.path.join(IMAGES_DIR, r["image"]) + self.assertTrue(os.path.exists(img_path), f"Image {img_path} not found") + self.assertGreater(os.path.getsize(img_path), 1000, f"Image {img_path} is too small") + +if __name__ == '__main__': + unittest.main() diff --git a/tests/visual/README.md b/tests/visual/README.md new file mode 100644 index 0000000..1c32cfe --- /dev/null +++ b/tests/visual/README.md @@ -0,0 +1,37 @@ +# NodeSlicer Path Operations Visual Test Suite + +This directory contains visual verifications and plots for all operations in the `Path Operations` node: +- **Maximum Coverage (Union)**: Boolean union of all polygons layer-by-layer. +- **Minimum Coverage**: Per-layer intersection of all objects having paths on that layer (ignoring empty layers so single-object layers are preserved intact). +- **Intersection**: Standard boolean intersection across all inputs. +- **Subtract**: Difference subtracting subsequent inputs from first input. +- **Exclusive Or (XOR)**: Symmetric difference across inputs. +- **Zip Paths**: Layer-by-layer toolpath stream interleaving. +- **Extend Paths**: Sequential toolpath concatenation. + +## Visual Gallery +Open `index.html` in your web browser to view the complete visual report with side-by-side before/after plots. + +## Summary of Test Cases +| ID | Operation | Test Title | Status | Image | +|---|---|---|---|---| +| TC01 | `MAX_COVERAGE` | Maximum Coverage: Overlapping Squares | **PASS** | [01_max_coverage_overlapping_squares.png](images/01_max_coverage_overlapping_squares.png) | +| TC02 | `MAX_COVERAGE` | Maximum Coverage: Donut with Hole + Solid Box | **PASS** | [02_max_coverage_donut_and_box.png](images/02_max_coverage_donut_and_box.png) | +| TC03 | `MIN_COVERAGE` | Minimum Coverage: Multi-Layer Staggered Objects | **PASS** | [03_min_coverage_staggered_layers.png](images/03_min_coverage_staggered_layers.png) | +| TC04 | `MIN_COVERAGE` | Minimum Coverage: Two Overlapping Squares | **PASS** | [04_min_coverage_overlapping_squares.png](images/04_min_coverage_overlapping_squares.png) | +| TC05 | `MIN_COVERAGE` | Minimum Coverage: Single Object with Multiple Islands | **PASS** | [05_min_coverage_single_object_islands.png](images/05_min_coverage_single_object_islands.png) | +| TC06 | `INTERSECTION` | Boolean Intersection: Multi-Layer Staggered | **PASS** | [06_intersection_staggered_layers.png](images/06_intersection_staggered_layers.png) | +| TC07 | `SUBTRACT` | Subtract: Difference Creating Interior Hole | **PASS** | [07_subtract_hole_creation.png](images/07_subtract_hole_creation.png) | +| TC08 | `XOR` | Exclusive Or (XOR): Symmetric Difference | **PASS** | [08_xor_overlapping_squares.png](images/08_xor_overlapping_squares.png) | +| TC09 | `ZIP` | Zip Paths: Layer-by-Layer Interleaving | **PASS** | [09_zip_paths_layer_interleaving.png](images/09_zip_paths_layer_interleaving.png) | +| TC10 | `EXTEND` | Extend Paths: Sequential Concatenation | **PASS** | [10_extend_paths_sequential.png](images/10_extend_paths_sequential.png) | +| TC11 | `MAX_COVERAGE` | Maximum Coverage: Concentric Perimeters (2 Inputs) | **PASS** | [11_max_coverage_concentric_perimeters.png](images/11_max_coverage_concentric_perimeters.png) | +| TC12 | `MIN_COVERAGE` | Minimum Coverage: Concentric Perimeters (2 Inputs) | **PASS** | [12_min_coverage_concentric_perimeters.png](images/12_min_coverage_concentric_perimeters.png) | +| TC13 | `MAX_COVERAGE` | Maximum Coverage: Single Input with Concentric Shells | **PASS** | [13_max_coverage_single_input_perimeters.png](images/13_max_coverage_single_input_perimeters.png) | +| TC14 | `MIN_COVERAGE` | Minimum Coverage: Single Input with Concentric Shells | **PASS** | [14_min_coverage_single_input_perimeters.png](images/14_min_coverage_single_input_perimeters.png) | + +## Running Visual Tests +To regenerate all images and the HTML gallery, run: +```bash +.venv/bin/python -m unittest tests.test_visual_operations +``` diff --git a/tests/visual/__init__.py b/tests/visual/__init__.py new file mode 100644 index 0000000..1d59a8c --- /dev/null +++ b/tests/visual/__init__.py @@ -0,0 +1 @@ +# tests/visual package diff --git a/tests/visual/images/01_max_coverage_overlapping_squares.png b/tests/visual/images/01_max_coverage_overlapping_squares.png new file mode 100644 index 0000000..83109ff Binary files /dev/null and b/tests/visual/images/01_max_coverage_overlapping_squares.png differ diff --git a/tests/visual/images/02_max_coverage_donut_and_box.png b/tests/visual/images/02_max_coverage_donut_and_box.png new file mode 100644 index 0000000..121cc9b Binary files /dev/null and b/tests/visual/images/02_max_coverage_donut_and_box.png differ diff --git a/tests/visual/images/03_min_coverage_staggered_layers.png b/tests/visual/images/03_min_coverage_staggered_layers.png new file mode 100644 index 0000000..bc0e4fd Binary files /dev/null and b/tests/visual/images/03_min_coverage_staggered_layers.png differ diff --git a/tests/visual/images/04_min_coverage_overlapping_squares.png b/tests/visual/images/04_min_coverage_overlapping_squares.png new file mode 100644 index 0000000..e32cfdd Binary files /dev/null and b/tests/visual/images/04_min_coverage_overlapping_squares.png differ diff --git a/tests/visual/images/05_min_coverage_single_object_islands.png b/tests/visual/images/05_min_coverage_single_object_islands.png new file mode 100644 index 0000000..12d9caf Binary files /dev/null and b/tests/visual/images/05_min_coverage_single_object_islands.png differ diff --git a/tests/visual/images/06_intersection_staggered_layers.png b/tests/visual/images/06_intersection_staggered_layers.png new file mode 100644 index 0000000..540d7ef Binary files /dev/null and b/tests/visual/images/06_intersection_staggered_layers.png differ diff --git a/tests/visual/images/07_subtract_hole_creation.png b/tests/visual/images/07_subtract_hole_creation.png new file mode 100644 index 0000000..37a8b54 Binary files /dev/null and b/tests/visual/images/07_subtract_hole_creation.png differ diff --git a/tests/visual/images/08_xor_overlapping_squares.png b/tests/visual/images/08_xor_overlapping_squares.png new file mode 100644 index 0000000..ddb8304 Binary files /dev/null and b/tests/visual/images/08_xor_overlapping_squares.png differ diff --git a/tests/visual/images/09_zip_paths_layer_interleaving.png b/tests/visual/images/09_zip_paths_layer_interleaving.png new file mode 100644 index 0000000..fa2c48a Binary files /dev/null and b/tests/visual/images/09_zip_paths_layer_interleaving.png differ diff --git a/tests/visual/images/10_extend_paths_sequential.png b/tests/visual/images/10_extend_paths_sequential.png new file mode 100644 index 0000000..aeec9c2 Binary files /dev/null and b/tests/visual/images/10_extend_paths_sequential.png differ diff --git a/tests/visual/images/11_max_coverage_concentric_perimeters.png b/tests/visual/images/11_max_coverage_concentric_perimeters.png new file mode 100644 index 0000000..a17f5bd Binary files /dev/null and b/tests/visual/images/11_max_coverage_concentric_perimeters.png differ diff --git a/tests/visual/images/12_min_coverage_concentric_perimeters.png b/tests/visual/images/12_min_coverage_concentric_perimeters.png new file mode 100644 index 0000000..2778f12 Binary files /dev/null and b/tests/visual/images/12_min_coverage_concentric_perimeters.png differ diff --git a/tests/visual/images/13_max_coverage_single_input_perimeters.png b/tests/visual/images/13_max_coverage_single_input_perimeters.png new file mode 100644 index 0000000..572d1aa Binary files /dev/null and b/tests/visual/images/13_max_coverage_single_input_perimeters.png differ diff --git a/tests/visual/images/14_min_coverage_single_input_perimeters.png b/tests/visual/images/14_min_coverage_single_input_perimeters.png new file mode 100644 index 0000000..a155741 Binary files /dev/null and b/tests/visual/images/14_min_coverage_single_input_perimeters.png differ diff --git a/tests/visual/index.html b/tests/visual/index.html new file mode 100644 index 0000000..ceef2a1 --- /dev/null +++ b/tests/visual/index.html @@ -0,0 +1,381 @@ + + +
+ + +Side-by-side verification gallery for Boolean Union, Multi-layer Minimum Coverage, Difference, XOR, Zip, and Extend operations.
+Calculates the boolean union of two overlapping polygons into a single continuous boundary.
+ +
+
+ Correctly preserves hole topology during union, partially clipping the interior hole.
+ +
+
+ Verifies that empty paths on layers where only 1 object exists do NOT zero out the layer, while overlapping layers are properly intersected.
+ +
+
+ Calculates the intersection region [10, 20] x [10, 20] between two active inputs.
+ +
+
+ Ensures disjoint shapes within a single object are preserved intact rather than annihilated by self-intersection.
+ +
+
+ Standard intersection requires all operands to be present on each layer, producing output only on layer 2.
+ +
+
+ Performs geometric subtraction producing outer boundary + interior hole contour.
+ +
+
+ Calculates symmetric difference, removing the overlapping intersection.
+ +
+
+ Interleaves toolpath streams layer-by-layer conserving metadata and extrusion attributes.
+ +
+
+ Concatenates multiple toolpath streams sequentially preserving all group IDs and attributes.
+ +
+
+ Calculates boolean union across concentric shells yielding the outsidemost perimeter boundary.
+ +
+
+ Calculates boolean intersection across concentric shells yielding the insidemost perimeter boundary.
+ +
+
+ Unions concentric perimeter loops within a single input into the outermost boundary.
+ +
+
+ Intersects concentric perimeter loops within a single input into the innermost boundary.
+ +
+
+ Side-by-side verification gallery for Boolean Union, Multi-layer Minimum Coverage, Difference, XOR, Zip, and Extend operations.
+