rotate legend
This commit is contained in:
parent
0c266dd8c7
commit
8154370c99
3 changed files with 196 additions and 92 deletions
|
|
@ -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]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue