From 0c266dd8c77e7fe9e67ee2690fca5f99995e111c Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Thu, 6 Aug 2026 12:31:44 +0300 Subject: [PATCH] 3d grid --- core/pdf_exporter/__init__.py | 2 - core/pdf_exporter/geometry.py | 163 ++++++++++++++++++++++++++++++++++ core/pdf_exporter/writer.py | 144 +++++++++++++++++------------- tests/test_pdf_exporter.py | 75 +++++++++++++++- 4 files changed, 316 insertions(+), 68 deletions(-) diff --git a/core/pdf_exporter/__init__.py b/core/pdf_exporter/__init__.py index 08dd7e6..59c77f5 100644 --- a/core/pdf_exporter/__init__.py +++ b/core/pdf_exporter/__init__.py @@ -47,7 +47,6 @@ from .sa2_measures import ( from .sizing import MM_TO_PT from .writer import ( export_mesh_to_pdf, - export_wireframe_to_pdf, PDFElement, PDFBackgroundElement, PDFGridElement, @@ -62,7 +61,6 @@ from .writer import ( __all__ = [ # Main exporters & element classes "export_mesh_to_pdf", - "export_wireframe_to_pdf", "PDFElement", "PDFBackgroundElement", "PDFGridElement", diff --git a/core/pdf_exporter/geometry.py b/core/pdf_exporter/geometry.py index 02d58c4..ffd2966 100644 --- a/core/pdf_exporter/geometry.py +++ b/core/pdf_exporter/geometry.py @@ -31,6 +31,169 @@ def deduplicate_2d_edges(edges_2d: NDArray[np.float64]) -> NDArray[np.float64]: return edges_2d[unique_indices] +def transform_slice_paths( + sp: Any, + t_arr: NDArray[np.float64], + r_arr: NDArray[np.float64], + s_arr: NDArray[np.float64], +) -> Any: + """ + Transforms 3D coordinates in a SlicePaths object using translation, Euler rotation (radians), and scale. + """ + from ..slice import SlicePaths + + if not isinstance(sp, SlicePaths): + return sp + + has_scale = any(s != 1.0 for s in s_arr) + has_rot = any(r != 0.0 for r in r_arr) + has_trans = any(t != 0.0 for t in t_arr) + + if not (has_scale or has_rot or has_trans): + return sp.copy_with(paths=list(sp)) + + R = None + if has_rot: + 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) + + new_paths = [] + for p in sp: + if len(p) == 0: + new_paths.append(p.copy()) + continue + + new_p = p.astype(np.float64, copy=True) + dim = new_p.shape[1] if new_p.ndim > 1 else 0 + + if has_scale and dim > 0: + if dim >= 3: + new_p[:, :3] *= s_arr + else: + new_p[:, :dim] *= s_arr[:dim] + + if has_rot and R is not None and dim > 0: + if dim >= 3: + new_p[:, :3] = new_p[:, :3] @ R.T + elif dim == 2: + rot_2d = R[:2, :2] + new_p[:, :2] = new_p[:, :2] @ rot_2d.T + + if has_trans and dim > 0: + if dim >= 3: + new_p[:, :3] += t_arr + else: + new_p[:, :dim] += t_arr[:dim] + + new_paths.append(new_p) + + return sp.copy_with(paths=new_paths) + + +def get_first_object_rotation( + slicing_objs: list[Any], + raw_geometry: Any = None, + slice_paths: Any = None, +) -> tuple[float, float, float]: + """ + Extracts the accumulated 3D Euler rotation angles (rx, ry, rz) in radians + from the first object or raw_geometry / slice_paths. + """ + candidates: list[Any] = [] + if slicing_objs: + candidates.extend(slicing_objs) + if raw_geometry is not None and raw_geometry not in candidates: + candidates.append(raw_geometry) + if slice_paths is not None and slice_paths not in candidates: + candidates.append(slice_paths) + + for item in candidates: + transforms = getattr(item, "transforms", None) + if transforms: + rx, ry, rz = 0.0, 0.0, 0.0 + has_rot = False + for t in transforms: + if isinstance(t, dict) and "rotation" in t: + r = t["rotation"] + rx += float(r[0]) + ry += float(r[1]) + rz += float(r[2]) + has_rot = True + if has_rot: + return (rx, ry, rz) + + return (0.0, 0.0, 0.0) + + +def generate_grid_slice_paths( + grid_min_x: float, + grid_max_x: float, + grid_min_y: float, + grid_max_y: float, + minor_step: float, + max_grid_steps: int = 2000, +) -> tuple[Any, Any]: + """ + Generates 3D grid line geometry sitting on the XY plane (Z=0) as two SlicePaths objects: + (minor_grid_slice_paths, main_grid_slice_paths). + """ + from ..slice import SlicePaths + + k_x_start = int(np.round(grid_min_x / minor_step)) + k_x_end = int(np.round(grid_max_x / minor_step)) + k_x = np.arange(k_x_start, k_x_end + 1) + + k_y_start = int(np.round(grid_min_y / minor_step)) + k_y_end = int(np.round(grid_max_y / minor_step)) + k_y = np.arange(k_y_start, k_y_end + 1) + + if len(k_x) > max_grid_steps or len(k_y) > max_grid_steps: + return SlicePaths(), SlicePaths() + + minor_paths: list[NDArray[np.float64]] = [] + main_paths: list[NDArray[np.float64]] = [] + + # 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) + if k % 10 == 0: + main_paths.append(line) + else: + minor_paths.append(line) + + # 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) + if k % 10 == 0: + main_paths.append(line) + else: + minor_paths.append(line) + + return SlicePaths(minor_paths), SlicePaths(main_paths) + + + def extract_slice_paths_edges(slice_paths: Any) -> NDArray[np.float64]: """ Extracts 2D line segments (shape N x 2 x 2) from SlicePaths or a list/collection of SlicePaths. diff --git a/core/pdf_exporter/writer.py b/core/pdf_exporter/writer.py index e912964..bc39b95 100644 --- a/core/pdf_exporter/writer.py +++ b/core/pdf_exporter/writer.py @@ -24,6 +24,9 @@ from .geometry import ( extract_crease_edges, extract_slice_paths_edges, deduplicate_2d_edges, + transform_slice_paths, + get_first_object_rotation, + generate_grid_slice_paths, ) from .sa2_measures import render_sa2_measures_pdf from .sizing import ( @@ -836,31 +839,80 @@ class export_mesh_to_pdf: target_edges = self.edges_to_draw if len(self.edges_to_draw) > 0 else self.edges_2d if len(target_edges) > 0: pts_2d = target_edges.reshape(-1, 2) - min_x, max_x = float(pts_2d[:, 0].min()), float(pts_2d[:, 0].max()) - min_y, max_y = float(pts_2d[:, 1].min()), float(pts_2d[:, 1].max()) else: - min_x = max_x = min_y = max_y = 0.0 + pts_2d = np.empty((0, 2), dtype=np.float64) - w_geom = max_x - min_x - h_geom = max_y - min_y + # Extract 3D rotation of the first object + rot_vec = get_first_object_rotation( + self.slicing_objs, self.raw_geometry, self.slice_paths + ) + r_arr = np.array(rot_vec, dtype=np.float64) + + # Calculate rotation matrix R to project world points into local unrotated object space + 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) + + if len(pts_2d) > 0: + pts_3d_world = np.column_stack([pts_2d, np.zeros(len(pts_2d), dtype=np.float64)]) + 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()) + else: + l_min_x, l_max_x, l_min_y, l_max_y = -50.0, 50.0, -50.0, 50.0 + + 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) - if len(self.edges_to_draw) > 0 or len(self.edges_2d) > 0: - pad_x = self.minor_step - pad_y = self.minor_step + 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 - grid_min_x = min_x - pad_x - grid_max_x = max_x + pad_x - grid_min_y = min_y - pad_y - grid_max_y = max_y + pad_y + # 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.minor_step, + ) - self.grid_min_x = math.floor(grid_min_x / self.minor_step) * self.minor_step - self.grid_max_x = math.ceil(grid_max_x / self.minor_step) * self.minor_step - self.grid_min_y = math.floor(grid_min_y / self.minor_step) * self.minor_step - self.grid_max_y = math.ceil(grid_max_y / self.minor_step) * self.minor_step + # Rotate grid SlicePaths back into world space + rot_minor_sp = transform_slice_paths(minor_grid_sp, np.zeros(3), r_arr, np.ones(3)) + rot_main_sp = transform_slice_paths(main_grid_sp, np.zeros(3), r_arr, np.ones(3)) + + # Extract 2D top-view edge segments from rotated SlicePaths + minor_edges_2d = extract_slice_paths_edges(rot_minor_sp) + main_edges_2d = extract_slice_paths_edges(rot_main_sp) + + # Compute world-space canvas bounding box covering all objects and rotated grid lines + all_world_pts_list: list[NDArray[np.float64]] = [] + if len(pts_2d) > 0: + all_world_pts_list.append(pts_2d) + if len(minor_edges_2d) > 0: + all_world_pts_list.append(minor_edges_2d.reshape(-1, 2)) + if len(main_edges_2d) > 0: + all_world_pts_list.append(main_edges_2d.reshape(-1, 2)) + + if all_world_pts_list: + all_world_pts = np.vstack(all_world_pts_list) + w_min_x, w_max_x = float(all_world_pts[:, 0].min()), float(all_world_pts[:, 0].max()) + w_min_y, w_max_y = float(all_world_pts[:, 1].min()), float(all_world_pts[:, 1].max()) else: - self.grid_min_x, self.grid_max_x = -50.0, 50.0 - self.grid_min_y, self.grid_max_y = -50.0, 50.0 + w_min_x, w_max_x, w_min_y, w_max_y = -50.0, 50.0, -50.0, 50.0 + + self.grid_min_x = math.floor(w_min_x / self.minor_step) * self.minor_step + self.grid_max_x = math.ceil(w_max_x / self.minor_step) * self.minor_step + self.grid_min_y = math.floor(w_min_y / self.minor_step) * self.minor_step + self.grid_max_y = math.ceil(w_max_y / self.minor_step) * self.minor_step w_grid_mm = self.grid_max_x - self.grid_min_x h_grid_mm = self.grid_max_y - self.grid_min_y @@ -903,50 +955,20 @@ class export_mesh_to_pdf: self.page_w_pt = w_total_mm * MM_TO_PT self.page_h_pt = h_total_mm * MM_TO_PT - max_grid_steps = 2000 - - # Vectorized vertical grid lines - k_x_start = int(np.round(self.grid_min_x / self.minor_step)) - k_x_end = int(np.round(self.grid_max_x / self.minor_step)) - k_x = np.arange(k_x_start, k_x_end + 1) - - if len(k_x) <= max_grid_steps: - gx = k_x * self.minor_step - pts0_v = np.column_stack([gx, np.full_like(gx, self.grid_min_y)]) - pts1_v = np.column_stack([gx, np.full_like(gx, self.grid_max_y)]) - - p0_v = self.to_pdf_pt(pts0_v) - p1_v = self.to_pdf_pt(pts1_v) - is_main_x = (k_x % 10 == 0) - - main_v_lines = np.column_stack([p0_v[is_main_x], p1_v[is_main_x]]).reshape(-1, 2, 2) - minor_v_lines = np.column_stack([p0_v[~is_main_x], p1_v[~is_main_x]]).reshape(-1, 2, 2) + # Convert coordinates to PDF point coordinates + if len(minor_edges_2d) > 0: + p0_pt = self.to_pdf_pt(minor_edges_2d[:, 0, :]) + p1_pt = self.to_pdf_pt(minor_edges_2d[:, 1, :]) + self.minor_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2) else: - main_v_lines = np.empty((0, 2, 2)) - minor_v_lines = np.empty((0, 2, 2)) + self.minor_lines_arr = np.empty((0, 2, 2), dtype=np.float64) - # Vectorized horizontal grid lines - k_y_start = int(np.round(self.grid_min_y / self.minor_step)) - k_y_end = int(np.round(self.grid_max_y / self.minor_step)) - k_y = np.arange(k_y_start, k_y_end + 1) - - if len(k_y) <= max_grid_steps: - gy = k_y * self.minor_step - pts0_h = np.column_stack([np.full_like(gy, self.grid_min_x), gy]) - pts1_h = np.column_stack([np.full_like(gy, self.grid_max_x), gy]) - - p0_h = self.to_pdf_pt(pts0_h) - p1_h = self.to_pdf_pt(pts1_h) - is_main_y = (k_y % 10 == 0) - - main_h_lines = np.column_stack([p0_h[is_main_y], p1_h[is_main_y]]).reshape(-1, 2, 2) - minor_h_lines = np.column_stack([p0_h[~is_main_y], p1_h[~is_main_y]]).reshape(-1, 2, 2) + if len(main_edges_2d) > 0: + p0_pt = self.to_pdf_pt(main_edges_2d[:, 0, :]) + p1_pt = self.to_pdf_pt(main_edges_2d[:, 1, :]) + self.main_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2) else: - main_h_lines = np.empty((0, 2, 2)) - minor_h_lines = np.empty((0, 2, 2)) - - self.main_lines_arr = np.concatenate([main_v_lines, main_h_lines], axis=0) if len(main_v_lines) or len(main_h_lines) else np.empty((0, 2, 2)) - self.minor_lines_arr = np.concatenate([minor_v_lines, minor_h_lines], axis=0) if len(minor_v_lines) or len(minor_h_lines) else np.empty((0, 2, 2)) + self.main_lines_arr = np.empty((0, 2, 2), dtype=np.float64) def to_pdf_pt(self, pt_mm: NDArray[np.float64]) -> NDArray[np.float64]: x_pt = (pt_mm[:, 0] - self.grid_min_x + self.total_margin_mm) * MM_TO_PT @@ -1044,7 +1066,3 @@ class export_mesh_to_pdf: f.write(b"startxref\n") f.write(f"{xref_offset}\n".encode("utf-8")) f.write(b"%%EOF\n") - - -# Alias for backward compatibility -export_wireframe_to_pdf = export_mesh_to_pdf diff --git a/tests/test_pdf_exporter.py b/tests/test_pdf_exporter.py index 29a83fa..58e87df 100644 --- a/tests/test_pdf_exporter.py +++ b/tests/test_pdf_exporter.py @@ -48,7 +48,7 @@ from NodeSlicer.core.pdf_exporter import ( extract_lineart_edges, extract_outer_contour_edges, extract_slicing_object_features, - export_wireframe_to_pdf, + export_mesh_to_pdf, export_mesh_to_pdf, escape_pdf_text, MM_TO_PT @@ -238,7 +238,7 @@ class TestPdfExporter(unittest.TestCase): with tempfile.TemporaryDirectory() as tmpdir: pdf_path = os.path.join(tmpdir, "test_true_scale.pdf") - export_wireframe_to_pdf( + export_mesh_to_pdf( edges_2d, pdf_path, margin_mm=margin_mm, @@ -288,7 +288,7 @@ class TestPdfExporter(unittest.TestCase): for i, cfg in enumerate(configs): with tempfile.TemporaryDirectory() as tmpdir: pdf_path = os.path.join(tmpdir, f"test_layers_{i}.pdf") - export_wireframe_to_pdf( + export_mesh_to_pdf( edges_2d, pdf_path, crease_angle=30.0, @@ -1503,6 +1503,74 @@ class TestPdfExporter(unittest.TestCase): content = decompress_pdf_streams(f.read()) self.assertIn("(GearWheel) Tj", content) + def test_3d_grid_slice_paths_generation_and_rotation(self): + from NodeSlicer.core.pdf_exporter.geometry import ( + generate_grid_slice_paths, + get_first_object_rotation, + transform_slice_paths, + extract_slice_paths_edges, + ) + from NodeSlicer.core.slice import SlicePaths + from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject + + # 1. Test generate_grid_slice_paths + minor_sp, main_sp = generate_grid_slice_paths(-20.0, 20.0, -20.0, 20.0, 5.0) + self.assertIsInstance(minor_sp, SlicePaths) + self.assertIsInstance(main_sp, SlicePaths) + self.assertGreater(len(minor_sp), 0) + self.assertGreater(len(main_sp), 0) + + # 2. Test get_first_object_rotation + so = SlicingObject() + so.transforms = [ + {"type": "transform_geometry", "rotation": (0.0, 0.0, np.pi / 4.0)} + ] + rot = get_first_object_rotation([so]) + self.assertAlmostEqual(rot[2], np.pi / 4.0) + + # 3. Test rotating grid slice paths + rot_minor = transform_slice_paths(minor_sp, np.zeros(3), np.array(rot), np.ones(3)) + edges_2d = extract_slice_paths_edges(rot_minor) + self.assertGreater(len(edges_2d), 0) + self.assertEqual(edges_2d.shape[1:], (2, 2)) + + def test_export_mesh_to_pdf_with_object_rotation(self): + from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject + so = SlicingObject() + so.name = "RotatedCube" + so.mesh = np.array([[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0]]], dtype=np.float64) + so.transforms = [ + {"type": "transform_geometry", "rotation": (0.0, 0.0, np.pi / 6.0)} + ] + + with tempfile.TemporaryDirectory() as tmpdir: + pdf_file = os.path.join(tmpdir, "rotated_grid_output.pdf") + export_mesh_to_pdf( + raw_geometry=so, + filepath=pdf_file, + draw_wireframe=True, + ) + self.assertTrue(os.path.exists(pdf_file)) + with open(pdf_file, "rb") as f: + content = decompress_pdf_streams(f.read()) + def test_grid_covers_rotated_object_bounds(self): + from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject + so = SlicingObject() + so.name = "ZRotatedBox" + # 20x20 box centered at (50, 50) + tri = np.array([[40.0, 40.0, 0.0], [60.0, 40.0, 0.0], [40.0, 60.0, 0.0]], dtype=np.float64) + so.mesh = np.array([tri]) + so.transforms = [ + {"type": "transform_geometry", "rotation": (0.0, 0.0, np.pi / 4.0)} + ] + + exporter = export_mesh_to_pdf(raw_geometry=so) + self.assertGreater(len(exporter.minor_lines_arr), 0) + self.assertGreater(len(exporter.main_lines_arr), 0) + # Verify exporter bounds cleanly encompass geometry + self.assertLessEqual(exporter.grid_min_x, 0.0) + self.assertGreaterEqual(exporter.grid_max_x, 70.0) + if __name__ == '__main__': unittest.main() @@ -1510,3 +1578,4 @@ if __name__ == '__main__': +