From 8154370c99d94a9bfaf43599ba24c15d3f042f86 Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Fri, 7 Aug 2026 14:33:15 +0300 Subject: [PATCH] rotate legend --- core/pdf_exporter/geometry.py | 85 ++++++++++++++++++++ core/pdf_exporter/writer.py | 146 ++++++++++++++-------------------- tests/test_pdf_exporter.py | 57 +++++++++++-- 3 files changed, 196 insertions(+), 92 deletions(-) diff --git a/core/pdf_exporter/geometry.py b/core/pdf_exporter/geometry.py index ffd2966..b5058f1 100644 --- a/core/pdf_exporter/geometry.py +++ b/core/pdf_exporter/geometry.py @@ -193,6 +193,91 @@ def generate_grid_slice_paths( return SlicePaths(minor_paths), SlicePaths(main_paths) +def generate_legend_slice_paths( + grid_min_x: float, + grid_min_y: float, + grid_max_x: float, + main_step: float, + minor_step: float, + line_width_pt: float, + font_size_pt: float, +) -> tuple[Any, list[dict[str, Any]]]: + """ + Generates 3D legend line geometry (axes, arrowheads, tick marks) sitting on the XY plane (Z=0) + as a SlicePaths object, alongside tick and label metadata for text positioning. + Returns: (legend_slice_paths, label_metadata_list) + """ + from ..slice import SlicePaths + 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))) + + 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, 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)) + + # 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, 0.0], [xk, y0 + tick_h, 0.0]], dtype=np.float64)) + + 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}" + + label_meta.append({ + "type": "tick", + "text": tick_str, + "local_pt": (xk, y0 + tick_h + label_margin_mm, 0.0), + }) + + # 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)) + + # 4. Axis labels meta + label_meta.append({ + "type": "x_axis", + "text": "X", + "local_pt": (x_arrow_tip + label_margin_mm, y0, 0.0), + }) + label_meta.append({ + "type": "y_axis", + "text": "Y", + "local_pt": (x0, y_arrow_tip + label_margin_mm, 0.0), + }) + + return SlicePaths(legend_paths), label_meta + + def extract_slice_paths_edges(slice_paths: Any) -> NDArray[np.float64]: """ diff --git a/core/pdf_exporter/writer.py b/core/pdf_exporter/writer.py index bc39b95..b1de88e 100644 --- a/core/pdf_exporter/writer.py +++ b/core/pdf_exporter/writer.py @@ -27,6 +27,7 @@ from .geometry import ( transform_slice_paths, get_first_object_rotation, generate_grid_slice_paths, + generate_legend_slice_paths, ) from .sa2_measures import render_sa2_measures_pdf from .sizing import ( @@ -469,99 +470,70 @@ class PDFSA2MeasuresElement(PDFElement): class PDFLegendElement(PDFElement): """ - Renders physical size scale legend (axes, tick marks, arrows, and units). + Renders physical size scale legend (axes, tick marks, arrows, and units) + using 3D SlicePaths geometry oriented with object rotation. """ def render(self, s: PdfStream) -> None: exp = self.exporter - s.opacity("GS100").color(0, 0, 0) - leg_sizing = LegendSizing(exp.main_step, exp.minor_step, exp.line_width_pt, exp.font_size_pt) - x_grid_start_mm = exp.grid_min_x - x_grid_end_mm = min(exp.grid_max_x, exp.grid_min_x + 10.0 * exp.main_step) - num_main_steps = int(np.round((x_grid_end_mm - x_grid_start_mm) / exp.main_step)) + # Extract 3D rotation of the first object + rot_vec = get_first_object_rotation( + exp.slicing_objs, exp.raw_geometry, exp.slice_paths + ) + r_arr = np.array(rot_vec, dtype=np.float64) - p_start_pt = exp.to_pdf_pt(np.array([[x_grid_start_mm, exp.grid_min_y]])) - p_end_pt = exp.to_pdf_pt(np.array([[x_grid_end_mm, exp.grid_min_y]])) - - x_start_pt = p_start_pt[0, 0] - x_end_pt = p_end_pt[0, 0] - y_bar_pt = p_start_pt[0, 1] - - y_grid_top_mm = exp.grid_min_y + leg_sizing.y_axis_height_mm - p_top_pt = exp.to_pdf_pt(np.array([[exp.grid_min_x, y_grid_top_mm]])) - y_top_pt = p_top_pt[0, 1] - - arrow_len_pt = leg_sizing.arrow_len_pt - arrow_half_w_pt = leg_sizing.arrow_half_w_pt - - s.line_width(exp.line_width_pt).round_caps() - - # 6a. Main horizontal X-axis line - x_arrow_base_mm = x_grid_end_mm + leg_sizing.x_axis_extension_mm - p_arrow_base_pt = exp.to_pdf_pt(np.array([[x_arrow_base_mm, exp.grid_min_y]])) - x_arrow_base_pt = p_arrow_base_pt[0, 0] - x_arrow_tip_pt = x_arrow_base_pt + arrow_len_pt - - s.line_stroke(x_start_pt, y_bar_pt, x_arrow_tip_pt, y_bar_pt) - - s.polyline_stroke( - (x_arrow_base_pt, y_bar_pt + arrow_half_w_pt), - (x_arrow_tip_pt, y_bar_pt), - (x_arrow_base_pt, y_bar_pt - arrow_half_w_pt), + # 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.main_step, + exp.minor_step, + exp.line_width_pt, + exp.font_size_pt, ) - for k in range(num_main_steps + 1): - xk_mm = x_grid_start_mm + k * exp.main_step - pk_pt = exp.to_pdf_pt(np.array([[xk_mm, exp.grid_min_y]])) - xk_pt = pk_pt[0, 0] - tick_h_mm = leg_sizing.major_tick_mm if k in (0, 5, num_main_steps) else leg_sizing.minor_tick_mm - tick_h_pt = tick_h_mm * MM_TO_PT - s.line(xk_pt, y_bar_pt, xk_pt, y_bar_pt + tick_h_pt) - s.stroke() + # Rotate legend SlicePaths with existing transform_slice_paths + rot_legend_sp = transform_slice_paths(legend_sp, np.zeros(3), r_arr, np.ones(3)) - # 6b. Main vertical Y-axis line - y_arrow_base_pt = y_top_pt - y_arrow_tip_pt = y_arrow_base_pt + arrow_len_pt - s.line_stroke(x_start_pt, y_bar_pt, x_start_pt, y_arrow_tip_pt) + # Extract 2D edge segments using existing extract_slice_paths_edges + legend_edges_2d = extract_slice_paths_edges(rot_legend_sp) - s.polyline_stroke( - (x_start_pt - arrow_half_w_pt, y_arrow_base_pt), - (x_start_pt, y_arrow_tip_pt), - (x_start_pt + arrow_half_w_pt, y_arrow_base_pt), - ) + # Render vector lines + if len(legend_edges_2d) > 0: + s.opacity("GS100").line_width(exp.line_width_pt).stroke_color(0, 0, 0).round_caps() + p0_pt = exp.to_pdf_pt(legend_edges_2d[:, 0, :]) + p1_pt = exp.to_pdf_pt(legend_edges_2d[:, 1, :]) + lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2) + s.draw_line_batch(lines_arr) + + # Calculate rotation transpose matrix for text label placement + rx, ry, rz = r_arr + cx, sx = np.cos(rx), np.sin(rx) + cy, sy = np.cos(ry), np.sin(ry) + cz, sz = np.cos(rz), np.sin(rz) + + Rx = np.array([[1.0, 0.0, 0.0], [0.0, cx, -sx], [0.0, sx, cx]], dtype=np.float64) + Ry = np.array([[cy, 0.0, sy], [0.0, 1.0, 0.0], [-sy, 0.0, cy]], dtype=np.float64) + Rz = np.array([[cz, -sz, 0.0], [sz, cz, 0.0], [0.0, 0.0, 1.0]], dtype=np.float64) + R = Rz @ (Ry @ Rx) - # 6c. Text labels for ticks and axes s.fill_color(0, 0, 0) s.begin_text().font("F1", exp.font_size_pt) - for k in range(1, num_main_steps + 1): - xk_mm = x_grid_start_mm + k * exp.main_step - pk_pt = exp.to_pdf_pt(np.array([[xk_mm, exp.grid_min_y]])) - xk_pt = pk_pt[0, 0] - tick_h_mm = leg_sizing.major_tick_mm if k in (0, 5, num_main_steps) else leg_sizing.minor_tick_mm - tick_h_pt = tick_h_mm * MM_TO_PT - val_mm = k * exp.main_step + for meta in label_meta: + local_pt = np.array(meta["local_pt"], dtype=np.float64) + world_pt = local_pt @ R.T + pt_pdf = exp.to_pdf_pt(world_pt.reshape(1, 3)[:, :2])[0] + txt = meta["text"] - if exp.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}" - - tw_pt = len(tick_str) * exp.font_size_pt * 0.60 - x_text_pt = xk_pt - (tw_pt / 2.0) - y_text_pt = y_bar_pt + tick_h_pt + leg_sizing.label_margin_h_pt - s.text_position(x_text_pt, y_text_pt).show(tick_str) - - x_x_label_pt = x_arrow_tip_pt + leg_sizing.label_margin_h_pt - y_x_label_pt = y_bar_pt - (exp.font_size_pt * 0.35) - s.text_position(x_x_label_pt, y_x_label_pt).show("X") - - x_y_label_pt = x_start_pt - (exp.font_size_pt * 0.35) - y_y_label_pt = y_arrow_tip_pt + leg_sizing.label_margin_h_pt - s.text_position(x_y_label_pt, y_y_label_pt).show("Y") + if meta["type"] == "tick": + tw_pt = len(txt) * exp.font_size_pt * 0.60 + s.text_position(pt_pdf[0] - (tw_pt / 2.0), pt_pdf[1]).show(txt) + elif meta["type"] == "x_axis": + 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) s.end_text() @@ -871,17 +843,17 @@ class export_mesh_to_pdf: h_geom = max(l_max_y - l_min_y, 1.0) self.main_step, self.minor_step = compute_grid_steps(w_geom, h_geom) - local_min_x = math.floor((l_min_x - self.minor_step) / self.minor_step) * self.minor_step - local_max_x = math.ceil((l_max_x + self.minor_step) / self.minor_step) * self.minor_step - local_min_y = math.floor((l_min_y - self.minor_step) / self.minor_step) * self.minor_step - local_max_y = math.ceil((l_max_y + self.minor_step) / self.minor_step) * self.minor_step + 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 # Generate 3D grid line SlicePaths sitting on local XY axis (Z=0) minor_grid_sp, main_grid_sp = generate_grid_slice_paths( - local_min_x, - local_max_x, - local_min_y, - local_max_y, + self.local_min_x, + self.local_max_x, + self.local_min_y, + self.local_max_y, self.minor_step, ) diff --git a/tests/test_pdf_exporter.py b/tests/test_pdf_exporter.py index 58e87df..e7ac1fc 100644 --- a/tests/test_pdf_exporter.py +++ b/tests/test_pdf_exporter.py @@ -1567,15 +1567,62 @@ class TestPdfExporter(unittest.TestCase): exporter = export_mesh_to_pdf(raw_geometry=so) self.assertGreater(len(exporter.minor_lines_arr), 0) self.assertGreater(len(exporter.main_lines_arr), 0) + 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) + def test_generate_legend_slice_paths(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(-50.0, -50.0, 50.0, 10.0, 1.0, 0.5, 3.0) + self.assertIsInstance(legend_sp, SlicePaths) + self.assertGreater(len(legend_sp), 0) + self.assertGreater(len(label_meta), 0) + # Verify tick labels and axis labels are created + types = [m["type"] for m in label_meta] + self.assertIn("tick", types) + self.assertIn("x_axis", types) + self.assertIn("y_axis", types) + + def test_pdf_legend_element_rotation(self): + from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject + import math + so = SlicingObject() + so.transforms = [{"rotation": [0.0, 0.0, math.pi / 4.0]}] + tri = np.array([ + [0.0, 0.0, 0.0], + [10.0, 0.0, 0.0], + [0.0, 10.0, 0.0] + ], dtype=np.float64) + so.mesh = np.array([tri]) + + exporter = export_mesh_to_pdf(raw_geometry=so, show_legend=True) + legend_elem = next(e for e in exporter.elements if isinstance(e, export_mesh_to_pdf.Legend)) + + class MockPdfStream: + def __init__(self): + self.positions = [] + def opacity(self, *a, **kw): return self + def line_width(self, *a, **kw): return self + def stroke_color(self, *a, **kw): return self + def round_caps(self, *a, **kw): return self + def draw_line_batch(self, *a, **kw): return self + def fill_color(self, *a, **kw): return self + def begin_text(self, *a, **kw): return self + def font(self, *a, **kw): return self + def text_position(self, x, y): + self.positions.append((x, y)) + return self + def show(self, *a, **kw): return self + def end_text(self, *a, **kw): return self + + mock_s = MockPdfStream() + legend_elem.render(mock_s) + self.assertGreater(len(mock_s.positions), 0) + if __name__ == '__main__': unittest.main() - - - - -