grid placement

This commit is contained in:
Nicolas Predella 2026-08-07 15:00:58 +03:00
parent 8154370c99
commit 40cc779e5d
3 changed files with 113 additions and 18 deletions

View file

@ -151,9 +151,10 @@ def generate_grid_slice_paths(
grid_max_y: float,
minor_step: float,
max_grid_steps: int = 2000,
z_level: float = 0.0,
) -> tuple[Any, Any]:
"""
Generates 3D grid line geometry sitting on the XY plane (Z=0) as two SlicePaths objects:
Generates 3D grid line geometry sitting at local Z=z_level as two SlicePaths objects:
(minor_grid_slice_paths, main_grid_slice_paths).
"""
from ..slice import SlicePaths
@ -175,7 +176,7 @@ def generate_grid_slice_paths(
# Vertical grid lines (constant X)
for k in k_x:
gx = float(k * minor_step)
line = np.array([[gx, grid_min_y, 0.0], [gx, grid_max_y, 0.0]], dtype=np.float64)
line = np.array([[gx, grid_min_y, z_level], [gx, grid_max_y, z_level]], dtype=np.float64)
if k % 10 == 0:
main_paths.append(line)
else:
@ -184,7 +185,7 @@ def generate_grid_slice_paths(
# Horizontal grid lines (constant Y)
for k in k_y:
gy = float(k * minor_step)
line = np.array([[grid_min_x, gy, 0.0], [grid_max_x, gy, 0.0]], dtype=np.float64)
line = np.array([[grid_min_x, gy, z_level], [grid_max_x, gy, z_level]], dtype=np.float64)
if k % 10 == 0:
main_paths.append(line)
else:
@ -201,9 +202,10 @@ def generate_legend_slice_paths(
minor_step: float,
line_width_pt: float,
font_size_pt: float,
z_level: float = 0.0,
) -> tuple[Any, list[dict[str, Any]]]:
"""
Generates 3D legend line geometry (axes, arrowheads, tick marks) sitting on the XY plane (Z=0)
Generates 3D legend line geometry (axes, arrowheads, tick marks) sitting at local Z=z_level
as a SlicePaths object, alongside tick and label metadata for text positioning.
Returns: (legend_slice_paths, label_metadata_list)
"""
@ -230,9 +232,9 @@ def generate_legend_slice_paths(
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))
legend_paths.append(np.array([[x0, y0, z_level], [x_arrow_tip, y0, z_level]], dtype=np.float64))
legend_paths.append(np.array([[x_arrow_base, y0 + arrow_half_w_mm, z_level], [x_arrow_tip, y0, z_level]], dtype=np.float64))
legend_paths.append(np.array([[x_arrow_tip, y0, z_level], [x_arrow_base, y0 - arrow_half_w_mm, z_level]], dtype=np.float64))
# 2. X-axis tick marks & label positions
label_meta: list[dict[str, Any]] = []
@ -240,7 +242,7 @@ def generate_legend_slice_paths(
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))
legend_paths.append(np.array([[xk, y0, z_level], [xk, y0 + tick_h, z_level]], dtype=np.float64))
if k > 0:
val_mm = k * main_step
@ -255,24 +257,24 @@ def generate_legend_slice_paths(
label_meta.append({
"type": "tick",
"text": tick_str,
"local_pt": (xk, y0 + tick_h + label_margin_mm, 0.0),
"local_pt": (xk, y0 + tick_h + label_margin_mm, z_level),
})
# 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))
legend_paths.append(np.array([[x0, y0, z_level], [x0, y_arrow_tip, z_level]], dtype=np.float64))
legend_paths.append(np.array([[x0 - arrow_half_w_mm, y_arrow_base, z_level], [x0, y_arrow_tip, z_level]], dtype=np.float64))
legend_paths.append(np.array([[x0, y_arrow_tip, z_level], [x0 + arrow_half_w_mm, y_arrow_base, z_level]], 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),
"local_pt": (x_arrow_tip + label_margin_mm, y0, z_level),
})
label_meta.append({
"type": "y_axis",
"text": "Y",
"local_pt": (x0, y_arrow_tip + label_margin_mm, 0.0),
"local_pt": (x0, y_arrow_tip + label_margin_mm, z_level),
})
return SlicePaths(legend_paths), label_meta

View file

@ -491,6 +491,7 @@ class PDFLegendElement(PDFElement):
exp.minor_step,
exp.line_width_pt,
exp.font_size_pt,
z_level=getattr(exp, "local_min_z", 0.0),
)
# Rotate legend SlicePaths with existing transform_slice_paths
@ -831,14 +832,41 @@ class export_mesh_to_pdf:
Rz = np.array([[cz, -sz, 0.0], [sz, cz, 0.0], [0.0, 0.0, 1.0]], dtype=np.float64)
R = Rz @ (Ry @ Rx)
if len(pts_2d) > 0:
pts_3d_list: list[NDArray[np.float64]] = []
for obj in self.slicing_objs:
mesh_arr = getattr(obj, "mesh", None)
if isinstance(mesh_arr, np.ndarray) and mesh_arr.size > 0:
if mesh_arr.ndim == 3 and mesh_arr.shape[2] >= 3:
pts_3d_list.append(mesh_arr[:, :3, :3].reshape(-1, 3))
elif mesh_arr.ndim == 2 and mesh_arr.shape[1] >= 3:
pts_3d_list.append(mesh_arr[:, :3])
elif isinstance(obj, list) or (hasattr(obj, "__iter__") and not isinstance(obj, (str, bytes))):
for path in obj:
if isinstance(path, np.ndarray) and path.ndim == 2:
if path.shape[1] >= 3:
pts_3d_list.append(path[:, :3])
elif path.shape[1] == 2:
pts_3d_list.append(np.column_stack([path, np.zeros(len(path), dtype=np.float64)]))
if pts_3d_list:
pts_3d_world = np.vstack(pts_3d_list)
elif len(pts_2d) > 0:
pts_3d_world = np.column_stack([pts_2d, np.zeros(len(pts_2d), dtype=np.float64)])
else:
pts_3d_world = np.empty((0, 3), dtype=np.float64)
if len(pts_3d_world) > 0:
pts_local = pts_3d_world @ R
l_min_x, l_max_x = float(pts_local[:, 0].min()), float(pts_local[:, 0].max())
l_min_y, l_max_y = float(pts_local[:, 1].min()), float(pts_local[:, 1].max())
l_min_z, l_max_z = float(pts_local[:, 2].min()), float(pts_local[:, 2].max())
if R.T[2, 1] < -1e-6:
l_min_z = l_max_z
else:
l_min_x, l_max_x, l_min_y, l_max_y = -50.0, 50.0, -50.0, 50.0
l_min_z = 0.0
self.local_min_z = l_min_z
w_geom = max(l_max_x - l_min_x, 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)
@ -848,13 +876,14 @@ class export_mesh_to_pdf:
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)
# Generate 3D grid line SlicePaths sitting on local XY plane at the bottom of the object (Z=l_min_z)
minor_grid_sp, main_grid_sp = generate_grid_slice_paths(
self.local_min_x,
self.local_max_x,
self.local_min_y,
self.local_max_y,
self.minor_step,
z_level=l_min_z,
)
# Rotate grid SlicePaths back into world space

View file

@ -1570,8 +1570,8 @@ class TestPdfExporter(unittest.TestCase):
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)
self.assertLessEqual(exporter.grid_min_x, 40.0)
self.assertGreaterEqual(exporter.grid_max_x, 60.0)
def test_generate_legend_slice_paths(self):
from NodeSlicer.core.pdf_exporter.geometry import generate_legend_slice_paths
@ -1623,6 +1623,70 @@ class TestPdfExporter(unittest.TestCase):
legend_elem.render(mock_s)
self.assertGreater(len(mock_s.positions), 0)
def test_grid_step_sized_by_3d_xy_before_rotation(self):
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject
from NodeSlicer.core.pdf_exporter.sizing import compute_grid_steps
import math
so = SlicingObject()
so.name = "Rotated3DBox"
so.transforms = [{"rotation": [0.0, math.pi / 2.0, 0.0]}]
# Create 3D mesh vertices in world space after a 90 deg Y-rotation:
# Unrotated box: X in [0, 120], Y in [0, 80], Z in [0, 300]
# Rotated around Y by +90 deg: X_world = Z_local, Y_world = Y_local, Z_world = -X_local
# So X_world in [0, 300], Y_world in [0, 80], Z_world in [-120, 0]
unrotated_verts = np.array([
[0.0, 0.0, 0.0],
[120.0, 0.0, 0.0],
[0.0, 80.0, 0.0],
[0.0, 0.0, 300.0],
[120.0, 80.0, 300.0],
], dtype=np.float64)
# Apply Y-rotation (+90 deg) to generate world space mesh vertices
ry = math.pi / 2.0
cy, sy = math.cos(ry), math.sin(ry)
Ry = np.array([[cy, 0.0, sy], [0.0, 1.0, 0.0], [-sy, 0.0, cy]], dtype=np.float64)
world_verts = unrotated_verts @ Ry.T
# Store triangle in mesh array (N, 3, 3)
tri1 = np.array([world_verts[0], world_verts[1], world_verts[2]])
tri2 = np.array([world_verts[2], world_verts[3], world_verts[4]])
so.mesh = np.array([tri1, tri2])
exporter = export_mesh_to_pdf(raw_geometry=so)
# Expected unrotated 3D XY dimensions: w_geom = 120, h_geom = 80
expected_main, expected_minor = compute_grid_steps(120.0, 80.0)
self.assertEqual(exporter.main_step, expected_main)
self.assertEqual(exporter.minor_step, expected_minor)
def test_grid_positioned_at_bottom_of_object(self):
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject
from NodeSlicer.core.pdf_exporter.geometry import generate_grid_slice_paths
so = SlicingObject()
so.name = "ElevatedBox"
# 3D mesh with Z spanning from 15.0 to 45.0
tri = np.array([[10.0, 10.0, 15.0], [50.0, 10.0, 30.0], [10.0, 50.0, 45.0]], dtype=np.float64)
so.mesh = np.array([tri])
exporter = export_mesh_to_pdf(raw_geometry=so)
self.assertAlmostEqual(exporter.local_min_z, 15.0)
minor_sp, main_sp = generate_grid_slice_paths(
exporter.local_min_x,
exporter.local_max_x,
exporter.local_min_y,
exporter.local_max_y,
exporter.minor_step,
z_level=exporter.local_min_z,
)
self.assertGreater(len(minor_sp), 0)
self.assertAlmostEqual(float(minor_sp[0][0, 2]), 15.0)
if __name__ == '__main__':
unittest.main()