From 122f83b0873fa9f3972a50c79d10732700b5532d Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Wed, 29 Jul 2026 21:56:37 +0300 Subject: [PATCH] naming and sizes --- core/nodeslicer/nodes/mesh.py | 2 +- core/nodeslicer/nodes/output.py | 21 +++ core/pdf_exporter.py | 222 +++++++++++++++++++++++- doc/NODES.md | 5 +- tests/test_pdf_exporter.py | 287 ++++++++++++++++++++++++++++++++ 5 files changed, 528 insertions(+), 9 deletions(-) diff --git a/core/nodeslicer/nodes/mesh.py b/core/nodeslicer/nodes/mesh.py index 7637acb..9b0c750 100644 --- a/core/nodeslicer/nodes/mesh.py +++ b/core/nodeslicer/nodes/mesh.py @@ -595,7 +595,7 @@ class SLICENODE_RenameMesh(SliceNode): self.add_output("SLICESOCK_Mesh", "Geometry") def draw_buttons(self, context, layout): - super().draw_buttons(context, layout) + assert layout layout.prop(self, "new_name") def evaluate_output(self, socket_name: str, **_kwargs) -> Any: diff --git a/core/nodeslicer/nodes/output.py b/core/nodeslicer/nodes/output.py index 56a0e1f..8ed7d89 100644 --- a/core/nodeslicer/nodes/output.py +++ b/core/nodeslicer/nodes/output.py @@ -362,6 +362,9 @@ class SLICENODE_MeshToPdf(ExecSliceNode): draw_contour: NfBoolProperty(name="Contour", default=False) # pyright: ignore[reportInvalidTypeForm] draw_crease: NfBoolProperty(name="Crease Angle", default=False) # pyright: ignore[reportInvalidTypeForm] crease_angle: NfFloatProperty(name="Angle (°)", default=30.0, min=0.0, max=180.0) # pyright: ignore[reportInvalidTypeForm] + draw_bbox: NfBoolProperty(name="Bounding Box", default=False) # pyright: ignore[reportInvalidTypeForm] + show_name: NfBoolProperty(name="Show Name", default=False) # pyright: ignore[reportInvalidTypeForm] + show_bbox_size: NfBoolProperty(name="Bounding Box Sizes", default=False) # pyright: ignore[reportInvalidTypeForm] line_width: NfFloatProperty(name="Line Width (mm)", default=0.5, min=0.01, max=50.0) # pyright: ignore[reportInvalidTypeForm] margin: NfFloatProperty(name="Margin (mm)", default=10.0, min=0.0, max=500.0) # pyright: ignore[reportInvalidTypeForm] @@ -384,6 +387,15 @@ class SLICENODE_MeshToPdf(ExecSliceNode): size=3 ) # pyright: ignore[reportInvalidTypeForm] + bbox_color: NfFloatVectorProperty( + name="BBox Color", + subtype='COLOR', + default=(0.0, 0.0, 1.0), + min=0.0, + max=1.0, + size=3 + ) # pyright: ignore[reportInvalidTypeForm] + def init(self, context): self._init_exec() self.add_input("SLICESOCK_DirPath", "Folder") @@ -397,6 +409,11 @@ class SLICENODE_MeshToPdf(ExecSliceNode): layout.prop(self, "draw_crease") if self.draw_crease: layout.prop(self, "crease_angle") + layout.prop(self, "draw_bbox") + if self.draw_bbox: + layout.prop(self, "bbox_color") + layout.prop(self, "show_name") + layout.prop(self, "show_bbox_size") layout.prop(self, "line_width") layout.prop(self, "margin") layout.prop(self, "bg_color") @@ -453,11 +470,15 @@ class SLICENODE_MeshToPdf(ExecSliceNode): draw_boundary=self.draw_boundary, draw_contour=self.draw_contour, draw_crease=self.draw_crease, + draw_bbox=self.draw_bbox, + show_name=self.show_name, + show_bbox_size=self.show_bbox_size, crease_angle=self.crease_angle, margin_mm=self.margin, line_width_mm=self.line_width, stroke_color=(float(self.wire_color[0]), float(self.wire_color[1]), float(self.wire_color[2])), bg_color=(float(self.bg_color[0]), float(self.bg_color[1]), float(self.bg_color[2])), + bbox_color=(float(self.bbox_color[0]), float(self.bbox_color[1]), float(self.bbox_color[2])), raw_geometry=geom ) if operator: operator.report({'INFO'}, f"Mesh PDF saved to {filepath}") diff --git a/core/pdf_exporter.py b/core/pdf_exporter.py index b056e7c..004fe9f 100644 --- a/core/pdf_exporter.py +++ b/core/pdf_exporter.py @@ -459,6 +459,96 @@ def _format_pdf_lines(lines_arr: NDArray[np.float64]) -> list[str]: return [f"{row[0]:.2f} {row[1]:.2f} m {row[2]:.2f} {row[3]:.2f} l" for row in flat] +def extract_object_label_info(mesh_data: Any) -> list[tuple[str, float, float, float, float]]: + """ + Extracts (object_name, max_x_mm, y_at_max_x_mm, width_mm, height_mm) for each SlicingObject in mesh_data. + max_x_mm is the X coordinate of the rightmost vertex of the object. + y_at_max_x_mm is the Y coordinate of that rightmost vertex. + """ + from ..infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject + + results: list[tuple[str, float, float, float, float]] = [] + + def process_item(item: Any, fallback_name: str = ""): + if item is None: + return + elif isinstance(item, (list, tuple)): + for i, sub in enumerate(item): + process_item(sub, f"{fallback_name}_{i}" if fallback_name else f"object_{i}") + elif isinstance(item, SlicingGroup): + for key, coll in item.collections.items(): + process_item(coll, key) + elif isinstance(item, SlicingCollection): + coll_name = item.name or fallback_name + for i, so in enumerate(item.objects): + process_item(so, so.name or f"{coll_name}_{i}") + elif isinstance(item, SlicingObject): + if item.mesh.size > 0: + name = item.name or fallback_name or "object" + verts_2d = item.mesh[:, :3, :2].reshape(-1, 2) + if len(verts_2d) > 0: + max_idx = np.argmax(verts_2d[:, 0]) + max_x = float(verts_2d[max_idx, 0]) + min_x = float(verts_2d[:, 0].min()) + y_val = float(verts_2d[max_idx, 1]) + min_y = float(verts_2d[:, 1].min()) + max_y = float(verts_2d[:, 1].max()) + + w_mm = max_x - min_x + h_mm = max_y - min_y + results.append((name, max_x, y_val, w_mm, h_mm)) + + process_item(mesh_data) + return results + + +# Alias for backward compatibility +extract_object_name_positions = extract_object_label_info + + +def extract_object_bounding_boxes(mesh_data: Any) -> NDArray[np.float64]: + """ + Extracts 2D top-view bounding box rectangle edge segments (shape N x 2 x 2) for each SlicingObject in mesh_data. + """ + from ..infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject + + all_segments: list[np.ndarray] = [] + + def process_item(item: Any): + if item is None: + return + elif isinstance(item, (list, tuple)): + for sub in item: + process_item(sub) + elif isinstance(item, SlicingGroup): + for coll in item.collections.values(): + process_item(coll) + elif isinstance(item, SlicingCollection): + for so in item.objects: + process_item(so) + elif isinstance(item, SlicingObject): + if item.mesh.size > 0: + verts_2d = item.mesh[:, :3, :2].reshape(-1, 2) + if len(verts_2d) > 0: + min_x = float(verts_2d[:, 0].min()) + max_x = float(verts_2d[:, 0].max()) + min_y = float(verts_2d[:, 1].min()) + max_y = float(verts_2d[:, 1].max()) + + rect = np.array([ + [[min_x, min_y], [max_x, min_y]], + [[max_x, min_y], [max_x, max_y]], + [[max_x, max_y], [min_x, max_y]], + [[min_x, max_y], [min_x, min_y]] + ], dtype=np.float64) + all_segments.append(rect) + + process_item(mesh_data) + if all_segments: + return np.vstack(all_segments) + return np.empty((0, 2, 2), dtype=np.float64) + + def export_mesh_to_pdf( edges_2d: NDArray[np.float64], filepath: str, @@ -466,11 +556,15 @@ def export_mesh_to_pdf( draw_boundary: bool = False, draw_contour: bool = False, draw_crease: bool = False, + draw_bbox: bool = False, + show_name: bool = False, + show_bbox_size: bool = False, crease_angle: float = 30.0, margin_mm: float = 10.0, line_width_mm: float = 0.5, stroke_color: tuple[float, float, float] = (0.0, 0.0, 0.0), bg_color: tuple[float, float, float] = (1.0, 1.0, 1.0), + bbox_color: tuple[float, float, float] = (0.0, 0.0, 1.0), raw_geometry: Any = None, style: str | None = None # Backward compatibility ) -> None: @@ -671,7 +765,26 @@ def export_mesh_to_pdf( stream_cmds.extend(lines_str[start_idx:start_idx + 5000]) stream_cmds.append("S") - # 6. Size Scale Legend (Bottom Left, aligned directly on the bottom main grid lines with exact wireframe thickness) + # 5.5. Object Bounding Boxes (100% Opacity in bbox_color) + if draw_bbox and raw_geometry is not None: + bbox_edges = extract_object_bounding_boxes(raw_geometry) + if len(bbox_edges) > 0: + br, bg, bb = bbox_color + stream_cmds.append("/GS100 gs") + stream_cmds.append(f"{line_width_pt:.2f} w") + stream_cmds.append("1 j 1 J") + stream_cmds.append(f"{br:.3f} {bg:.3f} {bb:.3f} RG") + + p0_pt = to_pdf_pt(bbox_edges[:, 0, :]) + p1_pt = to_pdf_pt(bbox_edges[:, 1, :]) + bbox_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2) + lines_str = _format_pdf_lines(bbox_lines_arr) + + for start_idx in range(0, len(lines_str), 5000): + stream_cmds.extend(lines_str[start_idx:start_idx + 5000]) + stream_cmds.append("S") + + # 6. Size Scale Legend (Bottom Left, aligned directly on bottom-left main grid lines with arrows and X/Y labels) stream_cmds.append("/GS100 gs") x_grid_start_mm = grid_min_x x_grid_end_mm = min(grid_max_x, grid_min_x + 10.0 * main_step) @@ -684,14 +797,37 @@ def export_mesh_to_pdf( x_end_pt = p_end_pt[0, 0] y_bar_pt = p_start_pt[0, 1] + # Vertical Y-axis extension: 5 minor grid lines up from origin + y_grid_top_mm = grid_min_y + 5.0 * minor_step + p_top_pt = to_pdf_pt(np.array([[grid_min_x, y_grid_top_mm]])) + y_top_pt = p_top_pt[0, 1] + + # Smart arrowhead scaling based on line_width_pt and font size + font_size_pt = max(8.0, line_width_pt * 4.0) + arrow_len_pt = max(3.5 * MM_TO_PT, line_width_pt * 4.5) + arrow_half_w_pt = arrow_len_pt * 0.40 + # Exact same stroke thickness as object wireframe lines stream_cmds.append(f"{line_width_pt:.2f} w") stream_cmds.append("1 j 1 J") - # Main horizontal legend line directly overlapping bottom grid line - stream_cmds.append(f"{x_start_pt:.2f} {y_bar_pt:.2f} m {x_end_pt:.2f} {y_bar_pt:.2f} l S") + # 6a. Main horizontal X-axis line (spans 10 main grid steps, extends 5 minor grid lines past last tick + right arrow + X label) + x_arrow_base_mm = x_grid_end_mm + 5.0 * minor_step + p_arrow_base_pt = to_pdf_pt(np.array([[x_arrow_base_mm, grid_min_y]])) + x_arrow_base_pt = p_arrow_base_pt[0, 0] + x_arrow_tip_pt = x_arrow_base_pt + arrow_len_pt - # Ticks directly overlapping vertical main grid lines + # Horizontal main scale bar line + stream_cmds.append(f"{x_start_pt:.2f} {y_bar_pt:.2f} m {x_arrow_tip_pt:.2f} {y_bar_pt:.2f} l S") + + # Pointy arrowhead (+X) extending past 5th minor grid line beyond x_end_pt + stream_cmds.append( + f"{x_arrow_base_pt:.2f} {y_bar_pt + arrow_half_w_pt:.2f} m " + f"{x_arrow_tip_pt:.2f} {y_bar_pt:.2f} l " + f"{x_arrow_base_pt:.2f} {y_bar_pt - arrow_half_w_pt:.2f} l S" + ) + + # Ticks directly overlapping vertical main grid lines on X-axis (full 10 main grid steps including k=0) tick_cmds = [] for k in range(num_main_steps + 1): xk_mm = x_grid_start_mm + k * main_step @@ -702,14 +838,24 @@ def export_mesh_to_pdf( stream_cmds.extend(tick_cmds) stream_cmds.append("S") - # Proportionate font size scaling with linework thickness - font_size_pt = max(8.0, line_width_pt * 4.0) + # 6b. Main vertical Y-axis line (5 minor grid lines up with NO ticks) + top arrow (+Y) + y_arrow_base_pt = y_top_pt + y_arrow_tip_pt = y_arrow_base_pt + arrow_len_pt + stream_cmds.append(f"{x_start_pt:.2f} {y_bar_pt:.2f} m {x_start_pt:.2f} {y_arrow_tip_pt:.2f} l S") + # Pointy arrowhead (+Y) extending past 5th minor grid line (y_top_pt) + stream_cmds.append( + f"{x_start_pt - arrow_half_w_pt:.2f} {y_arrow_base_pt:.2f} m " + f"{x_start_pt:.2f} {y_arrow_tip_pt:.2f} l " + f"{x_start_pt + arrow_half_w_pt:.2f} {y_arrow_base_pt:.2f} l S" + ) + + # 6c. Text labels for measure ticks (skip k=0: NO text label at zero), X label, and Y label stream_cmds.append(f"{sr:.3f} {sg:.3f} {sb:.3f} rg") stream_cmds.append("BT") stream_cmds.append(f"/F1 {font_size_pt:.1f} Tf") - for k in range(num_main_steps + 1): + for k in range(1, num_main_steps + 1): xk_mm = x_grid_start_mm + k * main_step pk_pt = to_pdf_pt(np.array([[xk_mm, grid_min_y]])) xk_pt = pk_pt[0, 0] @@ -729,8 +875,70 @@ def export_mesh_to_pdf( stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {y_text_pt:.2f} Tm") stream_cmds.append(f"({tick_str}) Tj") + # X axis label next to right arrowhead + x_x_label_pt = x_arrow_tip_pt + (2.0 * MM_TO_PT) + y_x_label_pt = y_bar_pt - (font_size_pt * 0.35) + stream_cmds.append(f"1 0 0 1 {x_x_label_pt:.2f} {y_x_label_pt:.2f} Tm") + stream_cmds.append("(X) Tj") + + # Y axis label next to top arrowhead + x_y_label_pt = x_start_pt - (font_size_pt * 0.35) + y_y_label_pt = y_arrow_tip_pt + (2.0 * MM_TO_PT) + stream_cmds.append(f"1 0 0 1 {x_y_label_pt:.2f} {y_y_label_pt:.2f} Tm") + stream_cmds.append("(Y) Tj") + stream_cmds.append("ET") + # 7. Object Names and Bounding Box Sizes (to the right of the rightmost vertex) + if (show_name or show_bbox_size) and raw_geometry is not None: + label_infos = extract_object_label_info(raw_geometry) + if label_infos: + font_size_pt = max(8.0, line_width_pt * 4.0) + line_spacing_pt = font_size_pt * 1.2 + stream_cmds.append("/GS100 gs") + stream_cmds.append(f"{sr:.3f} {sg:.3f} {sb:.3f} rg") + stream_cmds.append("BT") + stream_cmds.append(f"/F1 {font_size_pt:.1f} Tf") + + for obj_name, max_x_mm, y_val_mm, w_obj_mm, h_obj_mm in label_infos: + p_pt = to_pdf_pt(np.array([[max_x_mm, y_val_mm]])) + x_text_pt = p_pt[0, 0] + (2.5 * MM_TO_PT) + y_text_pt = p_pt[0, 1] - (font_size_pt * 0.35) + + if show_name and show_bbox_size: + clean_name = str(obj_name).replace("(", "\\(").replace(")", "\\)") + stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {y_text_pt:.2f} Tm") + stream_cmds.append(f"({clean_name}) Tj") + + if main_step >= 1000.0: + w_val = w_obj_mm / 1000.0 + h_val = h_obj_mm / 1000.0 + size_str = f"(X: {w_val:g}m Y: {h_val:g}m)" + else: + size_str = f"(X: {w_obj_mm:g}mm Y: {h_obj_mm:g}mm)" + + clean_size = size_str.replace("(", "\\(").replace(")", "\\)") + y_line2_pt = y_text_pt - line_spacing_pt + stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {y_line2_pt:.2f} Tm") + stream_cmds.append(f"({clean_size}) Tj") + elif show_name: + clean_name = str(obj_name).replace("(", "\\(").replace(")", "\\)") + stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {y_text_pt:.2f} Tm") + stream_cmds.append(f"({clean_name}) Tj") + elif show_bbox_size: + if main_step >= 1000.0: + w_val = w_obj_mm / 1000.0 + h_val = h_obj_mm / 1000.0 + size_str = f"(X: {w_val:g}m Y: {h_val:g}m)" + else: + size_str = f"(X: {w_obj_mm:g}mm Y: {h_obj_mm:g}mm)" + + clean_size = size_str.replace("(", "\\(").replace(")", "\\)") + stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {y_text_pt:.2f} Tm") + stream_cmds.append(f"({clean_size}) Tj") + + stream_cmds.append("ET") + stream_content = "\n".join(stream_cmds) + "\n" stream_bytes = stream_content.encode("utf-8") diff --git a/doc/NODES.md b/doc/NODES.md index 374693c..f08ec8e 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -125,7 +125,10 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Boundary (1 Face)**: Checkbox to render open mesh boundary edges owned by only 1 face. - **Contour**: Checkbox to render outer boundary silhouette contour. - **Crease Angle**: Checkbox to render crease lines above specified dihedral angle. - - **Angle (°)**: Maximum dihedral angle threshold (default 30°) for marking feature crease lines when Crease Angle is enabled. + - **Bounding Box**: Checkbox to render the 2D bounding box rectangle of each `SlicingObject`. + - **BBox Color**: Color picker for the bounding box rectangle lines (default blue). + - **Show Name**: Checkbox to render the object name to the right of the rightmost vertex of each `SlicingObject`. + - **Bounding Box Sizes**: Checkbox to render 2D bounding box size dimensions (e.g. `X: 120mm Y: 230mm` or `X: 1.2m Y: 2.3m`) next to each object label. - **Line Width (mm)**: Stroke width in millimeters for the exported lines. - **Margin (mm)**: Margin around the geometry grid in mm. - **Background**: Color picker for the canvas background. diff --git a/tests/test_pdf_exporter.py b/tests/test_pdf_exporter.py index 0305fe2..f663e1f 100644 --- a/tests/test_pdf_exporter.py +++ b/tests/test_pdf_exporter.py @@ -38,6 +38,7 @@ from NodeSlicer.core.pdf_exporter import ( extract_unique_top_view_edges, extract_lineart_edges, export_wireframe_to_pdf, + export_mesh_to_pdf, MM_TO_PT ) @@ -192,6 +193,292 @@ class TestPdfExporter(unittest.TestCase): content = f.read() self.assertTrue(content.startswith(b"%PDF-1.4")) self.assertTrue(content.endswith(b"%%EOF\n")) + self.assertIn(b"(X) Tj", content) + self.assertIn(b"(Y) Tj", content) + + def test_export_pdf_show_name(self): + so = SlicingObject() + so.name = "MyTestPart" + tri = np.array([ + [0.0, 0.0, 0.0], + [50.0, 0.0, 0.0], + [0.0, 50.0, 0.0], + [0.0, 0.0, 1.0] + ], dtype=np.float64) + so.mesh = np.array([tri]) + + edges_2d = extract_unique_top_view_edges(so) + + with tempfile.TemporaryDirectory() as tmpdir: + pdf_path = os.path.join(tmpdir, "test_show_name.pdf") + export_mesh_to_pdf( + edges_2d, + pdf_path, + show_name=True, + raw_geometry=so + ) + self.assertTrue(os.path.exists(pdf_path)) + with open(pdf_path, "rb") as f: + content = f.read() + self.assertIn(b"(MyTestPart) Tj", content) + + def test_export_pdf_show_bbox_size(self): + so = SlicingObject() + so.name = "GearPart" + tri = np.array([ + [10.0, 20.0, 0.0], + [130.0, 20.0, 0.0], + [10.0, 250.0, 0.0], + [0.0, 0.0, 1.0] + ], dtype=np.float64) + so.mesh = np.array([tri]) + + edges_2d = extract_unique_top_view_edges(so) + + with tempfile.TemporaryDirectory() as tmpdir: + pdf_path = os.path.join(tmpdir, "test_show_bbox.pdf") + export_mesh_to_pdf( + edges_2d, + pdf_path, + show_name=True, + show_bbox_size=True, + raw_geometry=so + ) + self.assertTrue(os.path.exists(pdf_path)) + with open(pdf_path, "rb") as f: + content = f.read() + # Width = 120mm, Height = 230mm -> Line 1: (GearPart) Tj, Line 2: (\(X: 120mm Y: 230mm\)) Tj + self.assertIn(b"(GearPart) Tj", content) + self.assertIn(b"(\\(X: 120mm Y: 230mm\\)) Tj", content) + + def test_export_pdf_draw_bbox(self): + so = SlicingObject() + so.name = "BBoxPart" + tri = np.array([ + [0.0, 0.0, 0.0], + [100.0, 0.0, 0.0], + [0.0, 50.0, 0.0], + [0.0, 0.0, 1.0] + ], dtype=np.float64) + so.mesh = np.array([tri]) + + edges_2d = extract_unique_top_view_edges(so) + + with tempfile.TemporaryDirectory() as tmpdir: + pdf_path = os.path.join(tmpdir, "test_draw_bbox.pdf") + export_mesh_to_pdf( + edges_2d, + pdf_path, + draw_bbox=True, + bbox_color=(0.0, 0.0, 1.0), + raw_geometry=so + ) + self.assertTrue(os.path.exists(pdf_path)) + with open(pdf_path, "rb") as f: + content = f.read() + # BBox color blue: 0.000 0.000 1.000 RG + self.assertIn(b"0.000 0.000 1.000 RG", content) + + def test_pdf_object_physical_size_matches_3d_mesh(self): + """ + Verifies that the physical size of an object exported to PDF (in PostScript points pt) + matches its 3D mesh dimensions (in mm) * (72.0 / 25.4) with 100% mathematical precision. + """ + import re + + mesh_w_mm = 120.0 + mesh_h_mm = 80.0 + min_x_3d, min_y_3d = 25.0, -15.0 + max_x_3d, max_y_3d = min_x_3d + mesh_w_mm, min_y_3d + mesh_h_mm + + so = SlicingObject() + so.name = "ScaledPart" + tri1 = np.array([ + [min_x_3d, min_y_3d, 0.0], + [max_x_3d, min_y_3d, 0.0], + [min_x_3d, max_y_3d, 0.0], + [0.0, 0.0, 1.0] + ], dtype=np.float64) + tri2 = np.array([ + [max_x_3d, min_y_3d, 0.0], + [max_x_3d, max_y_3d, 0.0], + [min_x_3d, max_y_3d, 0.0], + [0.0, 0.0, 1.0] + ], dtype=np.float64) + so.mesh = np.array([tri1, tri2]) + + edges_2d = extract_unique_top_view_edges(so) + + with tempfile.TemporaryDirectory() as tmpdir: + pdf_path = os.path.join(tmpdir, "test_physical_scale.pdf") + export_mesh_to_pdf( + edges_2d, + pdf_path, + draw_wireframe=True, + margin_mm=15.0, + line_width_mm=0.5, + raw_geometry=so + ) + + with open(pdf_path, "rb") as f: + pdf_text = f.read().decode("utf-8", errors="ignore") + + mediabox_match = re.search(r"/MediaBox\s+\[0\s+0\s+(\d+\.\d+)\s+(\d+\.\d+)\]", pdf_text) + self.assertIsNotNone(mediabox_match) + page_w_pt = float(mediabox_match.group(1)) + page_h_pt = float(mediabox_match.group(2)) + + # Verify MediaBox exact conversion: 1 pt = 1/72 inch = 25.4/72 mm + page_w_mm = page_w_pt / MM_TO_PT + page_h_mm = page_h_pt / MM_TO_PT + + self.assertAlmostEqual(page_w_mm * MM_TO_PT, page_w_pt, places=4) + self.assertAlmostEqual(page_h_mm * MM_TO_PT, page_h_pt, places=4) + + # Object physical size in PDF points: + object_w_pt = mesh_w_mm * MM_TO_PT + object_h_pt = mesh_h_mm * MM_TO_PT + + # 120 mm -> 340.15748 pt, 80 mm -> 226.77165 pt + self.assertAlmostEqual(object_w_pt, 120.0 * (72.0 / 25.4), places=5) + self.assertAlmostEqual(object_h_pt, 80.0 * (72.0 / 25.4), places=5) + + # Extract drawn object line coordinates under /GS100 gs from the PDF stream + gs100_idx = pdf_text.find("/GS100 gs") + self.assertNotEqual(gs100_idx, -1) + gs100_section = pdf_text[gs100_idx:] + + line_matches = re.findall(r"(\d+\.\d+)\s+(\d+\.\d+)\s+m\s+(\d+\.\d+)\s+(\d+\.\d+)\s+l", gs100_section) + self.assertTrue(len(line_matches) >= 4) + + obj_x, obj_y = [], [] + for x0, y0, x1, y1 in line_matches[:4]: + obj_x.extend([float(x0), float(x1)]) + obj_y.extend([float(y0), float(y1)]) + + drawn_obj_w_pt = max(obj_x) - min(obj_x) + drawn_obj_h_pt = max(obj_y) - min(obj_y) + + # Assert that actual drawn line span in PDF points EXACTLY equals 3D mesh dimensions + self.assertAlmostEqual(drawn_obj_w_pt, 120.0 * (72.0 / 25.4), places=2) + self.assertAlmostEqual(drawn_obj_h_pt, 80.0 * (72.0 / 25.4), places=2) + + def test_complex_slicing_group_pdf_exporter_end_to_end(self): + """ + End-to-end test on a complex multi-object SlicingGroup containing 3D mechanical + components (chamfered block + hollow prism), testing all PDF layers simultaneously. + """ + import re + + # Object 1: 3D Chamfered Block + so1 = SlicingObject() + so1.name = "BaseBracket" + # Triangles forming a 3D block with a chamfered face + tris1 = [ + # Bottom face + [[10.0, 20.0, 0.0], [110.0, 20.0, 0.0], [10.0, 70.0, 0.0], [0.0, 0.0, -1.0]], + [[110.0, 20.0, 0.0], [110.0, 70.0, 0.0], [10.0, 70.0, 0.0], [0.0, 0.0, -1.0]], + # Top face + [[10.0, 20.0, 30.0], [10.0, 70.0, 30.0], [90.0, 20.0, 30.0], [0.0, 0.0, 1.0]], + [[90.0, 20.0, 30.0], [10.0, 70.0, 30.0], [90.0, 70.0, 30.0], [0.0, 0.0, 1.0]], + # Chamfer face (45 deg angle) + [[90.0, 20.0, 30.0], [90.0, 70.0, 30.0], [110.0, 20.0, 0.0], [0.707, 0.0, 0.707]], + [[110.0, 20.0, 0.0], [90.0, 70.0, 30.0], [110.0, 70.0, 0.0], [0.707, 0.0, 0.707]], + ] + so1.mesh = np.array(tris1, dtype=np.float64) + + # Object 2: 3D Hollow Octagonal Prism + so2 = SlicingObject() + so2.name = "HollowPipe" + # Construct an 8-sided hollow tube in 3D + angles = np.linspace(0, 2 * np.pi, 8, endpoint=False) + r_out = 30.0 + r_in = 15.0 + cx, cy = 180.0, 45.0 + + tube_tris = [] + for i in range(8): + a1, a2 = angles[i], angles[(i + 1) % 8] + x1_o, y1_o = cx + r_out * np.cos(a1), cy + r_out * np.sin(a1) + x2_o, y2_o = cx + r_out * np.cos(a2), cy + r_out * np.sin(a2) + x1_i, y1_i = cx + r_in * np.cos(a1), cy + r_in * np.sin(a1) + x2_i, y2_i = cx + r_in * np.cos(a2), cy + r_in * np.sin(a2) + + # Top ring face + tube_tris.append([[x1_o, y1_o, 50.0], [x2_o, y2_o, 50.0], [x1_i, y1_i, 50.0], [0.0, 0.0, 1.0]]) + tube_tris.append([[x2_o, y2_o, 50.0], [x2_i, y2_i, 50.0], [x1_i, y1_i, 50.0], [0.0, 0.0, 1.0]]) + # Outer wall face + nx, ny = np.cos((a1 + a2) / 2), np.sin((a1 + a2) / 2) + tube_tris.append([[x1_o, y1_o, 0.0], [x2_o, y2_o, 0.0], [x1_o, y1_o, 50.0], [nx, ny, 0.0]]) + tube_tris.append([[x2_o, y2_o, 0.0], [x2_o, y2_o, 50.0], [x1_o, y1_o, 50.0], [nx, ny, 0.0]]) + + so2.mesh = np.array(tube_tris, dtype=np.float64) + + # Assemble into SlicingGroup with 2 collections + sc1 = SlicingCollection([], "BracketColl") + sc1.objects = [so1] + + sc2 = SlicingCollection([], "PipeColl") + sc2.objects = [so2] + + sg = SlicingGroup([]) + sg.collections = {"BracketColl": sc1, "PipeColl": sc2} + + # Extract 2D wireframe edges from SlicingGroup + edges_2d = extract_unique_top_view_edges(sg) + self.assertTrue(len(edges_2d) > 0) + + # Export to PDF with ALL checkboxes enabled + with tempfile.TemporaryDirectory() as tmpdir: + pdf_path = os.path.join(tmpdir, "complex_slicing_group.pdf") + export_mesh_to_pdf( + edges_2d, + pdf_path, + draw_wireframe=True, + draw_boundary=True, + draw_contour=True, + draw_crease=True, + show_name=True, + crease_angle=30.0, + margin_mm=12.5, + line_width_mm=0.8, + stroke_color=(0.1, 0.2, 0.4), + bg_color=(0.95, 0.98, 1.0), + raw_geometry=sg + ) + + self.assertTrue(os.path.exists(pdf_path)) + with open(pdf_path, "rb") as f: + pdf_bytes = f.read() + + pdf_text = pdf_bytes.decode("utf-8", errors="ignore") + + # 1. Check PDF header & footer + self.assertTrue(pdf_bytes.startswith(b"%PDF-1.4")) + self.assertIn(b"%%EOF\n", pdf_bytes) + + # 2. Check MediaBox + mediabox_match = re.search(r"/MediaBox\s+\[0\s+0\s+(\d+\.\d+)\s+(\d+\.\d+)\]", pdf_text) + self.assertIsNotNone(mediabox_match) + w_pt, h_pt = float(mediabox_match.group(1)), float(mediabox_match.group(2)) + self.assertTrue(w_pt > 0 and h_pt > 0) + + # 3. Check ExtGState opacities (/GS25, /GS50, /GS100) + self.assertIn("/GS25 5 0 R", pdf_text) + self.assertIn("/GS50 6 0 R", pdf_text) + self.assertIn("/GS100 7 0 R", pdf_text) + + # 4. Check colors + self.assertIn("0.950 0.980 1.000 rg", pdf_text) # BG color + self.assertIn("0.100 0.200 0.400 RG", pdf_text) # Stroke color + + # 5. Check object name rendering for both objects + self.assertIn("(BaseBracket) Tj", pdf_text) + self.assertIn("(HollowPipe) Tj", pdf_text) + + # 6. Check size legend tick labels + self.assertIn("(10) Tj", pdf_text) + self.assertIn("(100 mm) Tj", pdf_text) if __name__ == '__main__':