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)
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]:
"""

View file

@ -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,
)

View file

@ -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()