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 @@ + + + + + + NodeSlicer - Path Operations Visual Test Suite + + + +
+
+

NodeSlicer - Path Operations Visual Test Suite

+

Side-by-side verification gallery for Boolean Union, Multi-layer Minimum Coverage, Difference, XOR, Zip, and Extend operations.

+
+
+ +
+
+

TC01: Maximum Coverage: Overlapping Squares

+ PASS +
+

Calculates the boolean union of two overlapping polygons into a single continuous boundary.

+
+ Operation: MAX_COVERAGE + Inputs: 2 + Output Paths: 1 +
+
+ + Maximum Coverage: Overlapping Squares + +
+
+ +
+
+

TC02: Maximum Coverage: Donut with Hole + Solid Box

+ PASS +
+

Correctly preserves hole topology during union, partially clipping the interior hole.

+
+ Operation: MAX_COVERAGE + Inputs: 2 + Output Paths: 2 +
+
+ + Maximum Coverage: Donut with Hole + Solid Box + +
+
+ +
+
+

TC03: Minimum Coverage: Multi-Layer Staggered Objects

+ PASS +
+

Verifies that empty paths on layers where only 1 object exists do NOT zero out the layer, while overlapping layers are properly intersected.

+
+ Operation: MIN_COVERAGE + Inputs: 2 + Output Paths: 3 +
+
+ + Minimum Coverage: Multi-Layer Staggered Objects + +
+
+ +
+
+

TC04: Minimum Coverage: Two Overlapping Squares

+ PASS +
+

Calculates the intersection region [10, 20] x [10, 20] between two active inputs.

+
+ Operation: MIN_COVERAGE + Inputs: 2 + Output Paths: 1 +
+
+ + Minimum Coverage: Two Overlapping Squares + +
+
+ +
+
+

TC05: Minimum Coverage: Single Object with Multiple Islands

+ PASS +
+

Ensures disjoint shapes within a single object are preserved intact rather than annihilated by self-intersection.

+
+ Operation: MIN_COVERAGE + Inputs: 1 + Output Paths: 2 +
+
+ + Minimum Coverage: Single Object with Multiple Islands + +
+
+ +
+
+

TC06: Boolean Intersection: Multi-Layer Staggered

+ PASS +
+

Standard intersection requires all operands to be present on each layer, producing output only on layer 2.

+
+ Operation: INTERSECTION + Inputs: 2 + Output Paths: 1 +
+
+ + Boolean Intersection: Multi-Layer Staggered + +
+
+ +
+
+

TC07: Subtract: Difference Creating Interior Hole

+ PASS +
+

Performs geometric subtraction producing outer boundary + interior hole contour.

+
+ Operation: SUBTRACT + Inputs: 2 + Output Paths: 2 +
+
+ + Subtract: Difference Creating Interior Hole + +
+
+ +
+
+

TC08: Exclusive Or (XOR): Symmetric Difference

+ PASS +
+

Calculates symmetric difference, removing the overlapping intersection.

+
+ Operation: XOR + Inputs: 2 + Output Paths: 2 +
+
+ + Exclusive Or (XOR): Symmetric Difference + +
+
+ +
+
+

TC09: Zip Paths: Layer-by-Layer Interleaving

+ PASS +
+

Interleaves toolpath streams layer-by-layer conserving metadata and extrusion attributes.

+
+ Operation: ZIP + Inputs: 2 + Output Paths: 4 +
+
+ + Zip Paths: Layer-by-Layer Interleaving + +
+
+ +
+
+

TC10: Extend Paths: Sequential Concatenation

+ PASS +
+

Concatenates multiple toolpath streams sequentially preserving all group IDs and attributes.

+
+ Operation: EXTEND + Inputs: 2 + Output Paths: 4 +
+
+ + Extend Paths: Sequential Concatenation + +
+
+ +
+
+

TC11: Maximum Coverage: Concentric Perimeters (2 Inputs)

+ PASS +
+

Calculates boolean union across concentric shells yielding the outsidemost perimeter boundary.

+
+ Operation: MAX_COVERAGE + Inputs: 2 + Output Paths: 1 +
+
+ + Maximum Coverage: Concentric Perimeters (2 Inputs) + +
+
+ +
+
+

TC12: Minimum Coverage: Concentric Perimeters (2 Inputs)

+ PASS +
+

Calculates boolean intersection across concentric shells yielding the insidemost perimeter boundary.

+
+ Operation: MIN_COVERAGE + Inputs: 2 + Output Paths: 1 +
+
+ + Minimum Coverage: Concentric Perimeters (2 Inputs) + +
+
+ +
+
+

TC13: Maximum Coverage: Single Input with Concentric Shells

+ PASS +
+

Unions concentric perimeter loops within a single input into the outermost boundary.

+
+ Operation: MAX_COVERAGE + Inputs: 1 + Output Paths: 1 +
+
+ + Maximum Coverage: Single Input with Concentric Shells + +
+
+ +
+
+

TC14: Minimum Coverage: Single Input with Concentric Shells

+ PASS +
+

Intersects concentric perimeter loops within a single input into the innermost boundary.

+
+ Operation: MIN_COVERAGE + Inputs: 1 + Output Paths: 1 +
+
+ + Minimum Coverage: Single Input with Concentric Shells + +
+
+ +
+
+ + diff --git a/tests/visual/visualize_path_operations.py b/tests/visual/visualize_path_operations.py new file mode 100644 index 0000000..1d3aac8 --- /dev/null +++ b/tests/visual/visualize_path_operations.py @@ -0,0 +1,830 @@ +""" +NodeSlicer Path Operations Visualizer and Test Suite Generator. +Generates comprehensive 2D/3D visualizations for all Path Operations modes: +- Maximum Coverage (Union) +- Minimum Coverage (Multi-layer Intersection with empty-layer ignoring) +- Intersection (Boolean Intersection across all inputs) +- Subtract (Boolean Difference) +- Exclusive Or (XOR) +- Zip Paths (Layer-by-layer interleaving) +- Extend Paths (Sequential concatenation) +""" + +import os +import sys +from pathlib import Path + +# Add project root to sys.path +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) +if str(PROJECT_ROOT.parent) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT.parent)) + +# Mock bpy if running outside Blender +if 'bpy' not in sys.modules or not hasattr(sys.modules['bpy'], 'types'): + from unittest.mock import MagicMock + 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 + + 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 copy +import numpy as np +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +from matplotlib.patches import PathPatch +from matplotlib.path import Path as MplPath +from shapely.geometry import Polygon, MultiPolygon + +try: + from NodeSlicer.core.slice import SlicePaths, is_closed_path, _group_closed_paths_into_shapes +except ImportError: + from core.slice import SlicePaths, is_closed_path, _group_closed_paths_into_shapes + +OUTPUT_DIR = os.path.dirname(os.path.abspath(__file__)) +IMAGES_DIR = os.path.join(OUTPUT_DIR, "images") + + +def _paths_to_mpl_patch(paths: list[np.ndarray], facecolor: str, edgecolor: str, alpha: float = 0.4, label: str = "") -> list: + """Converts a list of 2D/3D paths (outer boundaries + holes) into matplotlib artist patches.""" + closed_paths = [p for p in paths if is_closed_path(p)] + open_paths = [p for p in paths if not is_closed_path(p)] + + patches = [] + + if closed_paths: + shapes = _group_closed_paths_into_shapes(closed_paths) + for shape in shapes: + if not shape: + continue + vertices = [] + codes = [] + + # Outer boundary + outer_pts = shape[0][:, :2] + if np.allclose(outer_pts[0], outer_pts[-1]): + outer_pts = outer_pts[:-1] + for i, pt in enumerate(outer_pts): + vertices.append(pt) + codes.append(MplPath.MOVETO if i == 0 else MplPath.LINETO) + vertices.append(outer_pts[0]) + codes.append(MplPath.CLOSEPOLY) + + # Holes + for hole in shape[1:]: + hole_pts = hole[:, :2] + if np.allclose(hole_pts[0], hole_pts[-1]): + hole_pts = hole_pts[:-1] + for i, pt in enumerate(hole_pts): + vertices.append(pt) + codes.append(MplPath.MOVETO if i == 0 else MplPath.LINETO) + vertices.append(hole_pts[0]) + codes.append(MplPath.CLOSEPOLY) + + mpl_path = MplPath(vertices, codes) + patch = PathPatch(mpl_path, facecolor=facecolor, edgecolor=edgecolor, linewidth=2.0, alpha=alpha, label=label) + patches.append(patch) + + return patches, open_paths + + +def plot_operation_comparison( + title: str, + description: str, + inputs: list[tuple[str, SlicePaths, str]], # (name, SlicePaths, color) + result: SlicePaths, + operation_name: str, + filename: str +) -> str: + """ + Renders a multi-layer side-by-side visualization of inputs vs output. + """ + os.makedirs(IMAGES_DIR, exist_ok=True) + out_path = os.path.join(IMAGES_DIR, filename) + + # Collect all unique Z levels + all_zs = set() + for _, sp, _ in inputs: + for p in sp: + if len(p) > 0: + all_zs.add(round(float(p[0, 2]), 3)) + for p in result: + if len(p) > 0: + all_zs.add(round(float(p[0, 2]), 3)) + + sorted_zs = sorted(list(all_zs)) if all_zs else [0.0] + num_layers = len(sorted_zs) + + fig, axes = plt.subplots(num_layers, 2, figsize=(14, 5 * num_layers), squeeze=False) + fig.suptitle(f"{title}\nOperation: {operation_name}", fontsize=15, fontweight='bold', y=0.98) + + # Global bounding box for consistent scaling + all_pts = [] + for _, sp, _ in inputs: + for p in sp: + if len(p) > 0: + all_pts.append(p[:, :2]) + for p in result: + if len(p) > 0: + all_pts.append(p[:, :2]) + + if all_pts: + all_pts_arr = np.vstack(all_pts) + min_x, min_y = all_pts_arr.min(axis=0) - 5 + max_x, max_y = all_pts_arr.max(axis=0) + 5 + else: + min_x, min_y, max_x, max_y = -10, -10, 50, 50 + + for row_idx, z in enumerate(sorted_zs): + ax_in = axes[row_idx, 0] + ax_out = axes[row_idx, 1] + + # 1. Left Panel: Inputs on layer Z + ax_in.set_title(f"Inputs (Layer Z = {z:.2f} mm)", fontsize=12, fontweight='semibold') + ax_in.set_xlim(min_x, max_x) + ax_in.set_ylim(min_y, max_y) + ax_in.set_aspect('equal') + ax_in.grid(True, linestyle='--', alpha=0.5) + + has_in_geom = False + for inp_name, sp, color in inputs: + layer_paths = [p for p in sp if len(p) > 0 and abs(p[0, 2] - z) < 1e-4] + if layer_paths: + has_in_geom = True + patches, open_p = _paths_to_mpl_patch(layer_paths, facecolor=color, edgecolor=color, alpha=0.35, label=inp_name) + for patch in patches: + ax_in.add_patch(copy.copy(patch)) + for op in open_p: + ax_in.plot(op[:, 0], op[:, 1], color=color, linewidth=2.5, linestyle='-', label=f"{inp_name} (Open)") + ax_in.scatter(op[:, 0], op[:, 1], color=color, s=20) + # Draw outer line markers + for p in layer_paths: + ax_in.scatter(p[:, 0], p[:, 1], color=color, s=15, alpha=0.7) + + if not has_in_geom: + ax_in.text(0.5, 0.5, "No paths on this layer", transform=ax_in.transAxes, ha='center', va='center', color='gray', fontsize=12) + + # 2. Right Panel: Result on layer Z + ax_out.set_title(f"Result: {operation_name} (Layer Z = {z:.2f} mm)", fontsize=12, fontweight='semibold') + ax_out.set_xlim(min_x, max_x) + ax_out.set_ylim(min_y, max_y) + ax_out.set_aspect('equal') + ax_out.grid(True, linestyle='--', alpha=0.5) + + res_layer_paths = [p for p in result if len(p) > 0 and abs(p[0, 2] - z) < 1e-4] + if res_layer_paths: + res_color = "#9467bd" # Purple + patches, open_p = _paths_to_mpl_patch(res_layer_paths, facecolor=res_color, edgecolor="#5c2d91", alpha=0.5, label="Result Paths") + for patch in patches: + ax_out.add_patch(copy.copy(patch)) + for op in open_p: + ax_out.plot(op[:, 0], op[:, 1], color="#5c2d91", linewidth=3.0, linestyle='-', label="Result (Open)") + ax_out.scatter(op[:, 0], op[:, 1], color="#5c2d91", s=25) + for p in res_layer_paths: + ax_out.plot(p[:, 0], p[:, 1], color="#5c2d91", linewidth=2.0) + ax_out.scatter(p[:, 0], p[:, 1], color="#5c2d91", s=20) + # Mark seam / start vertex + ax_out.scatter([p[0, 0]], [p[0, 1]], color="#2ca02c", s=60, zorder=5, marker='o', label="Seam Start" if p is res_layer_paths[0] else "") + else: + ax_out.text(0.5, 0.5, "Empty (0 paths)", transform=ax_out.transAxes, ha='center', va='center', color='#d62728', fontsize=12, fontweight='bold') + + # Formatting legends + handles_in, labels_in = ax_in.get_legend_handles_labels() + if handles_in: + ax_in.legend(handles_in, labels_in, loc='upper right', fontsize=9) + + handles_out, labels_out = ax_out.get_legend_handles_labels() + if handles_out: + ax_out.legend(handles_out, labels_out, loc='upper right', fontsize=9) + + plt.tight_layout(rect=[0, 0.03, 1, 0.95]) + plt.savefig(out_path, dpi=120) + plt.close(fig) + return out_path + + +def run_all_visualizations() -> list[dict]: + """Generates all visual test cases for Path Operations and returns catalog of results.""" + test_results = [] + + # ========================================================================= + # Test 1: Maximum Coverage - Overlapping Rectangles (Boolean Union) + # ========================================================================= + p1 = np.array([[0, 0, 1.0], [20, 0, 1.0], [20, 20, 1.0], [0, 20, 1.0], [0, 0, 1.0]]) + p2 = np.array([[10, 10, 1.0], [30, 10, 1.0], [30, 30, 1.0], [10, 30, 1.0], [10, 10, 1.0]]) + sp1 = SlicePaths([p1], groups=[0], name="Square A") + sp2 = SlicePaths([p2], groups=[0], name="Square B") + + res1 = SlicePaths.path_operations([sp1, sp2], operation='MAX_COVERAGE') + fn1 = "01_max_coverage_overlapping_squares.png" + plot_operation_comparison( + title="1. Maximum Coverage: Overlapping Squares (Boolean Union)", + description="Two overlapping squares merged into a single outer contour without overlapping interior edges.", + inputs=[("Square A (0-20)", sp1, "#1f77b4"), ("Square B (10-30)", sp2, "#ff7f0e")], + result=res1, + operation_name="MAX_COVERAGE (Union)", + filename=fn1 + ) + test_results.append({ + "id": "TC01", + "title": "Maximum Coverage: Overlapping Squares", + "operation": "MAX_COVERAGE", + "image": fn1, + "description": "Calculates the boolean union of two overlapping polygons into a single continuous boundary.", + "input_count": 2, + "output_paths": len(res1), + "status": "PASS" if len(res1) == 1 and res1[0][:, 0].min() == 0 and res1[0][:, 0].max() == 30 else "FAIL" + }) + + # ========================================================================= + # Test 2: Maximum Coverage - Donut with Hole + Overlapping Square + # ========================================================================= + 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], name="Donut") + + box_overlap = 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_overlap], groups=[0], name="Solid Box") + + res2 = SlicePaths.path_operations([donut_sp, box_sp], operation='MAX_COVERAGE') + fn2 = "02_max_coverage_donut_and_box.png" + plot_operation_comparison( + title="2. Maximum Coverage: Donut with Hole + Overlapping Solid Box", + description="Donut shape with an interior hole unioned with a solid box partially covering the hole.", + inputs=[("Donut (Outer + Hole)", donut_sp, "#1f77b4"), ("Solid Box (15-40)", box_sp, "#ff7f0e")], + result=res2, + operation_name="MAX_COVERAGE (Union)", + filename=fn2 + ) + test_results.append({ + "id": "TC02", + "title": "Maximum Coverage: Donut with Hole + Solid Box", + "operation": "MAX_COVERAGE", + "image": fn2, + "description": "Correctly preserves hole topology during union, partially clipping the interior hole.", + "input_count": 2, + "output_paths": len(res2), + "status": "PASS" if len(res2) == 2 else "FAIL" + }) + + # ========================================================================= + # Test 3: Minimum Coverage - Multi-Layer Staggered Objects (Empty Layers Ignored) + # ========================================================================= + # Object A on Z=1.0 and Z=2.0 + pA1 = np.array([[0, 0, 1.0], [20, 0, 1.0], [20, 20, 1.0], [0, 20, 1.0], [0, 0, 1.0]]) + pA2 = np.array([[0, 0, 2.0], [20, 0, 2.0], [20, 20, 2.0], [0, 20, 2.0], [0, 0, 2.0]]) + spA = SlicePaths([pA1, pA2], groups=[0, 1], name="Object A") + + # Object B on Z=2.0 and Z=3.0 + pB2 = np.array([[10, 0, 2.0], [30, 0, 2.0], [30, 20, 2.0], [10, 20, 2.0], [10, 0, 2.0]]) + pB3 = np.array([[10, 10, 3.0], [30, 10, 3.0], [30, 30, 3.0], [10, 30, 3.0], [10, 10, 3.0]]) + spB = SlicePaths([pB2, pB3], groups=[0, 1], name="Object B") + + res3 = SlicePaths.path_operations([spA, spB], operation='MIN_COVERAGE') + fn3 = "03_min_coverage_staggered_layers.png" + plot_operation_comparison( + title="3. Minimum Coverage: Staggered Objects (Empty Layers Ignored)", + description="Layer 1: only Object A exists -> Object A preserved. Layer 2: A and B exist -> Intersection computed. Layer 3: only Object B exists -> Object B preserved.", + inputs=[("Object A (Z=1, 2)", spA, "#1f77b4"), ("Object B (Z=2, 3)", spB, "#2ca02c")], + result=res3, + operation_name="MIN_COVERAGE (Layer Intersection)", + filename=fn3 + ) + test_results.append({ + "id": "TC03", + "title": "Minimum Coverage: Multi-Layer Staggered Objects", + "operation": "MIN_COVERAGE", + "image": fn3, + "description": "Verifies that empty paths on layers where only 1 object exists do NOT zero out the layer, while overlapping layers are properly intersected.", + "input_count": 2, + "output_paths": len(res3), + "status": "PASS" if len(res3) == 3 else "FAIL" + }) + + # ========================================================================= + # Test 4: Minimum Coverage - Two Overlapping Boxes (Single Layer Intersection) + # ========================================================================= + res4 = SlicePaths.path_operations([sp1, sp2], operation='MIN_COVERAGE') + fn4 = "04_min_coverage_overlapping_squares.png" + plot_operation_comparison( + title="4. Minimum Coverage: Overlapping Squares on Same Layer", + description="Two overlapping squares on Z=1.0. Minimum coverage intersects them into the [10, 20] overlap region.", + inputs=[("Square A (0-20)", sp1, "#1f77b4"), ("Square B (10-30)", sp2, "#ff7f0e")], + result=res4, + operation_name="MIN_COVERAGE (Intersection)", + filename=fn4 + ) + test_results.append({ + "id": "TC04", + "title": "Minimum Coverage: Two Overlapping Squares", + "operation": "MIN_COVERAGE", + "image": fn4, + "description": "Calculates the intersection region [10, 20] x [10, 20] between two active inputs.", + "input_count": 2, + "output_paths": len(res4), + "status": "PASS" if len(res4) == 1 and np.isclose(res4[0][:, 0].min(), 10.0) and np.isclose(res4[0][:, 0].max(), 20.0) else "FAIL" + }) + + # ========================================================================= + # Test 5: Minimum Coverage - Single Object with Multiple Separate Islands + # ========================================================================= + island1 = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]]) + island2 = np.array([[30, 0, 1.0], [40, 0, 1.0], [40, 10, 1.0], [30, 10, 1.0], [30, 0, 1.0]]) + sp_islands = SlicePaths([island1, island2], groups=[0, 0], name="Dual Islands") + + res5 = SlicePaths.path_operations([sp_islands], operation='MIN_COVERAGE') + fn5 = "05_min_coverage_single_object_islands.png" + plot_operation_comparison( + title="5. Minimum Coverage: Single Object with Disjoint Islands", + description="Single object with two separate pillars/islands on Z=1.0. Preserves all islands intact without self-intersection destruction.", + inputs=[("Dual Islands Object", sp_islands, "#1f77b4")], + result=res5, + operation_name="MIN_COVERAGE (Single Input)", + filename=fn5 + ) + test_results.append({ + "id": "TC05", + "title": "Minimum Coverage: Single Object with Multiple Islands", + "operation": "MIN_COVERAGE", + "image": fn5, + "description": "Ensures disjoint shapes within a single object are preserved intact rather than annihilated by self-intersection.", + "input_count": 1, + "output_paths": len(res5), + "status": "PASS" if len(res5) == 2 else "FAIL" + }) + + # ========================================================================= + # Test 6: Standard Boolean Intersection - Staggered Objects (Non-Overlapping Layers Zeroed) + # ========================================================================= + res6 = SlicePaths.path_operations([spA, spB], operation='INTERSECTION') + fn6 = "06_intersection_staggered_layers.png" + plot_operation_comparison( + title="6. Boolean Intersection: Staggered Objects", + description="Standard Boolean Intersection: Only layers where ALL inputs are present produce output (Z=2.0 only; Z=1.0 and Z=3.0 are empty).", + inputs=[("Object A (Z=1, 2)", spA, "#1f77b4"), ("Object B (Z=2, 3)", spB, "#2ca02c")], + result=res6, + operation_name="INTERSECTION", + filename=fn6 + ) + test_results.append({ + "id": "TC06", + "title": "Boolean Intersection: Multi-Layer Staggered", + "operation": "INTERSECTION", + "image": fn6, + "description": "Standard intersection requires all operands to be present on each layer, producing output only on layer 2.", + "input_count": 2, + "output_paths": len(res6), + "status": "PASS" if len(res6) == 1 and np.allclose(res6[0][:, 2], 2.0) else "FAIL" + }) + + # ========================================================================= + # Test 7: Subtract - Outer Square Minus Inner Square (Hole Creation) + # ========================================================================= + outer_box = np.array([[0, 0, 1.0], [30, 0, 1.0], [30, 30, 1.0], [0, 30, 1.0], [0, 0, 1.0]]) + inner_box = np.array([[10, 10, 1.0], [20, 10, 1.0], [20, 20, 1.0], [10, 20, 1.0], [10, 10, 1.0]]) + sp_outer = SlicePaths([outer_box], groups=[0], name="Outer Square") + sp_inner = SlicePaths([inner_box], groups=[0], name="Subtracted Inner") + + res7 = SlicePaths.path_operations([sp_outer, sp_inner], operation='SUBTRACT') + fn7 = "07_subtract_hole_creation.png" + plot_operation_comparison( + title="7. Subtract: Difference Creating Interior Hole", + description="Subtracting an inner square from an outer square creates a donut polygon with 1 outer boundary and 1 hole.", + inputs=[("Outer Square (0-30)", sp_outer, "#1f77b4"), ("Inner Square (10-20)", sp_inner, "#d62728")], + result=res7, + operation_name="SUBTRACT (Difference)", + filename=fn7 + ) + test_results.append({ + "id": "TC07", + "title": "Subtract: Difference Creating Interior Hole", + "operation": "SUBTRACT", + "image": fn7, + "description": "Performs geometric subtraction producing outer boundary + interior hole contour.", + "input_count": 2, + "output_paths": len(res7), + "status": "PASS" if len(res7) == 2 else "FAIL" + }) + + # ========================================================================= + # Test 8: Exclusive Or (XOR) - Two Partially Overlapping Squares + # ========================================================================= + res8 = SlicePaths.path_operations([sp1, sp2], operation='XOR') + fn8 = "08_xor_overlapping_squares.png" + plot_operation_comparison( + title="8. Exclusive Or (XOR): Symmetric Difference", + description="Symmetric difference of two overlapping squares removes the overlap [10, 20] and produces two L-shaped polygons.", + inputs=[("Square A (0-20)", sp1, "#1f77b4"), ("Square B (10-30)", sp2, "#ff7f0e")], + result=res8, + operation_name="XOR (Symmetric Difference)", + filename=fn8 + ) + test_results.append({ + "id": "TC08", + "title": "Exclusive Or (XOR): Symmetric Difference", + "operation": "XOR", + "image": fn8, + "description": "Calculates symmetric difference, removing the overlapping intersection.", + "input_count": 2, + "output_paths": len(res8), + "status": "PASS" if len(res8) == 2 else "FAIL" + }) + + # ========================================================================= + # Test 9: Zip Paths - Perimeter and Infill Interleaving + # ========================================================================= + perim1 = np.array([[0, 0, 1.0], [20, 0, 1.0], [20, 20, 1.0], [0, 20, 1.0], [0, 0, 1.0]]) + infill1 = np.array([[2, 2, 1.0], [18, 18, 1.0]]) + perim2 = np.array([[0, 0, 2.0], [20, 0, 2.0], [20, 20, 2.0], [0, 20, 2.0], [0, 0, 2.0]]) + infill2 = np.array([[2, 18, 2.0], [18, 2, 2.0]]) + + sp_perims = SlicePaths([perim1, perim2], groups=[0, 1], name="Perimeters") + sp_infills = SlicePaths([infill1, infill2], groups=[0, 1], name="Infills") + + res9 = SlicePaths.path_operations([sp_perims, sp_infills], operation='ZIP') + fn9 = "09_zip_paths_layer_interleaving.png" + plot_operation_comparison( + title="9. Zip Paths: Layer-by-Layer Interleaving", + description="Interleaves perimeters and infills layer by layer: Layer 1 Perimeter -> Layer 1 Infill -> Layer 2 Perimeter -> Layer 2 Infill.", + inputs=[("Perimeter Paths", sp_perims, "#1f77b4"), ("Infill Paths", sp_infills, "#2ca02c")], + result=res9, + operation_name="ZIP (Layer Interleave)", + filename=fn9 + ) + test_results.append({ + "id": "TC09", + "title": "Zip Paths: Layer-by-Layer Interleaving", + "operation": "ZIP", + "image": fn9, + "description": "Interleaves toolpath streams layer-by-layer conserving metadata and extrusion attributes.", + "input_count": 2, + "output_paths": len(res9), + "status": "PASS" if len(res9) == 4 and res9.groups == [0, 0, 1, 1] else "FAIL" + }) + + # ========================================================================= + # Test 10: Extend Paths - Sequential Appending + # ========================================================================= + res10 = SlicePaths.path_operations([sp_perims, sp_infills], operation='EXTEND') + fn10 = "10_extend_paths_sequential.png" + plot_operation_comparison( + title="10. Extend Paths: Sequential Concatenation", + description="Appends paths sequentially: all perimeter layers followed by all infill layers.", + inputs=[("Perimeter Paths", sp_perims, "#1f77b4"), ("Infill Paths", sp_infills, "#2ca02c")], + result=res10, + operation_name="EXTEND (Sequential Append)", + filename=fn10 + ) + test_results.append({ + "id": "TC10", + "title": "Extend Paths: Sequential Concatenation", + "operation": "EXTEND", + "image": fn10, + "description": "Concatenates multiple toolpath streams sequentially preserving all group IDs and attributes.", + "input_count": 2, + "output_paths": len(res10), + "status": "PASS" if len(res10) == 4 else "FAIL" + }) + + # ========================================================================= + # Test 11: Multiple Perimeters (2 Separate Inputs) - Outer vs Inner Perimeter + # ========================================================================= + 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_outer_shell = SlicePaths([p_out], groups=[0], name="Outer Perimeter (0-20)") + sp_inner_shell = SlicePaths([p_in], groups=[0], name="Inner Perimeter (2-18)") + + res11_max = SlicePaths.path_operations([sp_outer_shell, sp_inner_shell], operation='MAX_COVERAGE') + res11_min = SlicePaths.path_operations([sp_outer_shell, sp_inner_shell], operation='MIN_COVERAGE') + + fn11_max = "11_max_coverage_concentric_perimeters.png" + plot_operation_comparison( + title="11. Maximum Coverage: Multiple Concentric Perimeters (2 Inputs)", + description="Two concentric perimeters. Maximum Coverage yields the outsidemost perimeter (0-20).", + inputs=[("Outer Perimeter (0-20)", sp_outer_shell, "#1f77b4"), ("Inner Perimeter (2-18)", sp_inner_shell, "#ff7f0e")], + result=res11_max, + operation_name="MAX_COVERAGE (Outsidemost Perimeter)", + filename=fn11_max + ) + test_results.append({ + "id": "TC11", + "title": "Maximum Coverage: Concentric Perimeters (2 Inputs)", + "operation": "MAX_COVERAGE", + "image": fn11_max, + "description": "Calculates boolean union across concentric shells yielding the outsidemost perimeter boundary.", + "input_count": 2, + "output_paths": len(res11_max), + "status": "PASS" if len(res11_max) == 1 and np.isclose(res11_max[0][:, 0].min(), 0.0) and np.isclose(res11_max[0][:, 0].max(), 20.0) else "FAIL" + }) + + fn11_min = "12_min_coverage_concentric_perimeters.png" + plot_operation_comparison( + title="12. Minimum Coverage: Multiple Concentric Perimeters (2 Inputs)", + description="Two concentric perimeters. Minimum Coverage yields the insidemost perimeter (2-18).", + inputs=[("Outer Perimeter (0-20)", sp_outer_shell, "#1f77b4"), ("Inner Perimeter (2-18)", sp_inner_shell, "#ff7f0e")], + result=res11_min, + operation_name="MIN_COVERAGE (Insidemost Perimeter)", + filename=fn11_min + ) + test_results.append({ + "id": "TC12", + "title": "Minimum Coverage: Concentric Perimeters (2 Inputs)", + "operation": "MIN_COVERAGE", + "image": fn11_min, + "description": "Calculates boolean intersection across concentric shells yielding the insidemost perimeter boundary.", + "input_count": 2, + "output_paths": len(res11_min), + "status": "PASS" if len(res11_min) == 1 and np.isclose(res11_min[0][:, 0].min(), 2.0) and np.isclose(res11_min[0][:, 0].max(), 18.0) else "FAIL" + }) + + # ========================================================================= + # Test 13: Multiple Perimeters in a Single Input + # ========================================================================= + sp_concentric_single = SlicePaths([p_out, p_in], groups=[0, 0], name="Concentric Shells Object") + res13_max = SlicePaths.path_operations([sp_concentric_single], operation='MAX_COVERAGE') + res13_min = SlicePaths.path_operations([sp_concentric_single], operation='MIN_COVERAGE') + + fn13_max = "13_max_coverage_single_input_perimeters.png" + plot_operation_comparison( + title="13. Maximum Coverage: Single Input with Multiple Perimeters", + description="Single object containing both outer and inner perimeter loops. Max coverage yields the outsidemost perimeter.", + inputs=[("Concentric Shells Object", sp_concentric_single, "#1f77b4")], + result=res13_max, + operation_name="MAX_COVERAGE (Single Input)", + filename=fn13_max + ) + test_results.append({ + "id": "TC13", + "title": "Maximum Coverage: Single Input with Concentric Shells", + "operation": "MAX_COVERAGE", + "image": fn13_max, + "description": "Unions concentric perimeter loops within a single input into the outermost boundary.", + "input_count": 1, + "output_paths": len(res13_max), + "status": "PASS" if len(res13_max) == 1 and np.isclose(res13_max[0][:, 0].min(), 0.0) else "FAIL" + }) + + fn13_min = "14_min_coverage_single_input_perimeters.png" + plot_operation_comparison( + title="14. Minimum Coverage: Single Input with Multiple Perimeters", + description="Single object containing both outer and inner perimeter loops. Min coverage yields the insidemost perimeter.", + inputs=[("Concentric Shells Object", sp_concentric_single, "#1f77b4")], + result=res13_min, + operation_name="MIN_COVERAGE (Single Input)", + filename=fn13_min + ) + test_results.append({ + "id": "TC14", + "title": "Minimum Coverage: Single Input with Concentric Shells", + "operation": "MIN_COVERAGE", + "image": fn13_min, + "description": "Intersects concentric perimeter loops within a single input into the innermost boundary.", + "input_count": 1, + "output_paths": len(res13_min), + "status": "PASS" if len(res13_min) == 1 and np.isclose(res13_min[0][:, 0].min(), 2.0) else "FAIL" + }) + + # Generate HTML Gallery + generate_html_report(test_results) + generate_markdown_readme(test_results) + + return test_results + + +def generate_html_report(test_results: list[dict]): + """Generates a modern HTML gallery showcasing all visual test comparisons.""" + html_file = os.path.join(OUTPUT_DIR, "index.html") + + rows_html = "" + for r in test_results: + badge_class = "badge-pass" if r["status"] == "PASS" else "badge-fail" + rows_html += f""" +
+
+

{r['id']}: {r['title']}

+ {r['status']} +
+

{r['description']}

+
+ Operation: {r['operation']} + Inputs: {r['input_count']} + Output Paths: {r['output_paths']} +
+
+ + {r['title']} + +
+
+ """ + + html_content = f""" + + + + + NodeSlicer - Path Operations Visual Test Suite + + + +
+
+

NodeSlicer - Path Operations Visual Test Suite

+

Side-by-side verification gallery for Boolean Union, Multi-layer Minimum Coverage, Difference, XOR, Zip, and Extend operations.

+
+
+ {rows_html} +
+
+ + +""" + with open(html_file, "w", encoding="utf-8") as f: + f.write(html_content) + + +def generate_markdown_readme(test_results: list[dict]): + """Generates README.md summary in tests/visual directory.""" + md_file = os.path.join(OUTPUT_DIR, "README.md") + lines = [ + "# 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 |", + "|---|---|---|---|---|" + ] + for r in test_results: + lines.append(f"| {r['id']} | `{r['operation']}` | {r['title']} | **{r['status']}** | [{r['image']}](images/{r['image']}) |") + + lines.extend([ + "", + "## Running Visual Tests", + "To regenerate all images and the HTML gallery, run:", + "```bash", + ".venv/bin/python -m unittest tests.test_visual_operations", + "```" + ]) + + with open(md_file, "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + + +if __name__ == "__main__": + results = run_all_visualizations() + print(f"Generated {len(results)} visual tests in {OUTPUT_DIR}")