diff --git a/core/pdf_exporter/geometry.py b/core/pdf_exporter/geometry.py index b5058f1..0597644 100644 --- a/core/pdf_exporter/geometry.py +++ b/core/pdf_exporter/geometry.py @@ -151,9 +151,10 @@ def generate_grid_slice_paths( grid_max_y: float, minor_step: float, max_grid_steps: int = 2000, + z_level: float = 0.0, ) -> tuple[Any, Any]: """ - Generates 3D grid line geometry sitting on the XY plane (Z=0) as two SlicePaths objects: + Generates 3D grid line geometry sitting at local Z=z_level as two SlicePaths objects: (minor_grid_slice_paths, main_grid_slice_paths). """ from ..slice import SlicePaths @@ -175,7 +176,7 @@ def generate_grid_slice_paths( # Vertical grid lines (constant X) for k in k_x: gx = float(k * minor_step) - line = np.array([[gx, grid_min_y, 0.0], [gx, grid_max_y, 0.0]], dtype=np.float64) + line = np.array([[gx, grid_min_y, z_level], [gx, grid_max_y, z_level]], dtype=np.float64) if k % 10 == 0: main_paths.append(line) else: @@ -184,7 +185,7 @@ def generate_grid_slice_paths( # Horizontal grid lines (constant Y) for k in k_y: gy = float(k * minor_step) - line = np.array([[grid_min_x, gy, 0.0], [grid_max_x, gy, 0.0]], dtype=np.float64) + line = np.array([[grid_min_x, gy, z_level], [grid_max_x, gy, z_level]], dtype=np.float64) if k % 10 == 0: main_paths.append(line) else: @@ -201,9 +202,10 @@ def generate_legend_slice_paths( minor_step: float, line_width_pt: float, font_size_pt: float, + z_level: float = 0.0, ) -> tuple[Any, list[dict[str, Any]]]: """ - Generates 3D legend line geometry (axes, arrowheads, tick marks) sitting on the XY plane (Z=0) + Generates 3D legend line geometry (axes, arrowheads, tick marks) sitting at local Z=z_level as a SlicePaths object, alongside tick and label metadata for text positioning. Returns: (legend_slice_paths, label_metadata_list) """ @@ -230,9 +232,9 @@ def generate_legend_slice_paths( legend_paths: list[NDArray[np.float64]] = [] # 1. Main horizontal X-axis line & Arrowhead - legend_paths.append(np.array([[x0, y0, 0.0], [x_arrow_tip, y0, 0.0]], dtype=np.float64)) - legend_paths.append(np.array([[x_arrow_base, y0 + arrow_half_w_mm, 0.0], [x_arrow_tip, y0, 0.0]], dtype=np.float64)) - legend_paths.append(np.array([[x_arrow_tip, y0, 0.0], [x_arrow_base, y0 - arrow_half_w_mm, 0.0]], dtype=np.float64)) + 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]] = [] @@ -240,7 +242,7 @@ def generate_legend_slice_paths( 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, 0.0], [xk, y0 + tick_h, 0.0]], dtype=np.float64)) + legend_paths.append(np.array([[xk, y0, z_level], [xk, y0 + tick_h, z_level]], dtype=np.float64)) if k > 0: val_mm = k * main_step @@ -255,24 +257,24 @@ def generate_legend_slice_paths( label_meta.append({ "type": "tick", "text": tick_str, - "local_pt": (xk, y0 + tick_h + label_margin_mm, 0.0), + "local_pt": (xk, y0 + tick_h + label_margin_mm, z_level), }) # 3. Main vertical Y-axis line & Arrowhead - legend_paths.append(np.array([[x0, y0, 0.0], [x0, y_arrow_tip, 0.0]], dtype=np.float64)) - legend_paths.append(np.array([[x0 - arrow_half_w_mm, y_arrow_base, 0.0], [x0, y_arrow_tip, 0.0]], dtype=np.float64)) - legend_paths.append(np.array([[x0, y_arrow_tip, 0.0], [x0 + arrow_half_w_mm, y_arrow_base, 0.0]], dtype=np.float64)) + 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)) # 4. Axis labels meta label_meta.append({ "type": "x_axis", "text": "X", - "local_pt": (x_arrow_tip + label_margin_mm, y0, 0.0), + "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, 0.0), + "local_pt": (x0, y_arrow_tip + label_margin_mm, z_level), }) return SlicePaths(legend_paths), label_meta diff --git a/core/pdf_exporter/writer.py b/core/pdf_exporter/writer.py index b1de88e..336f239 100644 --- a/core/pdf_exporter/writer.py +++ b/core/pdf_exporter/writer.py @@ -491,6 +491,7 @@ class PDFLegendElement(PDFElement): exp.minor_step, exp.line_width_pt, exp.font_size_pt, + z_level=getattr(exp, "local_min_z", 0.0), ) # Rotate legend SlicePaths with existing transform_slice_paths @@ -831,14 +832,41 @@ class export_mesh_to_pdf: Rz = np.array([[cz, -sz, 0.0], [sz, cz, 0.0], [0.0, 0.0, 1.0]], dtype=np.float64) R = Rz @ (Ry @ Rx) - if len(pts_2d) > 0: + pts_3d_list: list[NDArray[np.float64]] = [] + for obj in self.slicing_objs: + mesh_arr = getattr(obj, "mesh", None) + if isinstance(mesh_arr, np.ndarray) and mesh_arr.size > 0: + if mesh_arr.ndim == 3 and mesh_arr.shape[2] >= 3: + pts_3d_list.append(mesh_arr[:, :3, :3].reshape(-1, 3)) + elif mesh_arr.ndim == 2 and mesh_arr.shape[1] >= 3: + pts_3d_list.append(mesh_arr[:, :3]) + elif isinstance(obj, list) or (hasattr(obj, "__iter__") and not isinstance(obj, (str, bytes))): + for path in obj: + if isinstance(path, np.ndarray) and path.ndim == 2: + if path.shape[1] >= 3: + pts_3d_list.append(path[:, :3]) + elif path.shape[1] == 2: + pts_3d_list.append(np.column_stack([path, np.zeros(len(path), dtype=np.float64)])) + + if pts_3d_list: + pts_3d_world = np.vstack(pts_3d_list) + elif len(pts_2d) > 0: pts_3d_world = np.column_stack([pts_2d, np.zeros(len(pts_2d), dtype=np.float64)]) + else: + pts_3d_world = np.empty((0, 3), dtype=np.float64) + + if len(pts_3d_world) > 0: pts_local = pts_3d_world @ R 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 + 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) @@ -848,13 +876,14 @@ class export_mesh_to_pdf: 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 - # Generate 3D grid line SlicePaths sitting on local XY axis (Z=0) + # 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, ) # Rotate grid SlicePaths back into world space diff --git a/tests/test_pdf_exporter.py b/tests/test_pdf_exporter.py index e7ac1fc..c077be5 100644 --- a/tests/test_pdf_exporter.py +++ b/tests/test_pdf_exporter.py @@ -1570,8 +1570,8 @@ class TestPdfExporter(unittest.TestCase): self.assertTrue(hasattr(exporter, "local_min_x")) self.assertTrue(hasattr(exporter, "local_min_y")) # Verify exporter bounds cleanly encompass geometry - self.assertLessEqual(exporter.grid_min_x, 0.0) - self.assertGreaterEqual(exporter.grid_max_x, 70.0) + self.assertLessEqual(exporter.grid_min_x, 40.0) + self.assertGreaterEqual(exporter.grid_max_x, 60.0) def test_generate_legend_slice_paths(self): from NodeSlicer.core.pdf_exporter.geometry import generate_legend_slice_paths @@ -1623,6 +1623,70 @@ class TestPdfExporter(unittest.TestCase): legend_elem.render(mock_s) self.assertGreater(len(mock_s.positions), 0) + def test_grid_step_sized_by_3d_xy_before_rotation(self): + from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject + from NodeSlicer.core.pdf_exporter.sizing import compute_grid_steps + import math + + so = SlicingObject() + so.name = "Rotated3DBox" + so.transforms = [{"rotation": [0.0, math.pi / 2.0, 0.0]}] + + # Create 3D mesh vertices in world space after a 90 deg Y-rotation: + # Unrotated box: X in [0, 120], Y in [0, 80], Z in [0, 300] + # Rotated around Y by +90 deg: X_world = Z_local, Y_world = Y_local, Z_world = -X_local + # So X_world in [0, 300], Y_world in [0, 80], Z_world in [-120, 0] + unrotated_verts = np.array([ + [0.0, 0.0, 0.0], + [120.0, 0.0, 0.0], + [0.0, 80.0, 0.0], + [0.0, 0.0, 300.0], + [120.0, 80.0, 300.0], + ], dtype=np.float64) + + # Apply Y-rotation (+90 deg) to generate world space mesh vertices + ry = math.pi / 2.0 + cy, sy = math.cos(ry), math.sin(ry) + Ry = np.array([[cy, 0.0, sy], [0.0, 1.0, 0.0], [-sy, 0.0, cy]], dtype=np.float64) + world_verts = unrotated_verts @ Ry.T + + # Store triangle in mesh array (N, 3, 3) + tri1 = np.array([world_verts[0], world_verts[1], world_verts[2]]) + tri2 = np.array([world_verts[2], world_verts[3], world_verts[4]]) + so.mesh = np.array([tri1, tri2]) + + exporter = export_mesh_to_pdf(raw_geometry=so) + + # Expected unrotated 3D XY dimensions: w_geom = 120, h_geom = 80 + expected_main, expected_minor = compute_grid_steps(120.0, 80.0) + self.assertEqual(exporter.main_step, expected_main) + self.assertEqual(exporter.minor_step, expected_minor) + + def test_grid_positioned_at_bottom_of_object(self): + from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject + from NodeSlicer.core.pdf_exporter.geometry import generate_grid_slice_paths + + so = SlicingObject() + so.name = "ElevatedBox" + # 3D mesh with Z spanning from 15.0 to 45.0 + tri = np.array([[10.0, 10.0, 15.0], [50.0, 10.0, 30.0], [10.0, 50.0, 45.0]], dtype=np.float64) + so.mesh = np.array([tri]) + + exporter = export_mesh_to_pdf(raw_geometry=so) + + self.assertAlmostEqual(exporter.local_min_z, 15.0) + + minor_sp, main_sp = generate_grid_slice_paths( + exporter.local_min_x, + exporter.local_max_x, + exporter.local_min_y, + exporter.local_max_y, + exporter.minor_step, + z_level=exporter.local_min_z, + ) + self.assertGreater(len(minor_sp), 0) + self.assertAlmostEqual(float(minor_sp[0][0, 2]), 15.0) + if __name__ == '__main__': unittest.main()