diff --git a/core/pdf_exporter/geometry.py b/core/pdf_exporter/geometry.py index 123c40c..4da0b9d 100644 --- a/core/pdf_exporter/geometry.py +++ b/core/pdf_exporter/geometry.py @@ -269,6 +269,10 @@ def generate_legend_slice_paths( legend_paths: list[NDArray[np.float64]] = [] label_meta: list[dict[str, Any]] = [] + p_up = plane.upper() + is_xz = (p_up == "XZ") or (draw_z and not draw_y) + is_yz = (p_up == "YZ") or (draw_z and not draw_x) + # 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 @@ -278,23 +282,45 @@ def generate_legend_slice_paths( x_arrow_tip = x_arrow_base + arrow_len_mm 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)) - 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)) + if is_xz: + # On XZ grid plane (constant y=y0, ticks & wings extend in Z direction) + legend_paths.append(np.array([[x_arrow_base, y0, z0 + arrow_half_w_mm], [x_arrow_tip, y0, z0]], dtype=np.float64)) + legend_paths.append(np.array([[x_arrow_tip, y0, z0], [x_arrow_base, y0, z0 - arrow_half_w_mm]], 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": (xk, y0 - tick_h - label_margin_mm, z0), - }) + 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, z0 + tick_h]], 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": (xk, y0, z0 + tick_h + label_margin_mm), + }) + else: + # On XY grid plane (constant z=z0, ticks & wings extend in -Y direction) + 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)) + + 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)) + + 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", @@ -311,14 +337,16 @@ def generate_legend_slice_paths( 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: + if is_yz: + # On YZ grid plane (constant x=x0, ticks & wings extend in Z direction) + legend_paths.append(np.array([[x0, y_arrow_base, z0 - arrow_half_w_mm], [x0, y_arrow_tip, z0]], dtype=np.float64)) + legend_paths.append(np.array([[x0, y_arrow_tip, z0], [x0, y_arrow_base, z0 + arrow_half_w_mm]], dtype=np.float64)) + 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)) + legend_paths.append(np.array([[x0, yk, z0], [x0, yk, z0 + tick_h]], dtype=np.float64)) if k > 0: val_mm = k * main_step @@ -327,8 +355,28 @@ def generate_legend_slice_paths( label_meta.append({ "type": "tick", "text": val_str, - "local_pt": (x0 + tick_h + label_margin_mm, yk, z0), + "local_pt": (x0, yk, z0 + tick_h + label_margin_mm), }) + else: + # On XY grid plane (constant z=z0, ticks & wings extend in X direction) + 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", @@ -345,10 +393,12 @@ def generate_legend_slice_paths( 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: + if is_xz or draw_x: + # On XZ grid plane (constant y=y0, ticks & wings extend in X direction) + 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)) + 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 @@ -363,6 +413,25 @@ def generate_legend_slice_paths( "text": val_str, "local_pt": (x0 + tick_h + label_margin_mm, y0, zk), }) + else: + # On YZ grid plane (constant x=x0, ticks & wings extend in Y direction) + legend_paths.append(np.array([[x0, y0 - arrow_half_w_mm, z_arrow_base], [x0, y0, z_arrow_tip]], dtype=np.float64)) + legend_paths.append(np.array([[x0, y0, z_arrow_tip], [x0, y0 + arrow_half_w_mm, z_arrow_base]], dtype=np.float64)) + + 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, y0 - tick_h, 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, y0 - tick_h - label_margin_mm, zk), + }) label_meta.append({ "type": "z_axis", diff --git a/core/pdf_exporter/writer.py b/core/pdf_exporter/writer.py index dc2969a..1d9dc78 100644 --- a/core/pdf_exporter/writer.py +++ b/core/pdf_exporter/writer.py @@ -586,6 +586,7 @@ class PDFLegendElement(PDFElement): 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), + plane=getattr(exp, "projection_plane", "XY"), ) # Rotate legend SlicePaths with existing transform_slice_paths @@ -629,6 +630,8 @@ class PDFLegendElement(PDFElement): s.text_position(pt_pdf[0], pt_pdf[1] - (exp.font_size_pt * 0.35)).show(txt) elif meta["type"] == "y_axis": s.text_position(pt_pdf[0] - (exp.font_size_pt * 0.35), pt_pdf[1]).show(txt) + elif meta["type"] == "z_axis": + s.text_position(pt_pdf[0] - (exp.font_size_pt * 0.35), pt_pdf[1]).show(txt) s.end_text() diff --git a/tests/test_pdf_exporter.py b/tests/test_pdf_exporter.py index cb1f1f5..59a8f2d 100644 --- a/tests/test_pdf_exporter.py +++ b/tests/test_pdf_exporter.py @@ -1877,6 +1877,58 @@ class TestPdfExporter(unittest.TestCase): self.assertTrue(generated_name.endswith(f"-{today_str}.pdf")) self.assertIn("x", generated_name) + def test_generate_legend_slice_paths_xz_plane(self): + from NodeSlicer.core.pdf_exporter.geometry import generate_legend_slice_paths + from NodeSlicer.core.slice import SlicePaths + + legend_sp, label_meta = generate_legend_slice_paths( + grid_min_x=0.0, + grid_min_y=0.0, + grid_max_x=50.0, + main_step=10.0, + minor_step=1.0, + line_width_pt=0.5, + font_size_pt=3.0, + plane="XZ", + draw_x=True, + draw_y=False, + draw_z=True, + local_origin_3d=(0.0, 0.0, 0.0), + local_max_3d=(50.0, 0.0, 50.0), + ) + + self.assertIsInstance(legend_sp, SlicePaths) + self.assertGreater(len(legend_sp), 0) + + # Verify that all 3D line endpoints have constant y=0 (i.e. strictly in the XZ plane) + for path in legend_sp: + p_arr = np.asarray(path, dtype=np.float64) + np.testing.assert_allclose(p_arr[:, 1], 0.0, atol=1e-6, err_msg="Legend geometry for XZ plane must have Y=0") + + # Verify that ticks on the X axis span in Z + x_ticks_in_z = [ + path for path in legend_sp + if len(path) == 2 and abs(path[0, 0] - path[1, 0]) < 1e-6 and abs(path[0, 2] - path[1, 2]) > 0.1 + ] + self.assertGreater(len(x_ticks_in_z), 0, "X axis ticks must have height along Z on XZ plane") + + # Verify that ticks on the Z axis span in X + z_ticks_in_x = [ + path for path in legend_sp + if len(path) == 2 and abs(path[0, 2] - path[1, 2]) < 1e-6 and abs(path[0, 0] - path[1, 0]) > 0.1 + ] + self.assertGreater(len(z_ticks_in_x), 0, "Z axis ticks must have height along X on XZ plane") + + # Verify labels + types = [m["type"] for m in label_meta] + self.assertIn("tick", types) + self.assertIn("x_axis", types) + self.assertIn("z_axis", types) + + # Verify label positions are in XZ plane (Y=0) + for m in label_meta: + self.assertAlmostEqual(m["local_pt"][1], 0.0, places=5) + if __name__ == '__main__': unittest.main()