From 02175f379a8b7656979e6fba61165300e8ad303e Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Tue, 4 Aug 2026 15:44:42 +0300 Subject: [PATCH] draw lines --- core/nodeslicer/nodes/mesh.py | 10 +- core/nodeslicer/nodes/output.py | 67 +++++++---- core/nodeslicer/sockets.py | 31 +++++ core/nodeslicer/tree.py | 7 ++ core/pdf_exporter/__init__.py | 2 + core/pdf_exporter/geometry.py | 39 ++++++- core/pdf_exporter/metadata.py | 80 +++++++++---- core/pdf_exporter/sa2_measures.py | 14 ++- core/pdf_exporter/writer.py | 79 ++++++++----- core/slice.py | 4 + doc/NODES.md | 8 +- tests/test_pdf_exporter.py | 183 +++++++++++++++++++++++++++++- 12 files changed, 448 insertions(+), 76 deletions(-) diff --git a/core/nodeslicer/nodes/mesh.py b/core/nodeslicer/nodes/mesh.py index 5a926cb..eb18b1d 100644 --- a/core/nodeslicer/nodes/mesh.py +++ b/core/nodeslicer/nodes/mesh.py @@ -4,7 +4,7 @@ import numpy as np from ..bpy_ns_types import NfBoolProperty, NfEnumProperty, NfIntProperty, NfFloatProperty, NfStringProperty, NfFloatVectorProperty, notify_tree_changed from ....registry import register_class -from .base import SliceNode, SlicingCollection, get_all_children, SliceNodeJoiner, SlicingGroup, SlicingObject +from .base import SliceNode, SlicingCollection, get_all_children, SliceNodeJoiner, SlicingGroup, SlicingObject, SlicePaths from ....infra.blender.blender_mesh_capture import PdfProperties @register_class @@ -677,6 +677,10 @@ class SLICENODE_RenameMesh(SliceNode): def _assign_pdf_properties_to_item(geom: Any, line_width: float, line_color: tuple[float, float, float], show_name: bool = False) -> Any: if geom is None: return None + elif isinstance(geom, SlicePaths): + copied = geom.copy_with(paths=list(geom)) + copied.pdf_properties = PdfProperties(line_width=line_width, line_color=line_color, show_name=show_name) + return copied elif isinstance(geom, (list, tuple)): return [_assign_pdf_properties_to_item(item, line_width, line_color, show_name) for item in geom] elif isinstance(geom, SlicingGroup): @@ -742,6 +746,10 @@ def _assign_properties_to_item(geom: Any, props: list[tuple[str, str]]) -> Any: from ....core.pdf_exporter.metadata import resolve_object_properties if geom is None: return None + elif isinstance(geom, SlicePaths): + copied = geom.copy_with(paths=list(geom)) + copied.object_properties = resolve_object_properties(copied, props) + return copied elif isinstance(geom, (list, tuple)): return [_assign_properties_to_item(item, props) for item in geom] elif isinstance(geom, SlicingGroup): diff --git a/core/nodeslicer/nodes/output.py b/core/nodeslicer/nodes/output.py index a98f0bb..c07840b 100644 --- a/core/nodeslicer/nodes/output.py +++ b/core/nodeslicer/nodes/output.py @@ -6,6 +6,7 @@ import bpy import tempfile import re import json +import numpy as np from ..bpy_ns_types import NfStringProperty, NfFloatProperty, NfEnumProperty, NfFloatVectorProperty, NfBoolProperty from ....registry import register_class @@ -463,17 +464,24 @@ class SLICENODE_MeshToPdf(ExecSliceNode): from ...pdf_exporter import extract_unique_top_view_edges, export_mesh_to_pdf geom = self.resolve("Geometry") - slice_paths = self.resolve("Paths") # Currently unused + slice_paths = self.resolve("Paths") or self.resolve("SlicePaths") folder: str = self.resolve("Folder") or "" - settings = self.resolve("Settings") - if geom is None: - if operator: operator.report({'ERROR'}, "No Geometry connected") - return 'error' + # Collect raw geometry items + raw_geom_items: list[Any] = [] + if geom is not None: + raw_list = geom if isinstance(geom, list) else [geom] + raw_geom_items = [g for g in raw_list if g is not None] - geometries: list[Any] = [g for g in (geom if isinstance(geom, list) else [geom]) if g is not None] - if not geometries: - if operator: operator.report({'ERROR'}, "No Geometry connected") + # Collect slice path items + slice_paths_items: list[Any] = [] + if slice_paths is not None: + is_sp_inst = isinstance(slice_paths, SlicePaths) + raw_list = slice_paths if (isinstance(slice_paths, list) and not is_sp_inst) else [slice_paths] + slice_paths_items = [p for p in raw_list if p is not None] + + if not raw_geom_items and not slice_paths_items: + if operator: operator.report({'ERROR'}, "No Geometry or Paths connected") return 'error' if not folder: @@ -490,26 +498,29 @@ class SLICENODE_MeshToPdf(ExecSliceNode): pass folder_path = Path(abs_folder) - is_single_pdf_file = len(geometries) == 1 and folder_path.suffix.lower() == ".pdf" + has_raw_geom = len(raw_geom_items) > 0 + primary_items = raw_geom_items if has_raw_geom else slice_paths_items + + is_single_pdf_file = len(primary_items) == 1 and folder_path.suffix.lower() == ".pdf" if is_single_pdf_file: os.makedirs(folder_path.parent, exist_ok=True) - target_list = [(geometries[0], str(folder_path))] + target_list = [(primary_items[0], str(folder_path))] else: out_dir = folder_path if folder_path.suffix == "" else folder_path.parent os.makedirs(out_dir, exist_ok=True) target_list = [] - for i, geom in enumerate(geometries): - if isinstance(geom, SlicingGroup) and geom.collections: - raw_name = "_".join(geom.collections.keys()) - elif hasattr(geom, 'name') and getattr(geom, 'name'): - raw_name = str(getattr(geom, 'name')) + for i, target_item in enumerate(primary_items): + if isinstance(target_item, SlicingGroup) and target_item.collections: + raw_name = "_".join(target_item.collections.keys()) + elif hasattr(target_item, 'name') and getattr(target_item, 'name'): + raw_name = str(getattr(target_item, 'name')) else: raw_name = f"object_{i}" name_part = raw_name.split('.')[0] if raw_name else f"object_{i}" filename = f"{name_part}.pdf" - target_list.append((geom, str(out_dir / filename))) + target_list.append((target_item, str(out_dir / filename))) # Automatic scene scale determination scale_str = "1:1" @@ -537,8 +548,24 @@ class SLICENODE_MeshToPdf(ExecSliceNode): "scale": scale_str, } - for geom, filepath in target_list: - edges_2d = extract_unique_top_view_edges(geom) + for target_item, filepath in target_list: + if has_raw_geom: + current_raw_geom = target_item + current_slice_paths = slice_paths + else: + current_raw_geom = None + current_slice_paths = target_item + + edges_2d = np.empty((0, 2, 2), dtype=np.float64) + if current_raw_geom is not None: + raw_e = extract_unique_top_view_edges(current_raw_geom) + if len(raw_e) > 0: + edges_2d = raw_e + if current_slice_paths is not None: + sp_e = extract_unique_top_view_edges(current_slice_paths) + if len(sp_e) > 0: + edges_2d = np.concatenate([edges_2d, sp_e], axis=0) if len(edges_2d) > 0 else sp_e + if len(edges_2d) == 0: if operator: operator.report({'WARNING'}, f"Geometry for {os.path.basename(filepath)} has no valid 2D top-view wireframe edges") @@ -560,8 +587,8 @@ class SLICENODE_MeshToPdf(ExecSliceNode): stroke_color=(float(self.wire_color[0]), float(self.wire_color[1]), float(self.wire_color[2])), bg_color=(float(self.bg_color[0]), float(self.bg_color[1]), float(self.bg_color[2])), bbox_color=(float(self.bbox_color[0]), float(self.bbox_color[1]), float(self.bbox_color[2])), - raw_geometry=geom, - settings=settings + raw_geometry=current_raw_geom, + slice_paths=current_slice_paths, ) if operator: operator.report({'INFO'}, f"Mesh PDF saved to {filepath}") except Exception as e: diff --git a/core/nodeslicer/sockets.py b/core/nodeslicer/sockets.py index e5f5245..f86f21b 100644 --- a/core/nodeslicer/sockets.py +++ b/core/nodeslicer/sockets.py @@ -140,6 +140,37 @@ class SLICESOCK_Paths(LabelNodeSocket): default = SlicePaths() bl_color = (214/255, 0, 120/255, 1.0) +@register_class +class SLICESOCK_Geometry(LabelNodeSocket): + bl_label = "Geometry" + default = None + bl_color = (0.0, 0.75, 0.75, 1.0) + + def draw_color(self, context, node) -> tuple[float, float, float, float]: + def _get_upstream_color(sock: Any) -> tuple[float, float, float, float]: + if not sock or not getattr(sock, "is_linked", False) or not getattr(sock, "links", None): + return self.bl_color + from_sock = sock.links[0].from_socket + if hasattr(from_sock, "draw_color"): + try: + from_node = getattr(from_sock, "node", None) + return from_sock.draw_color(context, from_node) + except Exception: + pass + return getattr(from_sock, "bl_color", self.bl_color) + + if self.is_output: + if node and hasattr(node, "inputs"): + sock_name = getattr(self, "name", "") + matching_in = (node.inputs.get(sock_name) if sock_name else None) or node.inputs.get("Geometry") + if matching_in: + return _get_upstream_color(matching_in) + return self.bl_color + else: + return _get_upstream_color(self) + + + class SLICESOCK_Gcode_Common(InputNodeSocket): bl_label = "Gcode" value: NfStringProperty(name="Gcode", default="") # pyright: ignore[reportInvalidTypeForm] diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index 63808aa..17ab704 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -155,6 +155,13 @@ class SLICETREE_NodeTree(NodeTree): bl_group_idname = "SLICENODE_Group" bl_use_group_interface = True + def valid_link(self, from_socket: Any, to_socket: Any) -> bool: + geom_sockets = {"SLICESOCK_Mesh", "SLICESOCK_Paths", "SLICESOCK_Geometry"} + if from_socket.bl_idname in geom_sockets and to_socket.bl_idname in geom_sockets: + return True + return from_socket.bl_idname == to_socket.bl_idname + + @register_class class SLICENODE_OT_GroupToggle(Operator): bl_idname = "slicenode.group_toggle" diff --git a/core/pdf_exporter/__init__.py b/core/pdf_exporter/__init__.py index 720f19e..a015a7e 100644 --- a/core/pdf_exporter/__init__.py +++ b/core/pdf_exporter/__init__.py @@ -12,6 +12,7 @@ Modular structure: from .geometry import ( deduplicate_2d_edges, extract_unique_top_view_edges, + extract_slice_paths_edges, extract_lineart_edges, extract_boundary_edges, extract_outer_contour_edges, @@ -85,6 +86,7 @@ __all__ = [ # Geometry "deduplicate_2d_edges", "extract_unique_top_view_edges", + "extract_slice_paths_edges", "extract_lineart_edges", "extract_boundary_edges", "extract_outer_contour_edges", diff --git a/core/pdf_exporter/geometry.py b/core/pdf_exporter/geometry.py index 1e0848a..02d58c4 100644 --- a/core/pdf_exporter/geometry.py +++ b/core/pdf_exporter/geometry.py @@ -31,17 +31,54 @@ def deduplicate_2d_edges(edges_2d: NDArray[np.float64]) -> NDArray[np.float64]: return edges_2d[unique_indices] +def extract_slice_paths_edges(slice_paths: Any) -> NDArray[np.float64]: + """ + Extracts 2D line segments (shape N x 2 x 2) from SlicePaths or a list/collection of SlicePaths. + """ + from ..slice import SlicePaths + + if slice_paths is None: + return np.empty((0, 2, 2), dtype=np.float64) + + all_segments: list[np.ndarray] = [] + + def process_item(item: Any): + if item is None: + return + elif isinstance(item, SlicePaths): + for path in item: + if path is not None and len(path) >= 2: + pts = np.asarray(path, dtype=np.float64)[:, :2] + segs = np.stack([pts[:-1], pts[1:]], axis=1) + all_segments.append(segs) + elif isinstance(item, (list, tuple)): + for sub in item: + process_item(sub) + + process_item(slice_paths) + + if not all_segments: + return np.empty((0, 2, 2), dtype=np.float64) + + return np.concatenate(all_segments, axis=0) + + def extract_unique_top_view_edges(mesh_data: Any) -> NDArray[np.float64]: """ - Extracts 2D top-view unique edges (X, Y projection) from mesh_data. + Extracts 2D top-view unique edges (X, Y projection) from mesh_data (SlicingObjects or SlicePaths). """ from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject + from ..slice import SlicePaths all_edge_segments: list[NDArray[np.float64]] = [] def process_item(item: Any): if item is None: return + elif isinstance(item, SlicePaths): + sp_edges = extract_slice_paths_edges(item) + if len(sp_edges) > 0: + all_edge_segments.append(sp_edges) elif isinstance(item, (list, tuple)): for sub in item: process_item(sub) diff --git a/core/pdf_exporter/metadata.py b/core/pdf_exporter/metadata.py index 2dd1953..046f350 100644 --- a/core/pdf_exporter/metadata.py +++ b/core/pdf_exporter/metadata.py @@ -9,29 +9,54 @@ from numpy.typing import NDArray def get_first_attribute_value(so_item: Any, attr_name: str) -> Any: """ - Extracts scalar attribute value for a SlicingObject. + Extracts scalar attribute value for a SlicingObject or SlicePaths. """ - if not hasattr(so_item, 'attributes') or not isinstance(so_item.attributes, dict): - return None + if hasattr(so_item, 'extra_attributes') and isinstance(so_item.extra_attributes, dict): + if attr_name in so_item.extra_attributes: + vals = so_item.extra_attributes[attr_name] + if isinstance(vals, (list, tuple, np.ndarray)) and len(vals) > 0: + val = vals[0] + if hasattr(val, 'item'): + return val.item() + return val + elif vals is not None: + return vals - if attr_name not in so_item.attributes: - return None + if hasattr(so_item, 'attributes') and isinstance(so_item.attributes, dict): + if attr_name in so_item.attributes: + attr_info = so_item.attributes[attr_name] + if isinstance(attr_info, dict) and 'values' in attr_info: + vals = attr_info['values'] + elif isinstance(attr_info, (list, tuple, np.ndarray)): + vals = attr_info + else: + return attr_info - attr_info = so_item.attributes[attr_name] - if isinstance(attr_info, dict) and 'values' in attr_info: - vals = attr_info['values'] - elif isinstance(attr_info, (list, tuple, np.ndarray)): - vals = attr_info - else: - return attr_info + if len(vals) > 0: + val = vals[0] + if hasattr(val, 'item'): + return val.item() + return val - if len(vals) == 0: - return None + attr_key = attr_name.lower().replace(" ", "_") + if hasattr(so_item, attr_key): + val = getattr(so_item, attr_key) + if val is not None and not callable(val): + if isinstance(val, (list, tuple, np.ndarray)) and len(val) > 0: + val = val[0] + if hasattr(val, 'item'): + return val.item() + return val + elif hasattr(so_item, attr_name): + val = getattr(so_item, attr_name) + if val is not None and not callable(val): + if isinstance(val, (list, tuple, np.ndarray)) and len(val) > 0: + val = val[0] + if hasattr(val, 'item'): + return val.item() + return val - val = vals[0] - if hasattr(val, 'item'): - return val.item() - return val + return None def resolve_object_properties(so_item: Any, props: list[tuple[str, str]]) -> list[tuple[str, str]]: @@ -79,15 +104,32 @@ def extract_settings_items(settings: Any) -> list[tuple[str, str]]: def extract_object_label_info(mesh_data: Any) -> list[tuple[Any, str, float, float, float, float]]: """ - Extracts (slicing_object, object_name, max_x_mm, y_at_max_x_mm, width_mm, height_mm) for each SlicingObject in mesh_data. + Extracts (slicing_object, object_name, max_x_mm, y_at_max_x_mm, width_mm, height_mm) for each SlicingObject or SlicePaths in mesh_data. """ from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject + from ..slice import SlicePaths results: list[tuple[Any, str, float, float, float, float]] = [] def process_item(item: Any, fallback_name: str = ""): if item is None: return + elif isinstance(item, SlicePaths): + all_pts = [p[:, :2] for p in item if p is not None and len(p) > 0] + if all_pts: + verts_2d = np.concatenate(all_pts, axis=0) + if len(verts_2d) > 0: + max_idx = np.argmax(verts_2d[:, 0]) + max_x = float(verts_2d[max_idx, 0]) + min_x = float(verts_2d[:, 0].min()) + y_val = float(verts_2d[max_idx, 1]) + min_y = float(verts_2d[:, 1].min()) + max_y = float(verts_2d[:, 1].max()) + + w_mm = max_x - min_x + h_mm = max_y - min_y + name = getattr(item, "name", "") or fallback_name or "SlicePaths" + results.append((item, name, max_x, y_val, w_mm, h_mm)) elif isinstance(item, (list, tuple)): for i, sub in enumerate(item): process_item(sub, f"{fallback_name}_{i}" if fallback_name else f"object_{i}") diff --git a/core/pdf_exporter/sa2_measures.py b/core/pdf_exporter/sa2_measures.py index 499a13a..d7de640 100644 --- a/core/pdf_exporter/sa2_measures.py +++ b/core/pdf_exporter/sa2_measures.py @@ -48,15 +48,27 @@ def extract_slicing_objects_bounds( ) -> list[tuple[Any, str, float, float, float, float]]: """ Extracts (slicing_object, object_name, min_x_mm, max_x_mm, min_y_mm, max_y_mm) - for each SlicingObject in mesh_data. + for each SlicingObject or SlicePaths in mesh_data. """ from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject + from ..slice import SlicePaths results: list[tuple[Any, str, float, float, float, float]] = [] def process_item(item: Any, fallback_name: str = ""): if item is None: return + elif isinstance(item, SlicePaths): + all_pts = [p[:, :2] for p in item if p is not None and len(p) > 0] + if all_pts: + verts_2d = np.concatenate(all_pts, axis=0) + if len(verts_2d) > 0: + min_x = float(verts_2d[:, 0].min()) + max_x = float(verts_2d[:, 0].max()) + min_y = float(verts_2d[:, 1].min()) + max_y = float(verts_2d[:, 1].max()) + name = getattr(item, "name", "") or fallback_name or "SlicePaths" + results.append((item, name, min_x, max_x, min_y, max_y)) elif isinstance(item, (list, tuple)): for i, sub in enumerate(item): process_item(sub, f"{fallback_name}_{i}" if fallback_name else f"object_{i}") diff --git a/core/pdf_exporter/writer.py b/core/pdf_exporter/writer.py index 90071a2..4e34492 100644 --- a/core/pdf_exporter/writer.py +++ b/core/pdf_exporter/writer.py @@ -22,6 +22,7 @@ from .geometry import ( extract_boundary_edges, extract_outer_contour_edges, extract_crease_edges, + extract_slice_paths_edges, deduplicate_2d_edges, ) from .sa2_measures import render_sa2_measures_pdf @@ -165,7 +166,7 @@ def render_object_properties_table( """ objs_with_props = [ so for so in slicing_objs - if hasattr(so, "object_properties") and so.object_properties + if getattr(so, "object_properties", None) is not None and len(getattr(so, "object_properties", [])) > 0 ] if not objs_with_props: return @@ -285,10 +286,15 @@ def render_object_properties_table( def get_all_slicing_objects(mesh_data: Any) -> list[Any]: from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject + from ..slice import SlicePaths + objects = [] + def process_item(item: Any): if item is None: return + elif isinstance(item, (SlicingObject, SlicePaths)): + objects.append(item) elif isinstance(item, (list, tuple)): for sub in item: process_item(sub) @@ -298,8 +304,7 @@ def get_all_slicing_objects(mesh_data: Any) -> list[Any]: elif isinstance(item, SlicingCollection): for so in item.objects: process_item(so) - elif isinstance(item, SlicingObject): - objects.append(item) + process_item(mesh_data) return objects @@ -349,30 +354,36 @@ class PDFGridElement(PDFElement): class PDFGeometryElement(PDFElement): """ - Renders 2D vector geometry (wireframe, boundary, contour, crease lines). + Renders 2D vector geometry (wireframe, boundary, contour, crease lines, and slice paths). """ def render(self, s: PdfStream) -> None: exp = self.exporter slicing_objs = exp.slicing_objs + from ..slice import SlicePaths if slicing_objs: for so in slicing_objs: so_edge_list: list[NDArray[np.float64]] = [] - if exp.draw_wireframe: - so_w = extract_unique_top_view_edges(so) - if len(so_w) > 0: - so_edge_list.append(so_w) - if exp.draw_boundary and not exp.draw_crease: - so_b = extract_boundary_edges(so) - if len(so_b) > 0: - so_edge_list.append(so_b) - if exp.draw_contour: - so_c = extract_outer_contour_edges(so) - if len(so_c) > 0: - so_edge_list.append(so_c) - if exp.draw_crease: - so_cr = extract_crease_edges(so, max_angle_deg=exp.crease_angle) - if len(so_cr) > 0: - so_edge_list.append(so_cr) + if isinstance(so, SlicePaths): + sp_edges = extract_slice_paths_edges(so) + if len(sp_edges) > 0: + so_edge_list.append(sp_edges) + else: + if exp.draw_wireframe: + so_w = extract_unique_top_view_edges(so) + if len(so_w) > 0: + so_edge_list.append(so_w) + if exp.draw_boundary and not exp.draw_crease: + so_b = extract_boundary_edges(so) + if len(so_b) > 0: + so_edge_list.append(so_b) + if exp.draw_contour: + so_c = extract_outer_contour_edges(so) + if len(so_c) > 0: + so_edge_list.append(so_c) + if exp.draw_crease: + so_cr = extract_crease_edges(so, max_angle_deg=exp.crease_angle) + if len(so_cr) > 0: + so_edge_list.append(so_cr) if so_edge_list: so_combined = np.concatenate(so_edge_list, axis=0) @@ -561,8 +572,9 @@ class PDFObjectLabelsElement(PDFElement): slicing_objs_list = exp.slicing_objs has_any_obj_show_name = any(hasattr(o, "pdf_properties") and o.pdf_properties and o.pdf_properties.show_name for o in slicing_objs_list) - if (exp.show_name or has_any_obj_show_name or settings_items) and exp.raw_geometry is not None: - label_infos = extract_object_label_info(exp.raw_geometry) + if (exp.show_name or has_any_obj_show_name or settings_items) and (exp.raw_geometry is not None or exp.slice_paths is not None): + geom_source = [g for g in (exp.raw_geometry, exp.slice_paths) if g is not None] + label_infos = extract_object_label_info(geom_source) if label_infos: eff_font_size_pt = exp.font_size_pt if exp.font_size_pt is not None and exp.font_size_pt > 0 else max(8.0, exp.line_width_pt * 4.0) line_spacing_pt = eff_font_size_pt * 1.25 @@ -710,7 +722,7 @@ class export_mesh_to_pdf: def __init__( self, - edges_2d: NDArray[np.float64], + edges_2d: NDArray[np.float64] | None = None, filepath: str | Path | None = None, draw_wireframe: bool = True, draw_boundary: bool = False, @@ -729,10 +741,11 @@ class export_mesh_to_pdf: bg_color: tuple[float, float, float] = (1.0, 1.0, 1.0), bbox_color: tuple[float, float, float] = (0.0, 0.0, 1.0), raw_geometry: Any = None, + slice_paths: Any = None, settings: Any = None, **kwargs: Any, ) -> None: - self.edges_2d = edges_2d + self.edges_2d = edges_2d if edges_2d is not None else np.empty((0, 2, 2), dtype=np.float64) self.filepath = filepath self.draw_wireframe = draw_wireframe self.draw_boundary = draw_boundary @@ -753,6 +766,7 @@ class export_mesh_to_pdf: self.bg_color = bg_color self.bbox_color = bbox_color self.raw_geometry = raw_geometry + self.slice_paths = slice_paths self.settings = settings self._prepare_geometry_and_layout() @@ -773,7 +787,12 @@ class export_mesh_to_pdf: self.export(filepath) def _prepare_geometry_and_layout(self) -> None: - self.slicing_objs = get_all_slicing_objects(self.raw_geometry) + objs_raw = get_all_slicing_objects(self.raw_geometry) + objs_paths = get_all_slicing_objects(self.slice_paths) + self.slicing_objs = list(objs_raw) + for po in objs_paths: + if not any(po is existing for existing in self.slicing_objs): + self.slicing_objs.append(po) edge_list: list[NDArray[np.float64]] = [] @@ -795,6 +814,11 @@ class export_mesh_to_pdf: if len(cr_edges) > 0: edge_list.append(cr_edges) + if self.slice_paths is not None: + sp_edges = extract_slice_paths_edges(self.slice_paths) + if len(sp_edges) > 0: + edge_list.append(sp_edges) + if edge_list: combined = np.concatenate(edge_list, axis=0) self.edges_to_draw = deduplicate_2d_edges(combined) @@ -843,7 +867,7 @@ class export_mesh_to_pdf: objs_with_props = [ so for so in self.slicing_objs - if hasattr(so, "object_properties") and so.object_properties + if getattr(so, "object_properties", None) is not None and len(getattr(so, "object_properties", [])) > 0 ] w_table_mm = 0.0 @@ -918,8 +942,6 @@ class export_mesh_to_pdf: self.main_lines_arr = np.concatenate([main_v_lines, main_h_lines], axis=0) if len(main_v_lines) or len(main_h_lines) else np.empty((0, 2, 2)) self.minor_lines_arr = np.concatenate([minor_v_lines, minor_h_lines], axis=0) if len(minor_v_lines) or len(minor_h_lines) else np.empty((0, 2, 2)) - self.slicing_objs = get_all_slicing_objects(self.raw_geometry) - def to_pdf_pt(self, pt_mm: NDArray[np.float64]) -> NDArray[np.float64]: x_pt = (pt_mm[:, 0] - self.grid_min_x + self.total_margin_mm) * MM_TO_PT y_pt = (pt_mm[:, 1] - self.grid_min_y + self.total_margin_mm) * MM_TO_PT @@ -1020,4 +1042,3 @@ class export_mesh_to_pdf: # Alias for backward compatibility export_wireframe_to_pdf = export_mesh_to_pdf - diff --git a/core/slice.py b/core/slice.py index 2f0022a..09a3680 100644 --- a/core/slice.py +++ b/core/slice.py @@ -5,6 +5,8 @@ import pyclipper from typing import Any, Literal from shapely.geometry.base import BaseGeometry +from ..infra.blender.blender_mesh_capture import PdfProperties + from .geometry import ndarray_lru_cache # Scaling factor for pyclipper (it works with integers) @@ -160,6 +162,8 @@ class SlicePaths(list[np.ndarray]): layer_height: float first_layer_height: float | None transforms: list[dict] + object_properties: list[tuple[str, str]] + pdf_properties: PdfProperties def __init__(self, paths: list[np.ndarray] = [], groups: list[int] = [], extrusion_rate: list[np.ndarray] | None = None, feedrate: list[np.ndarray] | None = None, extra_attributes: dict[str, list[np.ndarray]] | None = None, layer_height: float = 0.2, first_layer_height: float | None = None, transforms: list[dict] | None = None): super().__init__(paths) diff --git a/doc/NODES.md b/doc/NODES.md index 3e3abf9..9325a98 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -63,12 +63,12 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Rename Mesh**: Overrides the internal metadata name of a `SlicingObject`, `SlicingCollection`, or `SlicingGroup`. Supports `{AttributeName}` attribute placeholders (e.g., `Rebar-L{Rebar Length}M`) that evaluate to index zero of the target attribute for each object, as well as `{Name}` to include the item's previous name (e.g., `{Name}_T{Thickness}mm`). - **Geometry**: Input geometry data. - **Name**: String input socket or property for the new name template. -- **Assign PDF Properties**: Assigns line width (mm) and line color PDF export properties to all `SlicingObject` instances within a `SlicingObject`, `SlicingCollection`, or `SlicingGroup`. - - **Geometry**: Input geometry socket. +- **Assign PDF Properties**: Assigns line width (mm) and line color PDF export properties to geometry (`SlicingObject`, `SlicingCollection`, `SlicingGroup`, or `SlicePaths`). + - **Geometry**: Generic geometry input socket (accepts `SLICESOCK_Mesh`, `SLICESOCK_Paths`, or `SLICESOCK_Geometry`). - **Line Width**: Input socket / float property for line width in mm. - **Line Color**: Color property for stroke color. -- **Assign Property to Object**: Assigns custom settings/properties to each object in a `SlicingObject`, `SlicingCollection`, or `SlicingGroup` for rendering in PDF properties tables. - - **Geometry**: Input geometry socket. +- **Assign Property to Object**: Assigns custom settings/properties to geometry (`SlicingObject`, `SlicingCollection`, `SlicingGroup`, or `SlicePaths`) for rendering in PDF properties tables. + - **Geometry**: Generic geometry input socket (accepts `SLICESOCK_Mesh`, `SLICESOCK_Paths`, or `SLICESOCK_Geometry`). - **Settings**: Input socket accepting configuration settings or property items. - **Transform Geometry**: Moves, rotates, and scales a `SlicingCollection` or `SlicingGroup` relative to the origin. - **Mesh**: Input mesh data. diff --git a/tests/test_pdf_exporter.py b/tests/test_pdf_exporter.py index 482e18b..536d147 100644 --- a/tests/test_pdf_exporter.py +++ b/tests/test_pdf_exporter.py @@ -951,7 +951,7 @@ class TestPdfExporter(unittest.TestCase): pdf_file = os.path.join(tmpdir, "output.pdf") resolved_inputs = { "Geometry": so, - "SlicePaths": None, + "Paths": None, "Folder": pdf_file, "Settings": None, } @@ -1121,6 +1121,47 @@ class TestPdfExporter(unittest.TestCase): for obj in coll.objects: self.assertEqual(obj.object_properties, props) + # Also verify SlicePaths support + from NodeSlicer.core.slice import SlicePaths + sp = SlicePaths([np.array([[0, 0, 0], [10, 10, 0]], dtype=np.float64)]) + updated_sp = _assign_properties_to_item(sp, props) + self.assertEqual(updated_sp.object_properties, props) + + # Verify dynamic socket color logic + from NodeSlicer.core.nodeslicer.sockets import SLICESOCK_Geometry, SLICESOCK_Paths, SLICESOCK_Mesh + class DummyLink: + def __init__(self, from_sock): + self.from_socket = from_sock + + class DummyNode: + def __init__(self): + self.inputs = {} + + dummy_node = DummyNode() + in_geom = SLICESOCK_Geometry() + in_geom.is_output = False + in_geom.is_linked = True + in_geom.name = "Geometry" + + paths_out = SLICESOCK_Paths() + in_geom.links = [DummyLink(paths_out)] + dummy_node.inputs["Geometry"] = in_geom + + out_geom = SLICESOCK_Geometry() + out_geom.is_output = True + + # Input socket draws Paths color (pink) + self.assertEqual(in_geom.draw_color(None, dummy_node), paths_out.bl_color) + # Output socket on same node inherits Paths color (pink) from input socket + self.assertEqual(out_geom.draw_color(None, dummy_node), paths_out.bl_color) + + # Switch upstream to Mesh (cyan) + mesh_out = SLICESOCK_Mesh() + in_geom.links = [DummyLink(mesh_out)] + self.assertEqual(in_geom.draw_color(None, dummy_node), mesh_out.bl_color) + self.assertEqual(out_geom.draw_color(None, dummy_node), mesh_out.bl_color) + + def test_export_pdf_object_properties_table(self): so1 = SlicingObject() so1.name = "BracketA" @@ -1245,6 +1286,146 @@ class TestPdfExporter(unittest.TestCase): self.assertIn("1.000 1.000 1.000 rg", content) self.assertIn("1.000 1.000 1.000 RG", content) + def test_slicepaths_pdf_export_default_style_and_properties(self): + from NodeSlicer.core.slice import SlicePaths + from NodeSlicer.core.nodeslicer.nodes.mesh import _assign_pdf_properties_to_item, _assign_properties_to_item + + sp = SlicePaths([np.array([[0.0, 0.0], [50.0, 0.0], [50.0, 50.0], [0.0, 50.0]], dtype=np.float64)]) + sp.name = "TestSlicePath" + + # 1. Export with default style + with tempfile.TemporaryDirectory() as tmpdir: + pdf_path = os.path.join(tmpdir, "slicepath_default.pdf") + export_mesh_to_pdf( + slice_paths=sp, + filepath=pdf_path, + draw_wireframe=True, + ) + self.assertTrue(os.path.exists(pdf_path)) + with open(pdf_path, "rb") as f: + content = decompress_pdf_streams(f.read()) + self.assertTrue(len(content) > 0) + + # 2. Assign PDF properties (custom width, color, show_name) + sp_pdf_props = _assign_pdf_properties_to_item(sp, line_width=3.0, line_color=(1.0, 0.0, 0.0), show_name=True) + with tempfile.TemporaryDirectory() as tmpdir: + pdf_path = os.path.join(tmpdir, "slicepath_pdf_props.pdf") + export_mesh_to_pdf( + slice_paths=sp_pdf_props, + filepath=pdf_path, + draw_wireframe=True, + ) + with open(pdf_path, "rb") as f: + content = decompress_pdf_streams(f.read()) + self.assertIn("1.000 0.000 0.000 RG", content) + self.assertIn("(TestSlicePath) Tj", content) + + # 3. Assign Property to Object (table properties) + props = [("Layer Height", "0.2mm"), ("Speed", "60mm/s")] + sp_props = _assign_properties_to_item(sp, props) + with tempfile.TemporaryDirectory() as tmpdir: + pdf_path = os.path.join(tmpdir, "slicepath_object_props.pdf") + export_mesh_to_pdf( + slice_paths=sp_props, + filepath=pdf_path, + draw_wireframe=True, + ) + with open(pdf_path, "rb") as f: + content = decompress_pdf_streams(f.read()) + self.assertIn("(Layer Height) Tj", content) + self.assertIn("(0.2mm) Tj", content) + self.assertIn("(Speed) Tj", content) + self.assertIn("(60mm/s) Tj", content) + + def test_mesh_to_pdf_paths_input_only_and_combined(self): + from NodeSlicer.core.slice import SlicePaths + from NodeSlicer.core.nodeslicer.nodes.output import SLICENODE_MeshToPdf + node = SLICENODE_MeshToPdf.__new__(SLICENODE_MeshToPdf) + node.draw_wireframe = True + node.draw_boundary = False + node.draw_contour = False + node.draw_crease = False + node.crease_angle = 30.0 + node.draw_bbox = False + node.draw_sa2_holes = False + node.draw_header = False + node.header_company = "" + node.header_part_name = "" + node.header_drawing_number = "" + node.header_author = "" + node.header_revision = "" + node.header_material = "" + node.header_tolerance = "" + node.font_size = 0.0 + node.margin = 10.0 + node.wire_color = (0.0, 0.0, 0.0) + node.bg_color = (1.0, 1.0, 1.0) + node.bbox_color = (0.0, 0.0, 1.0) + + class MockOperator: + def __init__(self): + self.reports = [] + def report(self, type_set, message): + self.reports.append((type_set, message)) + + sp = SlicePaths([np.array([[5.0, 5.0], [25.0, 25.0]], dtype=np.float64)]) + sp.name = "PathOnly" + + # Case A: Only Paths connected (Geometry is None) + with tempfile.TemporaryDirectory() as tmpdir: + pdf_file = os.path.join(tmpdir, "paths_only.pdf") + op = MockOperator() + resolved = { + "Geometry": None, + "Paths": sp, + "Folder": pdf_file, + } + node.resolve = lambda k: resolved.get(k) + res = node.evaluate(operator=op) + self.assertEqual(res, "ok", msg=f"Reports: {op.reports}") + self.assertTrue(os.path.exists(pdf_file)) + with open(pdf_file, "rb") as f: + content = decompress_pdf_streams(f.read()) + self.assertIn(" m ", content) + + # Case B: Both Geometry and Paths connected + so = SlicingObject() + so.name = "MeshObj" + so.mesh = np.array([[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0]]], dtype=np.float64) + + with tempfile.TemporaryDirectory() as tmpdir: + pdf_file = os.path.join(tmpdir, "combined.pdf") + op = MockOperator() + resolved = { + "Geometry": so, + "Paths": sp, + "Folder": pdf_file, + } + node.resolve = lambda k: resolved.get(k) + res = node.evaluate(operator=op) + self.assertEqual(res, "ok", msg=f"Reports: {op.reports}") + self.assertTrue(os.path.exists(pdf_file)) + with open(pdf_file, "rb") as f: + content = decompress_pdf_streams(f.read()) + self.assertIn(" m ", content) + + # Case C: Geometry is an empty list [] and Paths is connected + with tempfile.TemporaryDirectory() as tmpdir: + pdf_file = os.path.join(tmpdir, "empty_geom_list.pdf") + op = MockOperator() + resolved = { + "Geometry": [], + "Paths": sp, + "Folder": pdf_file, + } + node.resolve = lambda k: resolved.get(k) + res = node.evaluate(operator=op) + self.assertEqual(res, "ok", msg=f"Reports: {op.reports}") + self.assertTrue(os.path.exists(pdf_file)) + with open(pdf_file, "rb") as f: + content = decompress_pdf_streams(f.read()) + self.assertIn(" m ", content) + if __name__ == '__main__': unittest.main()