transforms and path operations

This commit is contained in:
Nicolas Predella 2026-09-27 18:04:45 +03:00
parent b0943f2ca9
commit c0b71ab16b
27 changed files with 1868 additions and 226 deletions

View file

@ -63,3 +63,74 @@ def ndarray_lru_cache(maxsize: int = 128) -> Callable[[Callable[..., Any]], Call
return result
return wrapper
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]),
}]

View file

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

View file

@ -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,25 +397,38 @@ 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()
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):
bl_label = "Infill"
@ -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

View file

@ -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:
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
# 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:
# 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 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
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")
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}")

View file

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

View file

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

View file

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

View file

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

View file

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

37
tests/visual/README.md Normal file
View file

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

1
tests/visual/__init__.py Normal file
View file

@ -0,0 +1 @@
# tests/visual package

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

381
tests/visual/index.html Normal file
View file

@ -0,0 +1,381 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NodeSlicer - Path Operations Visual Test Suite</title>
<style>
:root {
--bg-color: #0f141c;
--card-bg: #1a2230;
--text-color: #e2e8f0;
--text-muted: #94a3b8;
--accent-color: #38bdf8;
--border-color: #334155;
--pass-color: #22c55e;
--fail-color: #ef4444;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
margin: 0;
padding: 24px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
header {
border-bottom: 1px solid var(--border-color);
padding-bottom: 16px;
margin-bottom: 24px;
}
h1 {
color: var(--accent-color);
margin: 0 0 8px 0;
}
p.subtitle {
color: var(--text-muted);
margin: 0;
font-size: 1.1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(650px, 1fr));
gap: 24px;
}
.test-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
display: flex;
flex-direction: column;
}
.test-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.test-header h3 {
margin: 0;
font-size: 1.2rem;
color: #f8fafc;
}
.badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: bold;
text-transform: uppercase;
}
.badge-pass {
background: rgba(34, 197, 94, 0.2);
color: var(--pass-color);
border: 1px solid var(--pass-color);
}
.badge-fail {
background: rgba(239, 68, 68, 0.2);
color: var(--fail-color);
border: 1px solid var(--fail-color);
}
.test-desc {
color: var(--text-muted);
margin: 0 0 12px 0;
font-size: 0.95rem;
}
.test-meta {
display: flex;
gap: 16px;
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 16px;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
code {
background: #0f172a;
color: var(--accent-color);
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
}
.test-image img {
width: 100%;
height: auto;
border-radius: 6px;
border: 1px solid var(--border-color);
transition: transform 0.2s;
}
.test-image img:hover {
transform: scale(1.01);
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>NodeSlicer - Path Operations Visual Test Suite</h1>
<p class="subtitle">Side-by-side verification gallery for Boolean Union, Multi-layer Minimum Coverage, Difference, XOR, Zip, and Extend operations.</p>
</header>
<div class="grid">
<div class="test-card">
<div class="test-header">
<h3>TC01: Maximum Coverage: Overlapping Squares</h3>
<span class="badge badge-pass">PASS</span>
</div>
<p class="test-desc">Calculates the boolean union of two overlapping polygons into a single continuous boundary.</p>
<div class="test-meta">
<span><strong>Operation:</strong> <code>MAX_COVERAGE</code></span>
<span><strong>Inputs:</strong> 2</span>
<span><strong>Output Paths:</strong> 1</span>
</div>
<div class="test-image">
<a href="images/01_max_coverage_overlapping_squares.png" target="_blank">
<img src="images/01_max_coverage_overlapping_squares.png" alt="Maximum Coverage: Overlapping Squares" loading="lazy" />
</a>
</div>
</div>
<div class="test-card">
<div class="test-header">
<h3>TC02: Maximum Coverage: Donut with Hole + Solid Box</h3>
<span class="badge badge-pass">PASS</span>
</div>
<p class="test-desc">Correctly preserves hole topology during union, partially clipping the interior hole.</p>
<div class="test-meta">
<span><strong>Operation:</strong> <code>MAX_COVERAGE</code></span>
<span><strong>Inputs:</strong> 2</span>
<span><strong>Output Paths:</strong> 2</span>
</div>
<div class="test-image">
<a href="images/02_max_coverage_donut_and_box.png" target="_blank">
<img src="images/02_max_coverage_donut_and_box.png" alt="Maximum Coverage: Donut with Hole + Solid Box" loading="lazy" />
</a>
</div>
</div>
<div class="test-card">
<div class="test-header">
<h3>TC03: Minimum Coverage: Multi-Layer Staggered Objects</h3>
<span class="badge badge-pass">PASS</span>
</div>
<p class="test-desc">Verifies that empty paths on layers where only 1 object exists do NOT zero out the layer, while overlapping layers are properly intersected.</p>
<div class="test-meta">
<span><strong>Operation:</strong> <code>MIN_COVERAGE</code></span>
<span><strong>Inputs:</strong> 2</span>
<span><strong>Output Paths:</strong> 3</span>
</div>
<div class="test-image">
<a href="images/03_min_coverage_staggered_layers.png" target="_blank">
<img src="images/03_min_coverage_staggered_layers.png" alt="Minimum Coverage: Multi-Layer Staggered Objects" loading="lazy" />
</a>
</div>
</div>
<div class="test-card">
<div class="test-header">
<h3>TC04: Minimum Coverage: Two Overlapping Squares</h3>
<span class="badge badge-pass">PASS</span>
</div>
<p class="test-desc">Calculates the intersection region [10, 20] x [10, 20] between two active inputs.</p>
<div class="test-meta">
<span><strong>Operation:</strong> <code>MIN_COVERAGE</code></span>
<span><strong>Inputs:</strong> 2</span>
<span><strong>Output Paths:</strong> 1</span>
</div>
<div class="test-image">
<a href="images/04_min_coverage_overlapping_squares.png" target="_blank">
<img src="images/04_min_coverage_overlapping_squares.png" alt="Minimum Coverage: Two Overlapping Squares" loading="lazy" />
</a>
</div>
</div>
<div class="test-card">
<div class="test-header">
<h3>TC05: Minimum Coverage: Single Object with Multiple Islands</h3>
<span class="badge badge-pass">PASS</span>
</div>
<p class="test-desc">Ensures disjoint shapes within a single object are preserved intact rather than annihilated by self-intersection.</p>
<div class="test-meta">
<span><strong>Operation:</strong> <code>MIN_COVERAGE</code></span>
<span><strong>Inputs:</strong> 1</span>
<span><strong>Output Paths:</strong> 2</span>
</div>
<div class="test-image">
<a href="images/05_min_coverage_single_object_islands.png" target="_blank">
<img src="images/05_min_coverage_single_object_islands.png" alt="Minimum Coverage: Single Object with Multiple Islands" loading="lazy" />
</a>
</div>
</div>
<div class="test-card">
<div class="test-header">
<h3>TC06: Boolean Intersection: Multi-Layer Staggered</h3>
<span class="badge badge-pass">PASS</span>
</div>
<p class="test-desc">Standard intersection requires all operands to be present on each layer, producing output only on layer 2.</p>
<div class="test-meta">
<span><strong>Operation:</strong> <code>INTERSECTION</code></span>
<span><strong>Inputs:</strong> 2</span>
<span><strong>Output Paths:</strong> 1</span>
</div>
<div class="test-image">
<a href="images/06_intersection_staggered_layers.png" target="_blank">
<img src="images/06_intersection_staggered_layers.png" alt="Boolean Intersection: Multi-Layer Staggered" loading="lazy" />
</a>
</div>
</div>
<div class="test-card">
<div class="test-header">
<h3>TC07: Subtract: Difference Creating Interior Hole</h3>
<span class="badge badge-pass">PASS</span>
</div>
<p class="test-desc">Performs geometric subtraction producing outer boundary + interior hole contour.</p>
<div class="test-meta">
<span><strong>Operation:</strong> <code>SUBTRACT</code></span>
<span><strong>Inputs:</strong> 2</span>
<span><strong>Output Paths:</strong> 2</span>
</div>
<div class="test-image">
<a href="images/07_subtract_hole_creation.png" target="_blank">
<img src="images/07_subtract_hole_creation.png" alt="Subtract: Difference Creating Interior Hole" loading="lazy" />
</a>
</div>
</div>
<div class="test-card">
<div class="test-header">
<h3>TC08: Exclusive Or (XOR): Symmetric Difference</h3>
<span class="badge badge-pass">PASS</span>
</div>
<p class="test-desc">Calculates symmetric difference, removing the overlapping intersection.</p>
<div class="test-meta">
<span><strong>Operation:</strong> <code>XOR</code></span>
<span><strong>Inputs:</strong> 2</span>
<span><strong>Output Paths:</strong> 2</span>
</div>
<div class="test-image">
<a href="images/08_xor_overlapping_squares.png" target="_blank">
<img src="images/08_xor_overlapping_squares.png" alt="Exclusive Or (XOR): Symmetric Difference" loading="lazy" />
</a>
</div>
</div>
<div class="test-card">
<div class="test-header">
<h3>TC09: Zip Paths: Layer-by-Layer Interleaving</h3>
<span class="badge badge-pass">PASS</span>
</div>
<p class="test-desc">Interleaves toolpath streams layer-by-layer conserving metadata and extrusion attributes.</p>
<div class="test-meta">
<span><strong>Operation:</strong> <code>ZIP</code></span>
<span><strong>Inputs:</strong> 2</span>
<span><strong>Output Paths:</strong> 4</span>
</div>
<div class="test-image">
<a href="images/09_zip_paths_layer_interleaving.png" target="_blank">
<img src="images/09_zip_paths_layer_interleaving.png" alt="Zip Paths: Layer-by-Layer Interleaving" loading="lazy" />
</a>
</div>
</div>
<div class="test-card">
<div class="test-header">
<h3>TC10: Extend Paths: Sequential Concatenation</h3>
<span class="badge badge-pass">PASS</span>
</div>
<p class="test-desc">Concatenates multiple toolpath streams sequentially preserving all group IDs and attributes.</p>
<div class="test-meta">
<span><strong>Operation:</strong> <code>EXTEND</code></span>
<span><strong>Inputs:</strong> 2</span>
<span><strong>Output Paths:</strong> 4</span>
</div>
<div class="test-image">
<a href="images/10_extend_paths_sequential.png" target="_blank">
<img src="images/10_extend_paths_sequential.png" alt="Extend Paths: Sequential Concatenation" loading="lazy" />
</a>
</div>
</div>
<div class="test-card">
<div class="test-header">
<h3>TC11: Maximum Coverage: Concentric Perimeters (2 Inputs)</h3>
<span class="badge badge-pass">PASS</span>
</div>
<p class="test-desc">Calculates boolean union across concentric shells yielding the outsidemost perimeter boundary.</p>
<div class="test-meta">
<span><strong>Operation:</strong> <code>MAX_COVERAGE</code></span>
<span><strong>Inputs:</strong> 2</span>
<span><strong>Output Paths:</strong> 1</span>
</div>
<div class="test-image">
<a href="images/11_max_coverage_concentric_perimeters.png" target="_blank">
<img src="images/11_max_coverage_concentric_perimeters.png" alt="Maximum Coverage: Concentric Perimeters (2 Inputs)" loading="lazy" />
</a>
</div>
</div>
<div class="test-card">
<div class="test-header">
<h3>TC12: Minimum Coverage: Concentric Perimeters (2 Inputs)</h3>
<span class="badge badge-pass">PASS</span>
</div>
<p class="test-desc">Calculates boolean intersection across concentric shells yielding the insidemost perimeter boundary.</p>
<div class="test-meta">
<span><strong>Operation:</strong> <code>MIN_COVERAGE</code></span>
<span><strong>Inputs:</strong> 2</span>
<span><strong>Output Paths:</strong> 1</span>
</div>
<div class="test-image">
<a href="images/12_min_coverage_concentric_perimeters.png" target="_blank">
<img src="images/12_min_coverage_concentric_perimeters.png" alt="Minimum Coverage: Concentric Perimeters (2 Inputs)" loading="lazy" />
</a>
</div>
</div>
<div class="test-card">
<div class="test-header">
<h3>TC13: Maximum Coverage: Single Input with Concentric Shells</h3>
<span class="badge badge-pass">PASS</span>
</div>
<p class="test-desc">Unions concentric perimeter loops within a single input into the outermost boundary.</p>
<div class="test-meta">
<span><strong>Operation:</strong> <code>MAX_COVERAGE</code></span>
<span><strong>Inputs:</strong> 1</span>
<span><strong>Output Paths:</strong> 1</span>
</div>
<div class="test-image">
<a href="images/13_max_coverage_single_input_perimeters.png" target="_blank">
<img src="images/13_max_coverage_single_input_perimeters.png" alt="Maximum Coverage: Single Input with Concentric Shells" loading="lazy" />
</a>
</div>
</div>
<div class="test-card">
<div class="test-header">
<h3>TC14: Minimum Coverage: Single Input with Concentric Shells</h3>
<span class="badge badge-pass">PASS</span>
</div>
<p class="test-desc">Intersects concentric perimeter loops within a single input into the innermost boundary.</p>
<div class="test-meta">
<span><strong>Operation:</strong> <code>MIN_COVERAGE</code></span>
<span><strong>Inputs:</strong> 1</span>
<span><strong>Output Paths:</strong> 1</span>
</div>
<div class="test-image">
<a href="images/14_min_coverage_single_input_perimeters.png" target="_blank">
<img src="images/14_min_coverage_single_input_perimeters.png" alt="Minimum Coverage: Single Input with Concentric Shells" loading="lazy" />
</a>
</div>
</div>
</div>
</div>
</body>
</html>

View file

@ -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"""
<div class="test-card">
<div class="test-header">
<h3>{r['id']}: {r['title']}</h3>
<span class="badge {badge_class}">{r['status']}</span>
</div>
<p class="test-desc">{r['description']}</p>
<div class="test-meta">
<span><strong>Operation:</strong> <code>{r['operation']}</code></span>
<span><strong>Inputs:</strong> {r['input_count']}</span>
<span><strong>Output Paths:</strong> {r['output_paths']}</span>
</div>
<div class="test-image">
<a href="images/{r['image']}" target="_blank">
<img src="images/{r['image']}" alt="{r['title']}" loading="lazy" />
</a>
</div>
</div>
"""
html_content = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NodeSlicer - Path Operations Visual Test Suite</title>
<style>
:root {{
--bg-color: #0f141c;
--card-bg: #1a2230;
--text-color: #e2e8f0;
--text-muted: #94a3b8;
--accent-color: #38bdf8;
--border-color: #334155;
--pass-color: #22c55e;
--fail-color: #ef4444;
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
margin: 0;
padding: 24px;
}}
.container {{
max-width: 1400px;
margin: 0 auto;
}}
header {{
border-bottom: 1px solid var(--border-color);
padding-bottom: 16px;
margin-bottom: 24px;
}}
h1 {{
color: var(--accent-color);
margin: 0 0 8px 0;
}}
p.subtitle {{
color: var(--text-muted);
margin: 0;
font-size: 1.1rem;
}}
.grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(650px, 1fr));
gap: 24px;
}}
.test-card {{
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
display: flex;
flex-direction: column;
}}
.test-header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}}
.test-header h3 {{
margin: 0;
font-size: 1.2rem;
color: #f8fafc;
}}
.badge {{
padding: 4px 10px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: bold;
text-transform: uppercase;
}}
.badge-pass {{
background: rgba(34, 197, 94, 0.2);
color: var(--pass-color);
border: 1px solid var(--pass-color);
}}
.badge-fail {{
background: rgba(239, 68, 68, 0.2);
color: var(--fail-color);
border: 1px solid var(--fail-color);
}}
.test-desc {{
color: var(--text-muted);
margin: 0 0 12px 0;
font-size: 0.95rem;
}}
.test-meta {{
display: flex;
gap: 16px;
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 16px;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}}
code {{
background: #0f172a;
color: var(--accent-color);
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
}}
.test-image img {{
width: 100%;
height: auto;
border-radius: 6px;
border: 1px solid var(--border-color);
transition: transform 0.2s;
}}
.test-image img:hover {{
transform: scale(1.01);
}}
</style>
</head>
<body>
<div class="container">
<header>
<h1>NodeSlicer - Path Operations Visual Test Suite</h1>
<p class="subtitle">Side-by-side verification gallery for Boolean Union, Multi-layer Minimum Coverage, Difference, XOR, Zip, and Extend operations.</p>
</header>
<div class="grid">
{rows_html}
</div>
</div>
</body>
</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}")