transforms and path operations
|
|
@ -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]),
|
||||
}]
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
206
core/slice.py
|
|
@ -9,7 +9,7 @@ from shapely.geometry import Polygon, MultiPolygon, LineString, LinearRing, Mult
|
|||
|
||||
from ..infra.blender.blender_mesh_capture import PdfProperties
|
||||
|
||||
from .geometry import ndarray_lru_cache
|
||||
from .geometry import ndarray_lru_cache, update_transform
|
||||
|
||||
# Scaling factor for 1 micron unit integer math (1 mm = 1000 microns)
|
||||
SCALE = 1000
|
||||
|
|
@ -226,21 +226,13 @@ class SlicePaths(list[np.ndarray]):
|
|||
def offset(self, offset_vec: np.ndarray) -> SlicePaths:
|
||||
"""Returns a new SlicePaths object shifted by the given vector."""
|
||||
res = self.copy_with([p + offset_vec for p in self])
|
||||
res.transforms = [dict(t) for t in getattr(self, "transforms", [])]
|
||||
res.transforms.append({
|
||||
"type": "offset",
|
||||
"offset": offset_vec.tolist() if isinstance(offset_vec, np.ndarray) else list(offset_vec)
|
||||
})
|
||||
res.transforms = update_transform(getattr(self, "transforms", []), t_arr=offset_vec)
|
||||
return res
|
||||
|
||||
def transform(self, matrix: np.ndarray) -> SlicePaths:
|
||||
"""Applies a transformation matrix to all paths."""
|
||||
res = self.copy_with([p @ matrix.T for p in self])
|
||||
res.transforms = [dict(t) for t in getattr(self, "transforms", [])]
|
||||
res.transforms.append({
|
||||
"type": "transform",
|
||||
"matrix": matrix.tolist() if isinstance(matrix, np.ndarray) else list(matrix)
|
||||
})
|
||||
return res
|
||||
|
||||
def buffer(self, distance: float, miter: str = 'round', rotation: Any = None) -> SlicePaths:
|
||||
|
|
@ -339,15 +331,11 @@ class SlicePaths(list[np.ndarray]):
|
|||
base = primary_source if primary_source is not None else next((sp for sp in paths_list if len(sp) > 0), paths_list[0])
|
||||
merged = base.copy_with([])
|
||||
|
||||
merged_transforms = []
|
||||
for sp in paths_list:
|
||||
if not sp:
|
||||
continue
|
||||
merged.extend_with_groups(sp)
|
||||
for t in getattr(sp, "transforms", []):
|
||||
if t not in merged_transforms:
|
||||
merged_transforms.append(t)
|
||||
merged.transforms = merged_transforms
|
||||
merged.transforms = [dict(t) for t in getattr(base, "transforms", [])]
|
||||
|
||||
return merged
|
||||
|
||||
|
|
@ -384,7 +372,6 @@ class SlicePaths(list[np.ndarray]):
|
|||
for path, ext, feed, extra in items:
|
||||
merged.append_with_group(path, group=g, extrusion_rate=ext, feedrate=feed, extra_attributes=extra)
|
||||
|
||||
merged_transforms = []
|
||||
merged_names = []
|
||||
merged_objs = []
|
||||
for sp in non_empty:
|
||||
|
|
@ -402,11 +389,7 @@ class SlicePaths(list[np.ndarray]):
|
|||
merged_names.append(n_str)
|
||||
merged_objs.append({"name": n_str, "polygon": []})
|
||||
|
||||
for t in getattr(sp, "transforms", []):
|
||||
if t not in merged_transforms:
|
||||
merged_transforms.append(t)
|
||||
|
||||
merged.transforms = merged_transforms
|
||||
merged.transforms = [dict(t) for t in getattr(base, "transforms", [])]
|
||||
if merged_objs:
|
||||
merged.objects_info = {"objects": merged_objs}
|
||||
merged.name = "-".join(merged_names)
|
||||
|
|
@ -466,12 +449,12 @@ class SlicePaths(list[np.ndarray]):
|
|||
new_paths.append(new_p)
|
||||
|
||||
res = self.copy_with(new_paths)
|
||||
res.transforms = [dict(t) for t in getattr(self, "transforms", [])]
|
||||
res.transforms.append({
|
||||
"type": "rotate",
|
||||
"angle_deg": float(angle_deg),
|
||||
"center": center.tolist() if isinstance(center, np.ndarray) else list(center)
|
||||
})
|
||||
r_arr = np.array([0.0, 0.0, float(angle_rad)], dtype=np.float64)
|
||||
res.transforms = update_transform(getattr(self, "transforms", []), r_arr=r_arr)
|
||||
if res.transforms:
|
||||
res.transforms[0]["angle_deg"] = float(angle_deg)
|
||||
if center is not None:
|
||||
res.transforms[0]["center"] = center.tolist() if isinstance(center, np.ndarray) else list(center)
|
||||
return res
|
||||
|
||||
def get_first_layer_midpoint(self) -> np.ndarray:
|
||||
|
|
@ -1245,11 +1228,9 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI
|
|||
merged_names.append(n_str)
|
||||
merged_objs.append({"name": n_str, "polygon": []})
|
||||
|
||||
for t in getattr(sp, "transforms", []):
|
||||
if t not in merged_transforms:
|
||||
merged_transforms.append(dict(t))
|
||||
|
||||
results.transforms = merged_transforms
|
||||
subj_trans = getattr(subject, "transforms", None)
|
||||
obj_trans = getattr(object, "transforms", None)
|
||||
results.transforms = [dict(t) for t in subj_trans] if subj_trans else ([dict(t) for t in obj_trans] if obj_trans else [])
|
||||
if merged_objs:
|
||||
results.objects_info = {"objects": merged_objs}
|
||||
results.name = "-".join(merged_names)
|
||||
|
|
@ -1262,21 +1243,19 @@ def minimum_coverage_slice(
|
|||
) -> SlicePaths:
|
||||
"""
|
||||
Computes the Minimum Coverage across multiple SlicePaths inputs (or within a single SlicePaths input).
|
||||
For each layer (grouped by integer micron Z), it calculates the intersection of all
|
||||
paths in that layer. If only one path (or single shape with holes) exists on a layer,
|
||||
it preserves that path/shape intact.
|
||||
For each layer (grouped by integer micron Z), it calculates the intersection of all objects having paths on that layer.
|
||||
In layers where some objects have no paths, the empty paths are ignored (so the result doesn't end up being zero for that layer).
|
||||
"""
|
||||
valid_paths = [sp for sp in paths_list if isinstance(sp, SlicePaths) and len(sp) > 0]
|
||||
if not valid_paths:
|
||||
return SlicePaths()
|
||||
|
||||
paths_list = valid_paths
|
||||
primary = paths_list[0]
|
||||
primary = valid_paths[0]
|
||||
|
||||
all_z_keys: set[int] = set()
|
||||
input_layer_maps: list[dict[int, list[np.ndarray]]] = []
|
||||
|
||||
for sp in paths_list:
|
||||
for sp in valid_paths:
|
||||
layer_map: dict[int, list[np.ndarray]] = {}
|
||||
for p in sp:
|
||||
if len(p) < 2:
|
||||
|
|
@ -1298,54 +1277,47 @@ def minimum_coverage_slice(
|
|||
continue
|
||||
|
||||
if len(active_indices) == 1:
|
||||
# Only one object has paths on this layer -> empty paths of other objects ignored
|
||||
idx = active_indices[0]
|
||||
layer_paths = input_layer_maps[idx][z_key]
|
||||
if len(layer_paths) <= 1:
|
||||
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}")
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
75
tests/test_visual_operations.py
Normal 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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
# tests/visual package
|
||||
BIN
tests/visual/images/01_max_coverage_overlapping_squares.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
tests/visual/images/02_max_coverage_donut_and_box.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
tests/visual/images/03_min_coverage_staggered_layers.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
tests/visual/images/04_min_coverage_overlapping_squares.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
tests/visual/images/05_min_coverage_single_object_islands.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
tests/visual/images/06_intersection_staggered_layers.png
Normal file
|
After Width: | Height: | Size: 211 KiB |
BIN
tests/visual/images/07_subtract_hole_creation.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
tests/visual/images/08_xor_overlapping_squares.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
tests/visual/images/09_zip_paths_layer_interleaving.png
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
tests/visual/images/10_extend_paths_sequential.png
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
tests/visual/images/11_max_coverage_concentric_perimeters.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
tests/visual/images/12_min_coverage_concentric_perimeters.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
tests/visual/images/13_max_coverage_single_input_perimeters.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
tests/visual/images/14_min_coverage_single_input_perimeters.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
381
tests/visual/index.html
Normal 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>
|
||||
830
tests/visual/visualize_path_operations.py
Normal 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}")
|
||||