rotate legend

This commit is contained in:
Nicolas Predella 2026-08-07 14:33:15 +03:00
parent 0c266dd8c7
commit 8154370c99
3 changed files with 196 additions and 92 deletions

View file

@ -193,6 +193,91 @@ def generate_grid_slice_paths(
return SlicePaths(minor_paths), SlicePaths(main_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]: def extract_slice_paths_edges(slice_paths: Any) -> NDArray[np.float64]:
""" """

View file

@ -27,6 +27,7 @@ from .geometry import (
transform_slice_paths, transform_slice_paths,
get_first_object_rotation, get_first_object_rotation,
generate_grid_slice_paths, generate_grid_slice_paths,
generate_legend_slice_paths,
) )
from .sa2_measures import render_sa2_measures_pdf from .sa2_measures import render_sa2_measures_pdf
from .sizing import ( from .sizing import (
@ -469,99 +470,70 @@ class PDFSA2MeasuresElement(PDFElement):
class PDFLegendElement(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: def render(self, s: PdfStream) -> None:
exp = self.exporter 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) # Extract 3D rotation of the first object
x_grid_start_mm = exp.grid_min_x rot_vec = get_first_object_rotation(
x_grid_end_mm = min(exp.grid_max_x, exp.grid_min_x + 10.0 * exp.main_step) exp.slicing_objs, exp.raw_geometry, exp.slice_paths
num_main_steps = int(np.round((x_grid_end_mm - x_grid_start_mm) / exp.main_step)) )
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]])) # Generate legend 3D SlicePaths & label metadata
p_end_pt = exp.to_pdf_pt(np.array([[x_grid_end_mm, exp.grid_min_y]])) legend_sp, label_meta = generate_legend_slice_paths(
exp.local_min_x,
x_start_pt = p_start_pt[0, 0] exp.local_min_y,
x_end_pt = p_end_pt[0, 0] exp.local_max_x,
y_bar_pt = p_start_pt[0, 1] exp.main_step,
exp.minor_step,
y_grid_top_mm = exp.grid_min_y + leg_sizing.y_axis_height_mm exp.line_width_pt,
p_top_pt = exp.to_pdf_pt(np.array([[exp.grid_min_x, y_grid_top_mm]])) exp.font_size_pt,
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),
) )
for k in range(num_main_steps + 1): # Rotate legend SlicePaths with existing transform_slice_paths
xk_mm = x_grid_start_mm + k * exp.main_step rot_legend_sp = transform_slice_paths(legend_sp, np.zeros(3), r_arr, np.ones(3))
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()
# 6b. Main vertical Y-axis line # Extract 2D edge segments using existing extract_slice_paths_edges
y_arrow_base_pt = y_top_pt legend_edges_2d = extract_slice_paths_edges(rot_legend_sp)
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)
s.polyline_stroke( # Render vector lines
(x_start_pt - arrow_half_w_pt, y_arrow_base_pt), if len(legend_edges_2d) > 0:
(x_start_pt, y_arrow_tip_pt), s.opacity("GS100").line_width(exp.line_width_pt).stroke_color(0, 0, 0).round_caps()
(x_start_pt + arrow_half_w_pt, y_arrow_base_pt), 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.fill_color(0, 0, 0)
s.begin_text().font("F1", exp.font_size_pt) s.begin_text().font("F1", exp.font_size_pt)
for k in range(1, num_main_steps + 1): for meta in label_meta:
xk_mm = x_grid_start_mm + k * exp.main_step local_pt = np.array(meta["local_pt"], dtype=np.float64)
pk_pt = exp.to_pdf_pt(np.array([[xk_mm, exp.grid_min_y]])) world_pt = local_pt @ R.T
xk_pt = pk_pt[0, 0] pt_pdf = exp.to_pdf_pt(world_pt.reshape(1, 3)[:, :2])[0]
tick_h_mm = leg_sizing.major_tick_mm if k in (0, 5, num_main_steps) else leg_sizing.minor_tick_mm txt = meta["text"]
tick_h_pt = tick_h_mm * MM_TO_PT
val_mm = k * exp.main_step
if exp.main_step >= 1000.0: if meta["type"] == "tick":
val_num = val_mm / 1000.0 tw_pt = len(txt) * exp.font_size_pt * 0.60
unit = " m" if k == num_main_steps else "" s.text_position(pt_pdf[0] - (tw_pt / 2.0), pt_pdf[1]).show(txt)
tick_str = f"{val_num:g}{unit}" elif meta["type"] == "x_axis":
else: s.text_position(pt_pdf[0], pt_pdf[1] - (exp.font_size_pt * 0.35)).show(txt)
unit = " mm" if k == num_main_steps else "" elif meta["type"] == "y_axis":
tick_str = f"{val_mm:g}{unit}" s.text_position(pt_pdf[0] - (exp.font_size_pt * 0.35), pt_pdf[1]).show(txt)
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")
s.end_text() s.end_text()
@ -871,17 +843,17 @@ class export_mesh_to_pdf:
h_geom = max(l_max_y - l_min_y, 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.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 self.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 self.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 self.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_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 axis (Z=0)
minor_grid_sp, main_grid_sp = generate_grid_slice_paths( minor_grid_sp, main_grid_sp = generate_grid_slice_paths(
local_min_x, self.local_min_x,
local_max_x, self.local_max_x,
local_min_y, self.local_min_y,
local_max_y, self.local_max_y,
self.minor_step, self.minor_step,
) )

View file

@ -1567,15 +1567,62 @@ class TestPdfExporter(unittest.TestCase):
exporter = export_mesh_to_pdf(raw_geometry=so) exporter = export_mesh_to_pdf(raw_geometry=so)
self.assertGreater(len(exporter.minor_lines_arr), 0) self.assertGreater(len(exporter.minor_lines_arr), 0)
self.assertGreater(len(exporter.main_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 # Verify exporter bounds cleanly encompass geometry
self.assertLessEqual(exporter.grid_min_x, 0.0) self.assertLessEqual(exporter.grid_min_x, 0.0)
self.assertGreaterEqual(exporter.grid_max_x, 70.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__': if __name__ == '__main__':
unittest.main() unittest.main()