From 32e43c748b18491b9e490259ced5571e5f4ebd4d Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Fri, 7 Aug 2026 16:04:51 +0300 Subject: [PATCH] grid --- core/nodeslicer/nodes/output.py | 15 ++ core/pdf_exporter/geometry.py | 231 +++++++++++++++++++----------- core/pdf_exporter/sa2_measures.py | 3 + core/pdf_exporter/writer.py | 108 ++++++++++---- tests/test_pdf_exporter.py | 55 +++++++ 5 files changed, 305 insertions(+), 107 deletions(-) diff --git a/core/nodeslicer/nodes/output.py b/core/nodeslicer/nodes/output.py index 5161c4b..29c88f4 100644 --- a/core/nodeslicer/nodes/output.py +++ b/core/nodeslicer/nodes/output.py @@ -367,6 +367,10 @@ class SLICENODE_MeshToPdf(ExecSliceNode): draw_sa2_holes: NfBoolProperty(name="Holes (Ø)", default=False) # 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] + draw_grid_xy: NfBoolProperty(name="XY Grid (Top)", default=True) # pyright: ignore[reportInvalidTypeForm] + draw_grid_xz: NfBoolProperty(name="XZ Grid (Front)", default=False) # pyright: ignore[reportInvalidTypeForm] + draw_grid_yz: NfBoolProperty(name="YZ Grid (Side)", default=False) # pyright: ignore[reportInvalidTypeForm] + show_grids: NfBoolProperty(name="Grids", 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] @@ -448,6 +452,14 @@ class SLICENODE_MeshToPdf(ExecSliceNode): box = layout.box() box.prop(self, "draw_sa2_holes") + # Grid Display subpanel + layout.prop(self, "show_grids", text="Grids", icon='TRIA_DOWN' if self.show_grids else 'TRIA_RIGHT') + if self.show_grids: + box = layout.box() + box.prop(self, "draw_grid_xy") + box.prop(self, "draw_grid_xz") + box.prop(self, "draw_grid_yz") + # Display settings layout.prop(self, "draw_bbox") if self.draw_bbox: @@ -599,6 +611,9 @@ class SLICENODE_MeshToPdf(ExecSliceNode): bbox_color=(float(self.bbox_color[0]), float(self.bbox_color[1]), float(self.bbox_color[2])), raw_geometry=current_raw_geom, slice_paths=current_slice_paths, + draw_grid_xy=getattr(self, "draw_grid_xy", True), + draw_grid_xz=getattr(self, "draw_grid_xz", False), + draw_grid_yz=getattr(self, "draw_grid_yz", False), ) if operator: operator.report({'INFO'}, f"Mesh PDF saved to {filepath}") except Exception as e: diff --git a/core/pdf_exporter/geometry.py b/core/pdf_exporter/geometry.py index 0597644..e209fb0 100644 --- a/core/pdf_exporter/geometry.py +++ b/core/pdf_exporter/geometry.py @@ -144,6 +144,26 @@ def get_first_object_rotation( return (0.0, 0.0, 0.0) +def map_2d_to_3d(u: float, v: float, d: float, plane: str = "XY") -> NDArray[np.float64]: + p = plane.upper() + if p == "XZ": + return np.array([u, d, v], dtype=np.float64) + elif p == "YZ": + return np.array([d, u, v], dtype=np.float64) + else: # XY + return np.array([u, v, d], dtype=np.float64) + + +def get_plane_2d_indices(plane: str = "XY") -> tuple[list[int], int]: + p = plane.upper() + if p == "XZ": + return [0, 2], 1 + elif p == "YZ": + return [1, 2], 0 + else: # XY + return [0, 1], 2 + + def generate_grid_slice_paths( grid_min_x: float, grid_max_x: float, @@ -152,10 +172,11 @@ def generate_grid_slice_paths( minor_step: float, max_grid_steps: int = 2000, z_level: float = 0.0, + plane: str = "XY", ) -> tuple[Any, Any]: """ - Generates 3D grid line geometry sitting at local Z=z_level as two SlicePaths objects: - (minor_grid_slice_paths, main_grid_slice_paths). + Generates 3D grid line geometry sitting at local depth d=z_level on specified local plane (XY, XZ, YZ) + as two SlicePaths objects: (minor_grid_slice_paths, main_grid_slice_paths). """ from ..slice import SlicePaths @@ -173,19 +194,23 @@ def generate_grid_slice_paths( minor_paths: list[NDArray[np.float64]] = [] main_paths: list[NDArray[np.float64]] = [] - # Vertical grid lines (constant X) + # Vertical grid lines (constant U) for k in k_x: - gx = float(k * minor_step) - line = np.array([[gx, grid_min_y, z_level], [gx, grid_max_y, z_level]], dtype=np.float64) + gu = float(k * minor_step) + p1 = map_2d_to_3d(gu, grid_min_y, z_level, plane) + p2 = map_2d_to_3d(gu, grid_max_y, z_level, plane) + line = np.array([p1, p2], dtype=np.float64) if k % 10 == 0: main_paths.append(line) else: minor_paths.append(line) - # Horizontal grid lines (constant Y) + # Horizontal grid lines (constant V) for k in k_y: - gy = float(k * minor_step) - line = np.array([[grid_min_x, gy, z_level], [grid_max_x, gy, z_level]], dtype=np.float64) + gv = float(k * minor_step) + p1 = map_2d_to_3d(grid_min_x, gv, z_level, plane) + p2 = map_2d_to_3d(grid_max_x, gv, z_level, plane) + line = np.array([p1, p2], dtype=np.float64) if k % 10 == 0: main_paths.append(line) else: @@ -203,9 +228,15 @@ def generate_legend_slice_paths( line_width_pt: float, font_size_pt: float, z_level: float = 0.0, + plane: str = "XY", + draw_x: bool = True, + draw_y: bool = True, + draw_z: bool = False, + local_origin_3d: tuple[float, float, float] | None = None, + local_max_3d: tuple[float, float, float] | None = None, ) -> tuple[Any, list[dict[str, Any]]]: """ - Generates 3D legend line geometry (axes, arrowheads, tick marks) sitting at local Z=z_level + Generates 3D legend line geometry (axes, arrowheads, tick marks) starting at origin (min x, max y, min z) as a SlicePaths object, alongside tick and label metadata for text positioning. Returns: (legend_slice_paths, label_metadata_list) """ @@ -213,74 +244,129 @@ def generate_legend_slice_paths( from .sizing import LegendSizing, MM_TO_PT leg_sizing = LegendSizing(main_step, minor_step, line_width_pt, font_size_pt) - x0 = grid_min_x - y0 = grid_min_y - x_grid_end_mm = min(grid_max_x, grid_min_x + 10.0 * main_step) - num_main_steps = max(1, int(np.round((x_grid_end_mm - grid_min_x) / main_step))) + if local_origin_3d is not None: + x0, y0, z0 = local_origin_3d + else: + x0 = grid_min_x + y0 = grid_min_y + z0 = z_level + + max_x_val = local_max_3d[0] if local_max_3d else grid_max_x + min_y_val = local_max_3d[1] if local_max_3d else grid_min_y + max_z_val = local_max_3d[2] if local_max_3d else z_level arrow_len_mm = leg_sizing.arrow_len_pt / MM_TO_PT arrow_half_w_mm = leg_sizing.arrow_half_w_pt / MM_TO_PT label_margin_mm = leg_sizing.label_margin_h_pt / MM_TO_PT - x_arrow_base = x_grid_end_mm + leg_sizing.x_axis_extension_mm - x_arrow_tip = x_arrow_base + arrow_len_mm - - y_arrow_base = y0 + leg_sizing.y_axis_height_mm - y_arrow_tip = y_arrow_base + arrow_len_mm - legend_paths: list[NDArray[np.float64]] = [] - - # 1. Main horizontal X-axis line & Arrowhead - legend_paths.append(np.array([[x0, y0, z_level], [x_arrow_tip, y0, z_level]], dtype=np.float64)) - legend_paths.append(np.array([[x_arrow_base, y0 + arrow_half_w_mm, z_level], [x_arrow_tip, y0, z_level]], dtype=np.float64)) - legend_paths.append(np.array([[x_arrow_tip, y0, z_level], [x_arrow_base, y0 - arrow_half_w_mm, z_level]], dtype=np.float64)) - - # 2. X-axis tick marks & label positions label_meta: list[dict[str, Any]] = [] - for k in range(num_main_steps + 1): - xk = x0 + k * main_step - tick_h = leg_sizing.major_tick_mm if k in (0, 5, num_main_steps) else leg_sizing.minor_tick_mm - legend_paths.append(np.array([[xk, y0, z_level], [xk, y0 + tick_h, z_level]], dtype=np.float64)) + # 1. Local X Axis (extends in +X direction from x0) + if draw_x: + x_grid_end_mm = min(max_x_val, x0 + 10.0 * main_step) if max_x_val > x0 else x0 + 10.0 * main_step + num_main_steps = max(1, int(np.round(abs(x_grid_end_mm - x0) / main_step))) - if k > 0: - val_mm = k * main_step - if main_step >= 1000.0: - val_num = val_mm / 1000.0 - unit = " m" if k == num_main_steps else "" - tick_str = f"{val_num:g}{unit}" - else: - unit = " mm" if k == num_main_steps else "" - tick_str = f"{val_mm:g}{unit}" + x_arrow_base = x_grid_end_mm + leg_sizing.x_axis_extension_mm + x_arrow_tip = x_arrow_base + arrow_len_mm - label_meta.append({ - "type": "tick", - "text": tick_str, - "local_pt": (xk, y0 + tick_h + label_margin_mm, z_level), - }) + legend_paths.append(np.array([[x0, y0, z0], [x_arrow_tip, y0, z0]], dtype=np.float64)) + legend_paths.append(np.array([[x_arrow_base, y0 + arrow_half_w_mm, z0], [x_arrow_tip, y0, z0]], dtype=np.float64)) + legend_paths.append(np.array([[x_arrow_tip, y0, z0], [x_arrow_base, y0 - arrow_half_w_mm, z0]], dtype=np.float64)) - # 3. Main vertical Y-axis line & Arrowhead - legend_paths.append(np.array([[x0, y0, z_level], [x0, y_arrow_tip, z_level]], dtype=np.float64)) - legend_paths.append(np.array([[x0 - arrow_half_w_mm, y_arrow_base, z_level], [x0, y_arrow_tip, z_level]], dtype=np.float64)) - legend_paths.append(np.array([[x0, y_arrow_tip, z_level], [x0 + arrow_half_w_mm, y_arrow_base, z_level]], dtype=np.float64)) + for k in range(num_main_steps + 1): + xk = x0 + k * main_step + tick_h = leg_sizing.major_tick_mm if k in (0, 5, num_main_steps) else leg_sizing.minor_tick_mm + legend_paths.append(np.array([[xk, y0, z0], [xk, y0 - tick_h, z0]], dtype=np.float64)) - # 4. Axis labels meta - label_meta.append({ - "type": "x_axis", - "text": "X", - "local_pt": (x_arrow_tip + label_margin_mm, y0, z_level), - }) - label_meta.append({ - "type": "y_axis", - "text": "Y", - "local_pt": (x0, y_arrow_tip + label_margin_mm, z_level), - }) + if k > 0: + val_mm = k * main_step + unit = " m" if (main_step >= 1000.0 and k == num_main_steps) else (" mm" if (main_step < 1000.0 and k == num_main_steps) else "") + val_str = f"{val_mm/1000.0:g}{unit}" if main_step >= 1000.0 else f"{val_mm:g}{unit}" + label_meta.append({ + "type": "tick", + "text": val_str, + "local_pt": (xk, y0 - tick_h - label_margin_mm, z0), + }) + + label_meta.append({ + "type": "x_axis", + "text": "X", + "local_pt": (x_arrow_tip + label_margin_mm, y0, z0), + }) + + # 2. Local Y Axis (extends in -Y direction from y0 towards min_y_val) + if draw_y: + y_grid_end_mm = max(min_y_val, y0 - 10.0 * main_step) if min_y_val < y0 else y0 - 10.0 * main_step + num_main_steps = max(1, int(np.round(abs(y0 - y_grid_end_mm) / main_step))) + + y_arrow_base = y_grid_end_mm - leg_sizing.y_axis_height_mm + y_arrow_tip = y_arrow_base - arrow_len_mm + + legend_paths.append(np.array([[x0, y0, z0], [x0, y_arrow_tip, z0]], dtype=np.float64)) + legend_paths.append(np.array([[x0 - arrow_half_w_mm, y_arrow_base, z0], [x0, y_arrow_tip, z0]], dtype=np.float64)) + legend_paths.append(np.array([[x0, y_arrow_tip, z0], [x0 + arrow_half_w_mm, y_arrow_base, z0]], dtype=np.float64)) + + if not draw_x: + for k in range(num_main_steps + 1): + yk = y0 - k * main_step + tick_h = leg_sizing.major_tick_mm if k in (0, 5, num_main_steps) else leg_sizing.minor_tick_mm + legend_paths.append(np.array([[x0, yk, z0], [x0 + tick_h, yk, z0]], dtype=np.float64)) + + if k > 0: + val_mm = k * main_step + unit = " m" if (main_step >= 1000.0 and k == num_main_steps) else (" mm" if (main_step < 1000.0 and k == num_main_steps) else "") + val_str = f"{val_mm/1000.0:g}{unit}" if main_step >= 1000.0 else f"{val_mm:g}{unit}" + label_meta.append({ + "type": "tick", + "text": val_str, + "local_pt": (x0 + tick_h + label_margin_mm, yk, z0), + }) + + label_meta.append({ + "type": "y_axis", + "text": "Y", + "local_pt": (x0, y_arrow_tip - label_margin_mm, z0), + }) + + # 3. Local Z Axis (extends in +Z direction from z0 towards max_z_val) + if draw_z: + z_grid_end_mm = min(max_z_val, z0 + 10.0 * main_step) if max_z_val > z0 else z0 + 10.0 * main_step + num_main_steps = max(1, int(np.round(abs(z_grid_end_mm - z0) / main_step))) + + z_arrow_base = z_grid_end_mm + leg_sizing.y_axis_height_mm + z_arrow_tip = z_arrow_base + arrow_len_mm + + legend_paths.append(np.array([[x0, y0, z0], [x0, y0, z_arrow_tip]], dtype=np.float64)) + legend_paths.append(np.array([[x0 - arrow_half_w_mm, y0, z_arrow_base], [x0, y0, z_arrow_tip]], dtype=np.float64)) + legend_paths.append(np.array([[x0, y0, z_arrow_tip], [x0 + arrow_half_w_mm, y0, z_arrow_base]], dtype=np.float64)) + + if not draw_x: + for k in range(num_main_steps + 1): + zk = z0 + k * main_step + tick_h = leg_sizing.major_tick_mm if k in (0, 5, num_main_steps) else leg_sizing.minor_tick_mm + legend_paths.append(np.array([[x0, y0, zk], [x0 + tick_h, y0, zk]], dtype=np.float64)) + + if k > 0: + val_mm = k * main_step + unit = " m" if (main_step >= 1000.0 and k == num_main_steps) else (" mm" if (main_step < 1000.0 and k == num_main_steps) else "") + val_str = f"{val_mm/1000.0:g}{unit}" if main_step >= 1000.0 else f"{val_mm:g}{unit}" + label_meta.append({ + "type": "tick", + "text": val_str, + "local_pt": (x0 + tick_h + label_margin_mm, y0, zk), + }) + + label_meta.append({ + "type": "z_axis", + "text": "Z", + "local_pt": (x0, y0, z_arrow_tip + label_margin_mm), + }) return SlicePaths(legend_paths), label_meta - 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. @@ -298,7 +384,8 @@ def extract_slice_paths_edges(slice_paths: Any) -> NDArray[np.float64]: 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] + p_arr = np.asarray(path, dtype=np.float64) + pts = p_arr[:, :2] segs = np.stack([pts[:-1], pts[1:]], axis=1) all_segments.append(segs) elif isinstance(item, (list, tuple)): @@ -440,20 +527,14 @@ def extract_lineart_edges(mesh_data: Any, max_angle_deg: float = 30.0) -> NDArra if num_tris == 0: return np.empty((0, 2, 2), dtype=np.float64) - # Top-view 2D vertices + # 2D vertices projected on plane verts_2d = tris_3d[:, :, :2] # (N, 3, 2) - # For silhouette detection, categorize faces by normal.z sign - # For view direction (0,0,-1): - # - normal.z < 0: front-facing (facing viewer) - # - normal.z > 0: back-facing (facing away) - # - normal.z == 0: edge-on / vertical normal_z = normals_unit[:, 2] 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) # Extract all 3 edges per triangle e0_3d = tris_3d[:, [0, 1], :] @@ -481,7 +562,6 @@ def extract_lineart_edges(mesh_data: Any, max_angle_deg: float = 30.0) -> NDArra if len(edges_3d) == 0: return np.empty((0, 2, 2), dtype=np.float64) - # Canonical undirected edge sorting in 3D (rounded to 4 decimal places) rounded_3d = np.round(edges_3d, decimals=4) pA = rounded_3d[:, 0, :] pB = rounded_3d[:, 1, :] @@ -497,7 +577,6 @@ def extract_lineart_edges(mesh_data: Any, max_angle_deg: float = 30.0) -> NDArra canonical_2d = edges_2d.copy() canonical_2d[swap_mask] = canonical_2d[swap_mask][:, ::-1, :] - # Unique 3D edge identification using NumPy structured view 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( @@ -511,13 +590,9 @@ def extract_lineart_edges(mesh_data: Any, max_angle_deg: float = 30.0) -> NDArra np.maximum.at(max_cat, inverse_indices, edge_category) np.minimum.at(min_cat, inverse_indices, edge_category) - # Rule 1: Silhouette edge - adjacent faces have different categories rule1_unique = (counts >= 2) & (max_cat != min_cat) - - # Rule 2: Boundary edge (owned by 1 face) rule2_unique = counts == 1 - # Rule 3: Crease edge (owned by 2+ faces with normal angle > max_angle_deg) cos_threshold = math.cos(math.radians(max_angle_deg)) rule3_unique = np.zeros(num_unique, dtype=bool) @@ -527,7 +602,6 @@ def extract_lineart_edges(mesh_data: Any, max_angle_deg: float = 30.0) -> NDArra sorted_edge_ids = inverse_indices[sort_by_edge] sorted_normals = edge_normals[sort_by_edge] - # Vectorized pair matching for adjacent faces sharing the same edge ID match = sorted_edge_ids[:-1] == sorted_edge_ids[1:] if np.any(match): n1 = sorted_normals[:-1][match] @@ -688,8 +762,6 @@ def extract_silhouette_contour_edges(mesh_data: Any) -> NDArray[np.float64]: if num_tris == 0: return np.empty((0, 2, 2), dtype=np.float64) - # 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] epsilon = 1e-3 front_mask = normal_z > epsilon @@ -698,7 +770,6 @@ def extract_silhouette_contour_edges(mesh_data: Any) -> NDArray[np.float64]: 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], :] @@ -710,7 +781,6 @@ def extract_silhouette_contour_edges(mesh_data: Any) -> NDArray[np.float64]: e2_2d = verts_2d[:, [2, 0], :] 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) valid_len_mask = dists_sq_2d >= 1e-8 @@ -720,7 +790,6 @@ def extract_silhouette_contour_edges(mesh_data: Any) -> NDArray[np.float64]: if len(edges_3d) == 0: return np.empty((0, 2, 2), dtype=np.float64) - # Canonical undirected edge sorting in 3D rounded_3d = np.round(edges_3d, decimals=4) pA = rounded_3d[:, 0, :] pB = rounded_3d[:, 1, :] @@ -736,14 +805,12 @@ def extract_silhouette_contour_edges(mesh_data: Any) -> NDArray[np.float64]: canonical_2d = edges_2d.copy() canonical_2d[swap_mask] = canonical_2d[swap_mask][:, ::-1, :] - # 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() _, first_indices, counts = np.unique( view_bytes, return_index=True, return_counts=True ) - # 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/sa2_measures.py b/core/pdf_exporter/sa2_measures.py index d7de640..cc48d2f 100644 --- a/core/pdf_exporter/sa2_measures.py +++ b/core/pdf_exporter/sa2_measures.py @@ -14,6 +14,7 @@ from .geometry import ( extract_boundary_edges, extract_outer_contour_edges, extract_crease_edges, + get_plane_2d_indices, ) from .sizing import SA2Sizing, compute_effective_stroke_and_font, MM_TO_PT from .pdf_utils import PdfStream, escape_pdf_text @@ -156,6 +157,7 @@ def extract_slicing_object_features( draw_crease: bool = False, crease_angle: float = 30.0, bounds: tuple[float, float, float, float] | None = None, + plane: str = "XY", ) -> list[tuple[float, float, float]]: """ Extracts circular bore / hole features (center_x, center_y, diameter_mm) from a SlicingObject @@ -511,6 +513,7 @@ def render_sa2_measures_pdf( draw_contour: bool = False, draw_crease: bool = False, crease_angle: float = 30.0, + plane: str = "XY", ) -> None: """ Renders ISO 129-1 compliant SA2 hole measurements (bores, concentric steps, Z-depth, pitch) on PDF stream. diff --git a/core/pdf_exporter/writer.py b/core/pdf_exporter/writer.py index 336f239..b610af2 100644 --- a/core/pdf_exporter/writer.py +++ b/core/pdf_exporter/writer.py @@ -484,14 +484,18 @@ class PDFLegendElement(PDFElement): # Generate legend 3D SlicePaths & label metadata legend_sp, label_meta = generate_legend_slice_paths( - exp.local_min_x, - exp.local_min_y, - exp.local_max_x, + exp.local_min_x_3d, + exp.local_min_y_3d, + exp.local_max_x_3d, exp.main_step, exp.minor_step, exp.line_width_pt, exp.font_size_pt, - z_level=getattr(exp, "local_min_z", 0.0), + draw_x=getattr(exp, "draw_x_axis", True), + draw_y=getattr(exp, "draw_y_axis", True), + draw_z=getattr(exp, "draw_z_axis", False), + local_origin_3d=getattr(exp, "legend_origin_3d", None), + local_max_3d=getattr(exp, "legend_max_3d", None), ) # Rotate legend SlicePaths with existing transform_slice_paths @@ -725,8 +729,22 @@ class export_mesh_to_pdf: raw_geometry: Any = None, slice_paths: Any = None, settings: Any = None, + projection_plane: str | None = None, + draw_grid_xy: bool = True, + draw_grid_xz: bool = False, + draw_grid_yz: bool = False, **kwargs: Any, ) -> None: + if projection_plane is not None: + p_up = projection_plane.upper() + draw_grid_xy = (p_up == "XY") + draw_grid_xz = (p_up == "XZ") + draw_grid_yz = (p_up == "YZ") + + self.draw_grid_xy = draw_grid_xy + self.draw_grid_xz = draw_grid_xz + self.draw_grid_yz = draw_grid_yz + self.projection_plane = "XY" if draw_grid_xy else ("XZ" if draw_grid_xz else "YZ") 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 @@ -860,37 +878,77 @@ class export_mesh_to_pdf: l_min_x, l_max_x = float(pts_local[:, 0].min()), float(pts_local[:, 0].max()) l_min_y, l_max_y = float(pts_local[:, 1].min()), float(pts_local[:, 1].max()) l_min_z, l_max_z = float(pts_local[:, 2].min()), float(pts_local[:, 2].max()) - if R.T[2, 1] < -1e-6: - l_min_z = l_max_z else: - l_min_x, l_max_x, l_min_y, l_max_y = -50.0, 50.0, -50.0, 50.0 - l_min_z = 0.0 + l_min_x, l_max_x, l_min_y, l_max_y, l_min_z, l_max_z = -50.0, 50.0, -50.0, 50.0, -50.0, 50.0 + + w_geom, h_geom = max(l_max_x - l_min_x, 1.0), max(l_max_y - l_min_y, 1.0) + if self.draw_grid_xz and not self.draw_grid_xy: + w_geom, h_geom = max(l_max_x - l_min_x, 1.0), max(l_max_z - l_min_z, 1.0) + elif self.draw_grid_yz and not self.draw_grid_xy and not self.draw_grid_xz: + w_geom, h_geom = max(l_max_y - l_min_y, 1.0), max(l_max_z - l_min_z, 1.0) - self.local_min_z = l_min_z - w_geom = max(l_max_x - l_min_x, 1.0) - h_geom = max(l_max_y - l_min_y, 1.0) self.main_step, self.minor_step = compute_grid_steps(w_geom, h_geom) - self.local_min_x = math.floor((l_min_x - self.minor_step) / self.minor_step) * self.minor_step - self.local_max_x = math.ceil((l_max_x + self.minor_step) / self.minor_step) * self.minor_step - self.local_min_y = math.floor((l_min_y - self.minor_step) / self.minor_step) * self.minor_step - self.local_max_y = math.ceil((l_max_y + self.minor_step) / self.minor_step) * self.minor_step + self.grid_min_x = math.floor((l_min_x - self.minor_step) / self.minor_step) * self.minor_step + self.grid_max_x = math.ceil((l_max_x + self.minor_step) / self.minor_step) * self.minor_step + self.grid_min_y = math.floor((l_min_y - self.minor_step) / self.minor_step) * self.minor_step + self.grid_max_y = math.ceil((l_max_y + self.minor_step) / self.minor_step) * self.minor_step + + # Object sits on XY grid: exact min Z (no margin below), margin over object on max Z + self.grid_min_z = l_min_z + self.grid_max_z = math.ceil((l_max_z + self.minor_step) / self.minor_step) * self.minor_step - # Generate 3D grid line SlicePaths sitting on local XY plane at the bottom of the object (Z=l_min_z) - minor_grid_sp, main_grid_sp = generate_grid_slice_paths( - self.local_min_x, - self.local_max_x, - self.local_min_y, - self.local_max_y, - self.minor_step, - z_level=l_min_z, - ) + self.local_min_x_3d = self.grid_min_x + self.local_max_x_3d = self.grid_max_x + self.local_min_y_3d = self.grid_min_y + self.local_max_y_3d = self.grid_max_y + self.local_min_z_3d = self.grid_min_z + self.local_max_z_3d = self.grid_max_z + + # Shared origin point (min X, max Y, min Z) + x0 = self.grid_min_x + y0 = self.grid_max_y + z0 = self.grid_min_z + + self.legend_origin_3d = (x0, y0, z0) + self.legend_max_3d = (self.grid_max_x, self.grid_min_y, self.grid_max_z) + + self.draw_x_axis = self.draw_grid_xy or self.draw_grid_xz + self.draw_y_axis = self.draw_grid_xy or self.draw_grid_yz + self.draw_z_axis = self.draw_grid_xz or self.draw_grid_yz + + self.local_min_x = self.grid_min_x + self.local_max_x = self.grid_max_x + self.local_min_y = self.grid_min_y + self.local_max_y = self.grid_max_y + self.local_min_z = z0 + + grid_minor_paths: list[NDArray[np.float64]] = [] + grid_main_paths: list[NDArray[np.float64]] = [] + + if self.draw_grid_xy: + min_sp, main_sp = generate_grid_slice_paths(self.grid_min_x, self.grid_max_x, self.grid_min_y, self.grid_max_y, self.minor_step, z_level=z0, plane="XY") + grid_minor_paths.extend(min_sp) + grid_main_paths.extend(main_sp) + + if self.draw_grid_xz: + min_sp, main_sp = generate_grid_slice_paths(self.grid_min_x, self.grid_max_x, self.grid_min_z, self.grid_max_z, self.minor_step, z_level=y0, plane="XZ") + grid_minor_paths.extend(min_sp) + grid_main_paths.extend(main_sp) + + if self.draw_grid_yz: + min_sp, main_sp = generate_grid_slice_paths(self.grid_min_y, self.grid_max_y, self.grid_min_z, self.grid_max_z, self.minor_step, z_level=x0, plane="YZ") + grid_minor_paths.extend(min_sp) + grid_main_paths.extend(main_sp) + + from ..slice import SlicePaths + minor_grid_sp = SlicePaths(grid_minor_paths) + main_grid_sp = SlicePaths(grid_main_paths) # Rotate grid SlicePaths back into world space rot_minor_sp = transform_slice_paths(minor_grid_sp, np.zeros(3), r_arr, np.ones(3)) rot_main_sp = transform_slice_paths(main_grid_sp, np.zeros(3), r_arr, np.ones(3)) - # Extract 2D top-view edge segments from rotated SlicePaths minor_edges_2d = extract_slice_paths_edges(rot_minor_sp) main_edges_2d = extract_slice_paths_edges(rot_main_sp) diff --git a/tests/test_pdf_exporter.py b/tests/test_pdf_exporter.py index c077be5..85c95d3 100644 --- a/tests/test_pdf_exporter.py +++ b/tests/test_pdf_exporter.py @@ -1687,6 +1687,61 @@ class TestPdfExporter(unittest.TestCase): self.assertGreater(len(minor_sp), 0) self.assertAlmostEqual(float(minor_sp[0][0, 2]), 15.0) + def test_projection_plane_selection_xz_and_yz(self): + from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject + from NodeSlicer.core.nodeslicer.nodes.output import SLICENODE_MeshToPdf + + so = SlicingObject() + so.name = "Box3D" + # 3D triangle spanning X: [10, 50], Y: [20, 80], Z: [5, 40] + tri = np.array([[10.0, 20.0, 5.0], [50.0, 20.0, 5.0], [10.0, 80.0, 40.0]], dtype=np.float64) + so.mesh = np.array([tri]) + + # Default XY plane + exp_xy = export_mesh_to_pdf(raw_geometry=so, draw_grid_xy=True, draw_grid_xz=False, draw_grid_yz=False) + self.assertTrue(exp_xy.draw_grid_xy) + self.assertFalse(exp_xy.draw_grid_xz) + self.assertGreater(len(exp_xy.minor_lines_arr) + len(exp_xy.main_lines_arr), 0) + + # Front view XZ plane + exp_xz = export_mesh_to_pdf(raw_geometry=so, draw_grid_xy=False, draw_grid_xz=True, draw_grid_yz=False) + self.assertTrue(exp_xz.draw_grid_xz) + self.assertGreater(len(exp_xz.minor_lines_arr) + len(exp_xz.main_lines_arr), 0) + + # Side view YZ plane + exp_yz = export_mesh_to_pdf(raw_geometry=so, draw_grid_xy=False, draw_grid_xz=False, draw_grid_yz=True) + self.assertTrue(exp_yz.draw_grid_yz) + self.assertGreater(len(exp_yz.minor_lines_arr) + len(exp_yz.main_lines_arr), 0) + + # Multi-grid plane selection (XY + YZ) + exp_multi = export_mesh_to_pdf(raw_geometry=so, draw_grid_xy=True, draw_grid_xz=False, draw_grid_yz=True) + self.assertTrue(exp_multi.draw_grid_xy) + self.assertTrue(exp_multi.draw_grid_yz) + self.assertTrue(exp_multi.draw_x_axis) + self.assertTrue(exp_multi.draw_y_axis) + self.assertTrue(exp_multi.draw_z_axis) + + # Rotated object by X = 90 deg with XZ front grid + import math + so_rot = SlicingObject() + so_rot.name = "RotatedFrontBox" + so_rot.transforms = [{"rotation": [math.pi / 2.0, 0.0, 0.0]}] + tri_rot = np.array([[0.0, 0.0, 0.0], [100.0, 0.0, 0.0], [0.0, 50.0, 0.0]], dtype=np.float64) + so_rot.mesh = np.array([tri_rot]) + + exp_rot_front = export_mesh_to_pdf(raw_geometry=so_rot, draw_grid_xy=False, draw_grid_xz=True, draw_grid_yz=False) + self.assertTrue(exp_rot_front.draw_grid_xz) + self.assertGreater(len(exp_rot_front.minor_lines_arr) + len(exp_rot_front.main_lines_arr), 0) + + # Node check + node = SLICENODE_MeshToPdf() + self.assertIn("draw_grid_xy", SLICENODE_MeshToPdf.__annotations__) + self.assertIn("draw_grid_xz", SLICENODE_MeshToPdf.__annotations__) + self.assertIn("draw_grid_yz", SLICENODE_MeshToPdf.__annotations__) + self.assertTrue(getattr(node, "draw_grid_xy", True)) + self.assertFalse(getattr(node, "draw_grid_xz", False)) + self.assertFalse(getattr(node, "draw_grid_yz", False)) + if __name__ == '__main__': unittest.main()