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)
|
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]:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue