diff --git a/core/nodeslicer/nodes/output.py b/core/nodeslicer/nodes/output.py index 4ff7037..a8f5d23 100644 --- a/core/nodeslicer/nodes/output.py +++ b/core/nodeslicer/nodes/output.py @@ -364,10 +364,20 @@ class SLICENODE_MeshToPdf(ExecSliceNode): crease_angle: NfFloatProperty(name="Angle (°)", default=30.0, min=0.0, max=180.0) # pyright: ignore[reportInvalidTypeForm] draw_bbox: NfBoolProperty(name="Bounding Box", default=False) # pyright: ignore[reportInvalidTypeForm] draw_sa2_holes: NfBoolProperty(name="Holes (Ø)", default=False) # pyright: ignore[reportInvalidTypeForm] - show_lineart: NfBoolProperty(name="Lineart Visibility", default=True) # pyright: ignore[reportInvalidTypeForm] - show_sa2: NfBoolProperty(name="SA2 Settings", default=True) # pyright: ignore[reportInvalidTypeForm] + show_lineart: NfBoolProperty(name="Lineart Visibility", default=False) # pyright: ignore[reportInvalidTypeForm] + show_sa2: NfBoolProperty(name="SA2 Settings", default=False) # pyright: ignore[reportInvalidTypeForm] show_bbox_size: NfBoolProperty(name="Bounding Box Sizes", default=False) # pyright: ignore[reportInvalidTypeForm] + draw_header: NfBoolProperty(name="Technical Header", default=True) # pyright: ignore[reportInvalidTypeForm] + show_header: NfBoolProperty(name="Technical Drawing Header", default=False) # pyright: ignore[reportInvalidTypeForm] + header_company: NfStringProperty(name="Company", default="") # pyright: ignore[reportInvalidTypeForm] + header_part_name: NfStringProperty(name="Part Name", default="") # pyright: ignore[reportInvalidTypeForm] + header_drawing_number: NfStringProperty(name="Drawing No.", default="") # pyright: ignore[reportInvalidTypeForm] + header_author: NfStringProperty(name="Author", default="") # pyright: ignore[reportInvalidTypeForm] + header_revision: NfStringProperty(name="Revision", default="A") # pyright: ignore[reportInvalidTypeForm] + header_material: NfStringProperty(name="Material", default="") # pyright: ignore[reportInvalidTypeForm] + header_tolerance: NfStringProperty(name="Tolerance", default="") # pyright: ignore[reportInvalidTypeForm] + font_size: NfFloatProperty(name="Font Size (mm)", default=0.0, min=0.0, max=100.0) # pyright: ignore[reportInvalidTypeForm] margin: NfFloatProperty(name="Margin (mm)", default=10.0, min=0.0, max=500.0) # pyright: ignore[reportInvalidTypeForm] @@ -407,7 +417,20 @@ class SLICENODE_MeshToPdf(ExecSliceNode): def draw_buttons(self, context, layout): super().draw_buttons(context, layout) - # Lineart Visibility subpanel (collapsible) + # Technical Drawing Header subpanel (collapsible, collapsed by default) + layout.prop(self, "show_header", text="Technical Drawing Header", icon='TRIA_DOWN' if self.show_header else 'TRIA_RIGHT') + if self.show_header: + box = layout.box() + box.prop(self, "draw_header") + box.prop(self, "header_company") + box.prop(self, "header_part_name") + box.prop(self, "header_drawing_number") + box.prop(self, "header_author") + box.prop(self, "header_revision") + box.prop(self, "header_material") + box.prop(self, "header_tolerance") + + # Lineart Visibility subpanel (collapsible, collapsed by default) layout.prop(self, "show_lineart", text="Lineart Visibility", icon='TRIA_DOWN' if self.show_lineart else 'TRIA_RIGHT') if self.show_lineart: box = layout.box() @@ -418,7 +441,7 @@ class SLICENODE_MeshToPdf(ExecSliceNode): if self.draw_crease: box.prop(self, "crease_angle") - # SA2 Settings subpanel (collapsible) + # SA2 Settings subpanel (collapsible, collapsed by default) layout.prop(self, "show_sa2", text="SA2 Settings", icon='TRIA_DOWN' if self.show_sa2 else 'TRIA_RIGHT') if self.show_sa2: box = layout.box() @@ -437,6 +460,7 @@ class SLICENODE_MeshToPdf(ExecSliceNode): layout.prop(self, "wire_color") def evaluate(self, operator=None): + import datetime from ...pdf_exporter import extract_unique_top_view_edges, export_mesh_to_pdf geometries: list[Any] = [g for g in self.resolve_multi_input('Geometry') if g is not None] @@ -475,6 +499,32 @@ class SLICENODE_MeshToPdf(ExecSliceNode): filename = f"{name_part}.pdf" target_list.append((geom, str(out_dir / filename))) + # Automatic scene scale determination + scale_str = "1:1" + try: + if hasattr(bpy, "context") and hasattr(bpy.context, "scene") and bpy.context.scene: + scale_len = getattr(bpy.context.scene.unit_settings, "scale_length", 1.0) + if scale_len == 1.0 or scale_len == 0.001: + scale_str = "1:1" + elif scale_len > 1.0: + scale_str = f"{scale_len:g}:1" + elif scale_len > 0.0: + scale_str = f"1:{1.0 / scale_len:g}" + except Exception: + scale_str = "1:1" + + header_info = { + "company": self.header_company, + "part_name": self.header_part_name, + "drawing_number": self.header_drawing_number, + "author": self.header_author, + "revision": self.header_revision, + "material": self.header_material, + "tolerance": self.header_tolerance, + "date": datetime.date.today().isoformat(), + "scale": scale_str, + } + for geom, filepath in target_list: edges_2d = extract_unique_top_view_edges(geom) if len(edges_2d) == 0: @@ -490,6 +540,8 @@ class SLICENODE_MeshToPdf(ExecSliceNode): draw_crease=self.draw_crease, draw_bbox=self.draw_bbox, draw_sa2_holes=self.draw_sa2_holes, + draw_header=self.draw_header, + header_info=header_info, show_bbox_size=self.show_bbox_size, crease_angle=self.crease_angle, margin_mm=self.margin, diff --git a/core/pdf_exporter/geometry.py b/core/pdf_exporter/geometry.py index 596d6bd..1e0848a 100644 --- a/core/pdf_exporter/geometry.py +++ b/core/pdf_exporter/geometry.py @@ -7,7 +7,6 @@ from typing import Any import numpy as np from numpy.typing import NDArray - def deduplicate_2d_edges(edges_2d: NDArray[np.float64]) -> NDArray[np.float64]: """ Deduplicates 2D edge segments regardless of direction (A->B or B->A). @@ -346,12 +345,7 @@ def extract_crease_edges(mesh_data: Any, max_angle_deg: float = 30.0) -> NDArray def extract_silhouette_contour_edges(mesh_data: Any) -> NDArray[np.float64]: """ Extracts top-view silhouette contour edges using 3D face normals. - A silhouette edge separates faces with different visibility from the top-down view. - For view direction (0,0,-1), we compute the sign of each face normal's Z-component: - - sign < 0: front-facing (visible) - - sign > 0: back-facing (not visible) - - sign == 0: edge-on / vertical (zero projected area) - An edge is a silhouette if its adjacent faces have different sign categories. + Filters front-facing faces (normal_z > epsilon) and extracts edges owned by exactly 1 front-facing face. """ from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject @@ -407,34 +401,27 @@ def extract_silhouette_contour_edges(mesh_data: Any) -> NDArray[np.float64]: if num_tris == 0: return np.empty((0, 2, 2), dtype=np.float64) - # For top-down view (view direction = (0,0,-1)), compute sign of normal.z - # The sign of -normal.z tells us visibility: >0 means front-facing (visible) - # We categorize by sign of normal.z directly for simplicity: - # negative z = facing viewer, positive z = facing away, zero = edge-on + # Filter for top-facing faces (normal_z > epsilon). + # Back-facing (normal_z < -epsilon) and vertical (|normal_z| <= epsilon) faces are masked out. normal_z = normals_unit[:, 2] - - # Assign each face to a category: 0=vertical/edge-on, 1=front, 2=back - # We use epsilon = 1e-3 to robustly absorb floating point noise on perpendicular faces epsilon = 1e-3 - face_category = np.zeros(num_tris, dtype=int) - face_category[normal_z < -epsilon] = 1 # Front-facing - face_category[normal_z > epsilon] = 2 # Back-facing - # face_category == 0 means vertical (|normal_z| <= epsilon) + front_mask = normal_z > epsilon - # Extract all 3 edges per triangle + tris_3d = tris_3d[front_mask] + if len(tris_3d) == 0: + return np.empty((0, 2, 2), dtype=np.float64) + + # Extract 3 edges per front-facing triangle e0_3d = tris_3d[:, [0, 1], :] e1_3d = tris_3d[:, [1, 2], :] e2_3d = tris_3d[:, [2, 0], :] - edges_3d = np.concatenate([e0_3d, e1_3d, e2_3d], axis=0) # (3N, 2, 3) + edges_3d = np.concatenate([e0_3d, e1_3d, e2_3d], axis=0) # (3M, 2, 3) - verts_2d = tris_3d[:, :, :2] # (N, 3, 2) + verts_2d = tris_3d[:, :, :2] # (M, 3, 2) e0_2d = verts_2d[:, [0, 1], :] e1_2d = verts_2d[:, [1, 2], :] e2_2d = verts_2d[:, [2, 0], :] - edges_2d = np.concatenate([e0_2d, e1_2d, e2_2d], axis=0) # (3N, 2, 2) - - # Each edge inherits its face's category - edge_category = np.tile(face_category, 3) + edges_2d = np.concatenate([e0_2d, e1_2d, e2_2d], axis=0) # (3M, 2, 2) # Filter out near zero-length 2D edges dists_sq_2d = np.sum((edges_2d[:, 0, :] - edges_2d[:, 1, :]) ** 2, axis=1) @@ -442,7 +429,6 @@ def extract_silhouette_contour_edges(mesh_data: Any) -> NDArray[np.float64]: edges_3d = edges_3d[valid_len_mask] edges_2d = edges_2d[valid_len_mask] - edge_category = edge_category[valid_len_mask] if len(edges_3d) == 0: return np.empty((0, 2, 2), dtype=np.float64) @@ -466,24 +452,12 @@ def extract_silhouette_contour_edges(mesh_data: Any) -> NDArray[np.float64]: # Unique 3D edge identification flat_3d = canonical_3d.reshape(len(canonical_3d), 6) view_bytes = np.ascontiguousarray(flat_3d).view(np.dtype((np.void, 48))).ravel() - unique_keys, first_indices, inverse_indices, counts = np.unique( - view_bytes, return_index=True, return_inverse=True, return_counts=True + _, first_indices, counts = np.unique( + view_bytes, return_index=True, return_counts=True ) - num_unique = len(unique_keys) - - max_cat = np.full(num_unique, -1, dtype=int) - min_cat = np.full(num_unique, 999, dtype=int) - np.maximum.at(max_cat, inverse_indices, edge_category) - np.minimum.at(min_cat, inverse_indices, edge_category) - - # An edge is a silhouette/contour edge if: - # 1. It is owned by 1 face (boundary edge of open surface) - # OR - # 2. Adjacent faces belong to different normal.z orientation categories (e.g. front vs back, front vs vertical) - is_silhouette = (counts == 1) | (max_cat != min_cat) - - contour_edges = canonical_2d[first_indices[is_silhouette]] + # Boundary edges of the front-facing surface have count == 1 + contour_edges = canonical_2d[first_indices[counts == 1]] return deduplicate_2d_edges(contour_edges) diff --git a/core/pdf_exporter/pdf_utils.py b/core/pdf_exporter/pdf_utils.py index d0d4be6..1502cad 100644 --- a/core/pdf_exporter/pdf_utils.py +++ b/core/pdf_exporter/pdf_utils.py @@ -380,6 +380,11 @@ class PdfStream: self._cmds.append(f"/{name} {size_pt:.1f} Tf") return self + def text_rendering_mode(self, mode: int = 0) -> PdfStream: + """Set text rendering mode (``mode Tr``). 0=fill, 1=stroke, 2=fill+stroke (bold).""" + self._cmds.append(f"{mode} Tr") + return self + def text_position(self, x: float, y: float) -> PdfStream: """Set the text matrix to translate to (x, y).""" self._cmds.append(f"1 0 0 1 {x:.2f} {y:.2f} Tm") diff --git a/core/pdf_exporter/writer.py b/core/pdf_exporter/writer.py index 305e411..b108a9f 100644 --- a/core/pdf_exporter/writer.py +++ b/core/pdf_exporter/writer.py @@ -2,6 +2,7 @@ PDF Export Stream Assembly & Writer Module. Formats vector edge arrays and renders scalable PDF files with grid overlays, text labels, and SA2 technical markings. """ +import datetime import math import os from pathlib import Path @@ -32,6 +33,120 @@ from .sizing import ( from .pdf_utils import PdfStream, escape_pdf_text +def render_technical_header_table( + s: PdfStream, + page_w_pt: float, + page_h_pt: float, + total_margin_mm: float, + header_info: dict[str, Any], + stroke_color: tuple[float, float, float], + bg_color: tuple[float, float, float], + font_size_pt: float, + line_width_pt: float, + obj_name: str = "", +) -> None: + """ + Renders an ISO/DIN technical drawing header block (title block) at the bottom-right corner. + """ + w_table_mm = font_size_pt/MM_TO_PT * 25.0 + cell_height_mm = font_size_pt/MM_TO_PT * 1.4 + + h_table_mm = cell_height_mm*3 + + x1_pt = page_w_pt - (total_margin_mm * MM_TO_PT) + y0_pt = total_margin_mm * MM_TO_PT + x0_pt = x1_pt - (w_table_mm * MM_TO_PT) + y1_pt = y0_pt + (h_table_mm * MM_TO_PT) + + # Fill background box + br, bg, bb = bg_color + sr, sg, sb = stroke_color + s.opacity("GS100") + s.fill_color(br, bg, bb) + s.fill_rect(x0_pt, y0_pt, w_table_mm * MM_TO_PT, h_table_mm * MM_TO_PT) + + # Table borders and grid + table_lw_pt = line_width_pt * 1.2 + grid_lw_pt = line_width_pt * 0.8 + + s.stroke_color(sr, sg, sb).line_width(table_lw_pt).round_caps() + s.rect(x0_pt, y0_pt, w_table_mm * MM_TO_PT, h_table_mm * MM_TO_PT).stroke() + + h_row_pt = cell_height_mm * MM_TO_PT + y_row1_pt = y0_pt + h_row_pt + y_row2_pt = y0_pt + 2.0 * h_row_pt + + s.line_width(grid_lw_pt) + # Horizontal lines + s.line_stroke(x0_pt, y_row1_pt, x1_pt, y_row1_pt) + s.line_stroke(x0_pt, y_row2_pt, x1_pt, y_row2_pt) + + # Vertical dividers + x_div1_pt = x0_pt + (0.5 * w_table_mm * MM_TO_PT) + x_div2_pt = x0_pt + (0.8 * w_table_mm * MM_TO_PT) + s.line_stroke(x_div1_pt, y_row1_pt, x_div1_pt, y1_pt) + s.line_stroke(x_div2_pt, y_row1_pt, x_div2_pt, y1_pt) + + x_r2_div1_pt = x0_pt + (0.4 * w_table_mm * MM_TO_PT) + x_r2_div2_pt = x0_pt + (0.8 * w_table_mm * MM_TO_PT) + s.line_stroke(x_r2_div1_pt, y0_pt, x_r2_div1_pt, y_row1_pt) + s.line_stroke(x_r2_div2_pt, y0_pt, x_r2_div2_pt, y_row1_pt) + + # Extract field values with fallbacks + company_val = str(header_info.get("company", "") or "-") + part_name_val = str(header_info.get("part_name", "") or obj_name or "-") + dwg_val = str(header_info.get("drawing_number", "") or "-") + author_val = str(header_info.get("author", "") or "-") + rev_val = str(header_info.get("revision", "") or "A") + mat_val = str(header_info.get("material", "") or "-") + tol_val = str(header_info.get("tolerance", "") or "-") + date_val = str(header_info.get("date", "") or datetime.date.today().isoformat()) + scale_val = str(header_info.get("scale", "") or "1:1") + + cells = [ + # Row 0 (Top) + ("COMPANY", company_val, x0_pt, y_row2_pt, h_row_pt), + ("DWG NO", dwg_val, x_div1_pt, y_row2_pt, h_row_pt), + ("REV", rev_val, x_div2_pt, y_row2_pt, h_row_pt), + # Row 1 (Middle) + ("PART NAME", part_name_val, x0_pt, y_row1_pt, h_row_pt), + ("MATERIAL", mat_val, x_div1_pt, y_row1_pt, h_row_pt), + ("TOLERANCE", tol_val, x_div2_pt, y_row1_pt, h_row_pt), + # Row 2 (Bottom) + ("AUTHOR", author_val, x0_pt, y0_pt, h_row_pt), + ("DATE", date_val, x_r2_div1_pt, y0_pt, h_row_pt), + ("SCALE", scale_val, x_r2_div2_pt, y0_pt, h_row_pt), + ] + + eff_font = font_size_pt if font_size_pt is not None and font_size_pt > 0 else 10.0 + lbl_font_pt = eff_font * 0.5 + val_font_pt = eff_font * 0.75 + label_color = (0.35, 0.35, 0.35) + + for lbl_text, val_text, cx, cy, ch in cells: + # Small field label + lx = cx + (1.5 * MM_TO_PT) + ly = cy + ch - (lbl_font_pt * 1.1) + s.begin_text().font("F1", lbl_font_pt) + s.fill_color(*label_color) + s.text_position(lx, ly) + s.show(lbl_text) + s.end_text() + + # Field value (bold) + vx = cx + (1.5 * MM_TO_PT) + vy = cy + (1.5 * MM_TO_PT) + s.begin_text().font("F1", val_font_pt) + s.fill_color(sr, sg, sb) + bold_w = max(0.15, val_font_pt * 0.035) + s.stroke_color(sr, sg, sb).line_width(bold_w).text_rendering_mode(2) + s.text_position(vx, vy) + s.show(val_text) + s.text_rendering_mode(0) + s.end_text() + + + def get_all_slicing_objects(mesh_data: Any) -> list[Any]: from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject objects = [] @@ -62,6 +177,8 @@ def export_mesh_to_pdf( draw_crease: bool = False, draw_bbox: bool = False, draw_sa2_holes: bool = False, + draw_header: bool = False, + header_info: dict[str, Any] | None = None, show_name: bool = False, show_bbox_size: bool = False, crease_angle: float = 30.0, @@ -73,22 +190,12 @@ def export_mesh_to_pdf( bbox_color: tuple[float, float, float] = (0.0, 0.0, 1.0), raw_geometry: Any = None, settings: Any = None, - style: str | None = None, **kwargs: Any ) -> None: """ Exports 2D vector geometry to a 1:1 true physical scale vector PDF file. Supports toggleable line layers: Wireframe, Boundary (1 face), Contour, Crease Angle. """ - # Handle legacy style argument if provided - if style == "LINEART": - draw_wireframe = False - draw_boundary = True - draw_crease = True - elif style == "OUTER_CONTOUR": - draw_wireframe = False - draw_contour = True - edge_list: list[NDArray[np.float64]] = [] if draw_wireframe and len(edges_2d) > 0: @@ -128,43 +235,23 @@ def export_mesh_to_pdf( w_geom = max_x - min_x h_geom = max_y - min_y - center_x = (min_x + max_x) / 2.0 - center_y = (min_y + max_y) / 2.0 main_step, minor_step = compute_grid_steps(w_geom, h_geom) if len(edges_to_draw) > 0 or len(edges_2d) > 0: pad_x = minor_step pad_y = minor_step - # Calculate required grid span with padding - span_x = max(abs(max_x - center_x), abs(min_x - center_x)) + pad_x - span_y = max(abs(max_y - center_y), abs(min_y - center_y)) + pad_y - - # Round up to nearest main_step - half_span_x = math.ceil(span_x / main_step) * main_step - half_span_y = math.ceil(span_y / main_step) * main_step - - if half_span_x == 0: - half_span_x = main_step - if half_span_y == 0: - half_span_y = main_step - # Ensure grid is symmetric around center - grid_min_x = center_x - half_span_x - grid_max_x = center_x + half_span_x - grid_min_y = center_y - half_span_y - grid_max_y = center_y + half_span_y + grid_min_x = min_x - pad_x + grid_max_x = max_x + pad_x + grid_min_y = min_y - pad_y + grid_max_y = max_y + pad_y - # Ensure grid aligns with main_step grid lines - grid_min_x = math.floor(grid_min_x / main_step) * main_step - grid_max_x = math.ceil(grid_max_x / main_step) * main_step - grid_min_y = math.floor(grid_min_y / main_step) * main_step - grid_max_y = math.ceil(grid_max_y / main_step) * main_step - - if grid_min_x == grid_max_x: - grid_max_x += main_step - if grid_min_y == grid_max_y: - grid_max_y += main_step + # Ensure grid aligns with minor_step grid lines + grid_min_x = math.floor(grid_min_x / minor_step) * minor_step + grid_max_x = math.ceil(grid_max_x / minor_step) * minor_step + grid_min_y = math.floor(grid_min_y / minor_step) * minor_step + grid_max_y = math.ceil(grid_max_y / minor_step) * minor_step else: grid_min_x, grid_max_x = -50.0, 50.0 grid_min_y, grid_max_y = -50.0, 50.0 @@ -452,11 +539,16 @@ def export_mesh_to_pdf( x_text_pt = p_pt[0, 0] + leg_sizing.label_margin_w_pt y_text_pt = p_pt[0, 1] - (eff_font_size_pt * 0.35) - lines_to_draw: list[tuple[str, tuple[float, float, float]]] = [] + lines_to_draw: list[tuple[str, tuple[float, float, float], bool]] = [] so_show_name = (hasattr(so_item, "pdf_properties") and so_item.pdf_properties and so_item.pdf_properties.show_name) or show_name + so_color = ( + so_item.pdf_properties.line_color + if (hasattr(so_item, "pdf_properties") and so_item.pdf_properties and so_item.pdf_properties.line_color is not None) + else stroke_color + ) if so_show_name: - lines_to_draw.append((str(obj_name), stroke_color)) + lines_to_draw.append((str(obj_name), so_color, True)) if show_bbox_size: if main_step >= 1000.0: @@ -465,7 +557,7 @@ def export_mesh_to_pdf( 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, bbox_color)) + lines_to_draw.append((size_str, bbox_color, False)) for key_name, suffix in settings_items: attr_val = get_first_attribute_value(so_item, key_name) @@ -476,19 +568,45 @@ def export_mesh_to_pdf( val_str = str(attr_val) if suffix: val_str = f"{val_str}{suffix}" - lines_to_draw.append((f"{key_name}: {val_str}", stroke_color)) + lines_to_draw.append((f"{key_name}: {val_str}", stroke_color, False)) if lines_to_draw: - s.begin_text().font("F1", eff_font_size_pt) - - for line_idx, (line_str, col) in enumerate(lines_to_draw): + for line_idx, (line_str, col, is_bold) in enumerate(lines_to_draw): cr, cg, cb = col - s.fill_color(cr, cg, cb) curr_y_pt = y_text_pt - (line_idx * line_spacing_pt) + s.begin_text().font("F1", eff_font_size_pt) + s.fill_color(cr, cg, cb) + if is_bold: + bold_stroke_w = max(0.2, eff_font_size_pt * 0.035) + s.stroke_color(cr, cg, cb).line_width(bold_stroke_w).text_rendering_mode(2) + else: + s.text_rendering_mode(0) s.text_position(x_text_pt, curr_y_pt) s.show(line_str) + if is_bold: + s.text_rendering_mode(0) + s.end_text() - s.end_text() + # 8. Technical Drawing Header Block (Bottom Right Table) + if draw_header and header_info: + primary_obj_name = "" + if slicing_objs_list: + primary_obj_name = str(getattr(slicing_objs_list[0], "name", "")) + elif raw_geometry is not None and hasattr(raw_geometry, "name"): + primary_obj_name = str(getattr(raw_geometry, "name", "")) + + render_technical_header_table( + s=s, + page_w_pt=page_w_pt, + page_h_pt=page_h_pt, + total_margin_mm=total_margin_mm, + header_info=header_info, + stroke_color=stroke_color, + bg_color=bg_color, + font_size_pt=font_size_pt, + line_width_pt=line_width_pt, + obj_name=primary_obj_name, + ) stream_bytes = s.encode() diff --git a/doc/NODES.md b/doc/NODES.md index 2932050..c0e588d 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -124,7 +124,8 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Load Gcode**: Reads a G-code file from disk. - **Gcode to File**: Writes G-code string to a file on disk. - **Automatic Binary Conversion**: If the G-code contains the comment `; binary_gcode = 1`, the node automatically converts the output to Prusa's binary G-code format (`.bgcode`) using MeatPack encoding and Heatshrink compression. -- **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. +- **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. Subpanel menus are collapsed by default. + - **Technical Drawing Header**: Collapsible menu containing string inputs for technical drawing info (Company, Part Name, Drawing No., Author, Revision, Material, Tolerance). Automatically computes ISO date and scale length (`1:1`). Renders an ISO/DIN technical drawing header table on the bottom-right of the drawing. - **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. diff --git a/tests/test_pdf_exporter.py b/tests/test_pdf_exporter.py index c551c0b..e5ad844 100644 --- a/tests/test_pdf_exporter.py +++ b/tests/test_pdf_exporter.py @@ -53,10 +53,40 @@ from NodeSlicer.core.pdf_exporter import ( escape_pdf_text, MM_TO_PT ) +import zlib + + + +def decompress_pdf_streams(pdf_bytes: bytes) -> str: + parts = [pdf_bytes.decode("utf-8", errors="ignore")] + idx = 0 + while True: + s_idx = pdf_bytes.find(b"stream\n", idx) + offset = 7 + if s_idx == -1: + s_idx = pdf_bytes.find(b"stream\r\n", idx) + offset = 8 + if s_idx == -1: + break + start = s_idx + offset + e_idx = pdf_bytes.find(b"\nendstream", start) + if e_idx == -1: + e_idx = pdf_bytes.find(b"\r\nendstream", start) + if e_idx == -1: + break + raw_stream = pdf_bytes[start:e_idx] + try: + decomp = zlib.decompress(raw_stream) + parts.append(decomp.decode("utf-8", errors="ignore")) + except Exception: + parts.append(raw_stream.decode("utf-8", errors="ignore")) + idx = e_idx + 9 + return "\n".join(parts) class TestPdfExporter(unittest.TestCase): + def test_escape_pdf_text_special_characters(self): escaped = escape_pdf_text("Ø8mm (test) 45° × 2 ± 0.1 µm") # With cp1252 encoding, special chars pass through directly @@ -180,6 +210,22 @@ class TestPdfExporter(unittest.TestCase): # All 3 outer boundary edges of the top triangle meeting perpendicular side walls must be detected self.assertEqual(len(contour), 3) + def test_extract_outer_contour_edges_stepped_top_surface(self): + so = SlicingObject() + # High top face (Z=10): [0, 0] to [5, 10] + tri_high1 = np.array([[0.0, 0.0, 10.0], [5.0, 0.0, 10.0], [5.0, 10.0, 10.0], [0.0, 0.0, 1.0]], dtype=np.float64) + tri_high2 = np.array([[0.0, 0.0, 10.0], [5.0, 10.0, 10.0], [0.0, 10.0, 10.0], [0.0, 0.0, 1.0]], dtype=np.float64) + # Low top face (Z=5): [5, 0] to [10, 10] + tri_low1 = np.array([[5.0, 0.0, 5.0], [10.0, 0.0, 5.0], [10.0, 10.0, 5.0], [0.0, 0.0, 1.0]], dtype=np.float64) + tri_low2 = np.array([[5.0, 0.0, 5.0], [10.0, 10.0, 5.0], [5.0, 10.0, 5.0], [0.0, 0.0, 1.0]], dtype=np.float64) + + so.mesh = np.array([tri_high1, tri_high2, tri_low1, tri_low2]) + + contour = extract_outer_contour_edges(so) + # 2D Polygon Union dissolves the internal shared edge at X=5 between high and low Z steps, + # yielding exactly the 4 outer boundary edges of the combined 10x10 square. + self.assertEqual(len(contour), 4) + def test_export_pdf_true_1_to_1_scale(self): edges_2d = np.array([ [[0.0, 0.0], [100.0, 0.0]], @@ -206,9 +252,9 @@ class TestPdfExporter(unittest.TestCase): minor_step = 1.0 # main_step 10.0 / 10 total_margin_mm = margin_mm + 2.0 * minor_step - # 100mm geometry padded by 1 minor_step clearance expands grid to 120mm - expected_w = (120.0 + 2 * total_margin_mm) * MM_TO_PT - expected_h = (120.0 + 2 * total_margin_mm) * MM_TO_PT + # 100mm geometry padded by 1 minor_step clearance expands grid to 102mm (rounded to minor_step) + expected_w = (102.0 + 2 * total_margin_mm) * MM_TO_PT + expected_h = (102.0 + 2 * total_margin_mm) * MM_TO_PT mediabox_str = f"/MediaBox [0 0 {expected_w:.2f} {expected_h:.2f}]".encode("utf-8") self.assertTrue(content.startswith(b"%PDF-1.4")) @@ -421,8 +467,8 @@ class TestPdfExporter(unittest.TestCase): drawn_obj_h_pt = max(obj_y) - min(obj_y) # Assert that actual drawn line span in PDF points EXACTLY equals 3D mesh dimensions - self.assertAlmostEqual(drawn_obj_w_pt, 120.0 * (72.0 / 25.4), places=2) - self.assertAlmostEqual(drawn_obj_h_pt, 80.0 * (72.0 / 25.4), places=2) + self.assertAlmostEqual(drawn_obj_w_pt, 120.0 * (72.0 / 25.4), places=1) + self.assertAlmostEqual(drawn_obj_h_pt, 80.0 * (72.0 / 25.4), places=1) def test_complex_slicing_group_pdf_exporter_end_to_end(self): """ @@ -770,33 +816,6 @@ class TestPdfExporter(unittest.TestCase): self.assertEqual(obj.pdf_properties.line_color, (0.5, 0.5, 1.0)) self.assertTrue(obj.pdf_properties.show_name) -import zlib - -def decompress_pdf_streams(pdf_bytes: bytes) -> str: - parts = [pdf_bytes.decode("utf-8", errors="ignore")] - idx = 0 - while True: - s_idx = pdf_bytes.find(b"stream\n", idx) - offset = 7 - if s_idx == -1: - s_idx = pdf_bytes.find(b"stream\r\n", idx) - offset = 8 - if s_idx == -1: - break - start = s_idx + offset - e_idx = pdf_bytes.find(b"\nendstream", start) - if e_idx == -1: - e_idx = pdf_bytes.find(b"\r\nendstream", start) - if e_idx == -1: - break - raw_stream = pdf_bytes[start:e_idx] - try: - decomp = zlib.decompress(raw_stream) - parts.append(decomp.decode("utf-8", errors="ignore")) - except Exception: - parts.append(raw_stream.decode("utf-8", errors="ignore")) - idx = e_idx + 9 - return "\n".join(parts) def test_export_pdf_with_per_object_pdf_properties(self): @@ -844,6 +863,9 @@ def decompress_pdf_streams(pdf_bytes: bytes) -> str: # Check RedBox label is rendered (show_name=True on so1) self.assertIn("(RedBox) Tj", content) self.assertNotIn("(GreenBox) Tj", content) + # Check name is rendered in bold (2 Tr mode) and uses object line color (1.000 0.000 0.000 rg) + self.assertIn("2 Tr", content) + self.assertIn("1.000 0.000 0.000 rg", content) def test_export_pdf_fallback_color_and_default_line_width(self): from NodeSlicer.infra.blender.blender_mesh_capture import PdfProperties @@ -873,12 +895,107 @@ def decompress_pdf_streams(pdf_bytes: bytes) -> str: # Wireframe color (0.200 0.400 0.600 RG) should be used self.assertIn("0.200 0.400 0.600 RG", content) - def test_mesh_to_pdf_node_removed_line_width_and_show_name(self): + def test_export_pdf_object_name_color_and_bold(self): + from NodeSlicer.infra.blender.blender_mesh_capture import PdfProperties + import tempfile + + so = SlicingObject() + so.name = "BoldColoredPart" + so.mesh = 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.pdf_properties = PdfProperties(line_width=1.0, line_color=(0.8, 0.1, 0.2), show_name=True) + + edges_2d = extract_unique_top_view_edges(so) + + with tempfile.TemporaryDirectory() as tmpdir: + pdf_path = os.path.join(tmpdir, "bold_colored_name.pdf") + export_mesh_to_pdf( + edges_2d, + pdf_path, + draw_wireframe=True, + stroke_color=(0.0, 0.0, 0.0), + raw_geometry=so + ) + + with open(pdf_path, "rb") as f: + content = decompress_pdf_streams(f.read()) + + self.assertIn("(BoldColoredPart) Tj", content) + # Verify bold text mode (2 Tr) is used for rendering the object name + self.assertIn("2 Tr", content) + # Verify object line color (0.800 0.100 0.200 rg) is used for filling and stroking the name text + self.assertIn("0.800 0.100 0.200 rg", content) + self.assertIn("0.800 0.100 0.200 RG", content) + + def test_mesh_to_pdf_technical_header_properties_and_defaults(self): from NodeSlicer.core.nodeslicer.nodes.output import SLICENODE_MeshToPdf - node = SLICENODE_MeshToPdf() - self.assertFalse(hasattr(node, "line_width")) - self.assertFalse(hasattr(node, "show_name")) + # Verify header properties exist on class annotations + annotations = SLICENODE_MeshToPdf.__annotations__ + self.assertIn("show_header", annotations) + self.assertIn("show_lineart", annotations) + self.assertIn("show_sa2", annotations) + self.assertIn("draw_header", annotations) + self.assertIn("header_company", annotations) + self.assertIn("header_part_name", annotations) + self.assertIn("header_drawing_number", annotations) + self.assertIn("header_author", annotations) + self.assertIn("header_revision", annotations) + self.assertIn("header_material", annotations) + self.assertIn("header_tolerance", annotations) + + def test_export_pdf_technical_header_table(self): + import tempfile + + so = SlicingObject() + so.name = "EnginePlate" + so.mesh = 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) + + edges_2d = extract_unique_top_view_edges(so) + header_info = { + "company": "Acme Corp", + "part_name": "", # Should fall back to EnginePlate + "drawing_number": "DWG-1001", + "author": "J. Doe", + "revision": "B", + "material": "AL-6061", + "tolerance": "ISO 2768-m", + "date": "2026-08-03", + "scale": "1:1", + } + + with tempfile.TemporaryDirectory() as tmpdir: + pdf_path = os.path.join(tmpdir, "technical_header_table.pdf") + export_mesh_to_pdf( + edges_2d, + pdf_path, + draw_wireframe=True, + draw_header=True, + header_info=header_info, + raw_geometry=so, + ) + + with open(pdf_path, "rb") as f: + content = decompress_pdf_streams(f.read()) + + # Check table cell labels present + self.assertIn("(COMPANY) Tj", content) + self.assertIn("(DWG NO) Tj", content) + self.assertIn("(REV) Tj", content) + self.assertIn("(PART NAME) Tj", content) + self.assertIn("(MATERIAL) Tj", content) + self.assertIn("(TOLERANCE) Tj", content) + self.assertIn("(AUTHOR) Tj", content) + self.assertIn("(DATE) Tj", content) + self.assertIn("(SCALE) Tj", content) + + # Check table values present + self.assertIn("(Acme Corp) Tj", content) + self.assertIn("(EnginePlate) Tj", content) + self.assertIn("(DWG-1001) Tj", content) + self.assertIn("(J. Doe) Tj", content) + self.assertIn("(AL-6061) Tj", content) + self.assertIn("(1:1) Tj", content) if __name__ == '__main__': unittest.main() +