diff --git a/core/nodeslicer/nodes/output.py b/core/nodeslicer/nodes/output.py index 8ed7d89..fedb88f 100644 --- a/core/nodeslicer/nodes/output.py +++ b/core/nodeslicer/nodes/output.py @@ -399,6 +399,7 @@ class SLICENODE_MeshToPdf(ExecSliceNode): def init(self, context): self._init_exec() self.add_input("SLICESOCK_DirPath", "Folder") + self.add_input("SLICESOCK_Settings", "Settings") self.add_multi_socket_group("SLICESOCK_Mesh", name="Geometry") def draw_buttons(self, context, layout): @@ -424,6 +425,7 @@ class SLICENODE_MeshToPdf(ExecSliceNode): geometries: list[Any] = [g for g in self.resolve_multi_input('Geometry') if g is not None] folder: str = self.resolve("Folder") or "" + settings = self.resolve("Settings") if not geometries: if operator: operator.report({'ERROR'}, "No Geometry connected") @@ -479,7 +481,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 + raw_geometry=geom, + settings=settings ) if operator: operator.report({'INFO'}, f"Mesh PDF saved to {filepath}") except Exception as e: diff --git a/core/pdf_exporter.py b/core/pdf_exporter.py index 004fe9f..1d38658 100644 --- a/core/pdf_exporter.py +++ b/core/pdf_exporter.py @@ -459,15 +459,92 @@ def _format_pdf_lines(lines_arr: NDArray[np.float64]) -> list[str]: return [f"{row[0]:.2f} {row[1]:.2f} m {row[2]:.2f} {row[3]:.2f} l" for row in flat] -def extract_object_label_info(mesh_data: Any) -> list[tuple[str, float, float, float, float]]: +def extract_settings_items(settings: Any) -> list[tuple[str, str]]: """ - Extracts (object_name, max_x_mm, y_at_max_x_mm, width_mm, height_mm) for each SlicingObject in mesh_data. + Extracts a list of (setting_name, second_field_suffix) from settings input. + The setting_name is the left field, and second_field_suffix is the right field (e.g. unit). + """ + if not settings: + return [] + items: list[tuple[str, str]] = [] + if isinstance(settings, dict): + for k, v in settings.items(): + key_str = str(k).strip() + if key_str: + suffix_str = str(v) if v is not None else "" + items.append((key_str, suffix_str)) + elif isinstance(settings, (list, tuple)): + for item in settings: + if isinstance(item, str) and item.strip(): + items.append((item.strip(), "")) + elif isinstance(item, dict): + if "field" in item and item["field"]: + key_str = str(item["field"]).strip() + val_str = str(item.get("value_str", item.get("value", ""))) + items.append((key_str, val_str)) + else: + for k, v in item.items(): + key_str = str(k).strip() + if key_str: + suffix_str = str(v) if v is not None else "" + items.append((key_str, suffix_str)) + elif hasattr(item, "field") and getattr(item, "field"): + key_str = str(getattr(item, "field")).strip() + val_str = str(getattr(item, "value_str", getattr(item, "value", ""))) + items.append((key_str, val_str)) + return items + + +def extract_settings_keys(settings: Any) -> list[str]: + return [item[0] for item in extract_settings_items(settings)] + + +def get_first_attribute_value(obj: Any, attr_name: str) -> Any: + """ + Accesses the first element of the attribute with attr_name on obj. + """ + val = None + if hasattr(obj, "attributes") and isinstance(obj.attributes, dict) and attr_name in obj.attributes: + attr = obj.attributes[attr_name] + if isinstance(attr, dict) and "values" in attr: + val = attr["values"] + else: + val = attr + elif hasattr(obj, attr_name): + val = getattr(obj, attr_name) + + if val is None: + return None + + if isinstance(val, np.ndarray): + if val.size > 0: + first_val = val.flat[0] + if hasattr(first_val, "item"): + return first_val.item() + return first_val + return None + elif isinstance(val, (list, tuple)): + if len(val) > 0: + first_val = val[0] + if hasattr(first_val, "item"): + return first_val.item() + return first_val + return None + else: + if hasattr(val, "item"): + return val.item() + return val + + +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. max_x_mm is the X coordinate of the rightmost vertex of the object. y_at_max_x_mm is the Y coordinate of that rightmost vertex. """ from ..infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject - results: list[tuple[str, float, float, float, float]] = [] + results: list[tuple[Any, str, float, float, float, float]] = [] def process_item(item: Any, fallback_name: str = ""): if item is None: @@ -496,7 +573,7 @@ def extract_object_label_info(mesh_data: Any) -> list[tuple[str, float, float, f w_mm = max_x - min_x h_mm = max_y - min_y - results.append((name, max_x, y_val, w_mm, h_mm)) + results.append((item, name, max_x, y_val, w_mm, h_mm)) process_item(mesh_data) return results @@ -539,19 +616,19 @@ def extract_object_bounding_boxes(mesh_data: Any) -> NDArray[np.float64]: [[min_x, min_y], [max_x, min_y]], [[max_x, min_y], [max_x, max_y]], [[max_x, max_y], [min_x, max_y]], - [[min_x, max_y], [min_x, min_y]] + [[min_x, max_y], [min_x, min_y]], ], dtype=np.float64) all_segments.append(rect) process_item(mesh_data) - if all_segments: - return np.vstack(all_segments) - return np.empty((0, 2, 2), dtype=np.float64) + if not all_segments: + return np.empty((0, 2, 2), dtype=np.float64) + return np.concatenate(all_segments, axis=0) def export_mesh_to_pdf( edges_2d: NDArray[np.float64], - filepath: str, + filepath: str | Path, draw_wireframe: bool = True, draw_boundary: bool = False, draw_contour: bool = False, @@ -566,6 +643,7 @@ def 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, + settings: Any = None, style: str | None = None # Backward compatibility ) -> None: """ @@ -889,8 +967,9 @@ def export_mesh_to_pdf( stream_cmds.append("ET") - # 7. Object Names and Bounding Box Sizes (to the right of the rightmost vertex) - if (show_name or show_bbox_size) and raw_geometry is not None: + # 7. Object Names, Bounding Box Sizes, and Settings Attributes (to the right of the rightmost vertex) + settings_items = extract_settings_items(settings) + if (show_name or show_bbox_size or settings_items) and raw_geometry is not None: label_infos = extract_object_label_info(raw_geometry) if label_infos: font_size_pt = max(8.0, line_width_pt * 4.0) @@ -900,42 +979,41 @@ def export_mesh_to_pdf( stream_cmds.append("BT") stream_cmds.append(f"/F1 {font_size_pt:.1f} Tf") - for obj_name, max_x_mm, y_val_mm, w_obj_mm, h_obj_mm in label_infos: + for so_item, obj_name, max_x_mm, y_val_mm, w_obj_mm, h_obj_mm in label_infos: p_pt = to_pdf_pt(np.array([[max_x_mm, y_val_mm]])) x_text_pt = p_pt[0, 0] + (2.5 * MM_TO_PT) y_text_pt = p_pt[0, 1] - (font_size_pt * 0.35) - if show_name and show_bbox_size: - clean_name = str(obj_name).replace("(", "\\(").replace(")", "\\)") - stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {y_text_pt:.2f} Tm") - stream_cmds.append(f"({clean_name}) Tj") + lines_to_draw: list[str] = [] + if show_name: + lines_to_draw.append(str(obj_name)) + + if show_bbox_size: if main_step >= 1000.0: w_val = w_obj_mm / 1000.0 h_val = h_obj_mm / 1000.0 size_str = f"(X: {w_val:g}m Y: {h_val:g}m)" else: size_str = f"(X: {w_obj_mm:g}mm Y: {h_obj_mm:g}mm)" + lines_to_draw.append(size_str) - clean_size = size_str.replace("(", "\\(").replace(")", "\\)") - y_line2_pt = y_text_pt - line_spacing_pt - stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {y_line2_pt:.2f} Tm") - stream_cmds.append(f"({clean_size}) Tj") - elif show_name: - clean_name = str(obj_name).replace("(", "\\(").replace(")", "\\)") - stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {y_text_pt:.2f} Tm") - stream_cmds.append(f"({clean_name}) Tj") - elif show_bbox_size: - if main_step >= 1000.0: - w_val = w_obj_mm / 1000.0 - h_val = h_obj_mm / 1000.0 - size_str = f"(X: {w_val:g}m Y: {h_val:g}m)" - else: - size_str = f"(X: {w_obj_mm:g}mm Y: {h_obj_mm:g}mm)" + for key_name, suffix in settings_items: + attr_val = get_first_attribute_value(so_item, key_name) + if attr_val is not None: + if isinstance(attr_val, float): + val_str = f"{attr_val:g}" + else: + val_str = str(attr_val) + if suffix: + val_str = f"{val_str}{suffix}" + lines_to_draw.append(f"{key_name}: {val_str}") - clean_size = size_str.replace("(", "\\(").replace(")", "\\)") - stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {y_text_pt:.2f} Tm") - stream_cmds.append(f"({clean_size}) Tj") + for line_idx, line_str in enumerate(lines_to_draw): + clean_str = line_str.replace("(", "\\(").replace(")", "\\)") + curr_y_pt = y_text_pt - (line_idx * line_spacing_pt) + stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {curr_y_pt:.2f} Tm") + stream_cmds.append(f"({clean_str}) Tj") stream_cmds.append("ET") diff --git a/doc/NODES.md b/doc/NODES.md index f08ec8e..ddbb09b 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -121,6 +121,7 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Mesh to PDF**: Generates a 1:1 true-scale 2D top-view (+Z orthographic) vector PDF from a `SlicingGroup` or `SlicingCollection` with a custom grid and background color. - **Geometry**: Input socket accepting a `SlicingGroup`, `SlicingCollection`, or `SlicingObject`. - **Folder**: Target folder or PDF file path on disk. + - **Settings**: Input socket accepting configuration settings or attribute names to render as `: ` next to each object. - **Wireframe**: Checkbox to render all projected 3D wireframe edges. - **Boundary (1 Face)**: Checkbox to render open mesh boundary edges owned by only 1 face. - **Contour**: Checkbox to render outer boundary silhouette contour. diff --git a/tests/test_pdf_exporter.py b/tests/test_pdf_exporter.py index f663e1f..5ab9ac3 100644 --- a/tests/test_pdf_exporter.py +++ b/tests/test_pdf_exporter.py @@ -480,6 +480,45 @@ class TestPdfExporter(unittest.TestCase): self.assertIn("(10) Tj", pdf_text) self.assertIn("(100 mm) Tj", pdf_text) + def test_export_pdf_with_settings_attributes(self): + so = SlicingObject() + so.name = "AttributedPart" + tri = np.array([ + [0.0, 0.0, 0.0], + [10.0, 0.0, 0.0], + [0.0, 10.0, 0.0], + [0.0, 0.0, 1.0] + ], dtype=np.float64) + so.mesh = np.array([tri]) + so.attributes = { + "infill_density": {"domain": "face", "type": "float", "values": np.array([20.0, 20.0])}, + "layer_height": {"domain": "face", "type": "float", "values": np.array([0.15, 0.15])}, + "material": ["PLA", "PLA"] + } + + edges_2d = extract_unique_top_view_edges(so) + + with tempfile.TemporaryDirectory() as tmpdir: + pdf_path = os.path.join(tmpdir, "test_settings_attr.pdf") + export_mesh_to_pdf( + edges_2d, + pdf_path, + show_name=True, + show_bbox_size=True, + raw_geometry=so, + settings={"infill_density": "%", "layer_height": " mm", "material": ""} + ) + self.assertTrue(os.path.exists(pdf_path)) + with open(pdf_path, "rb") as f: + content = f.read().decode("utf-8", errors="ignore") + + self.assertIn("(AttributedPart) Tj", content) + self.assertIn("(infill_density: 20%) Tj", content) + self.assertIn("(layer_height: 0.15 mm) Tj", content) + self.assertIn("(material: PLA) Tj", content) + if __name__ == '__main__': unittest.main() + +