From 9ccac33958bd64bd99ffee6e89773c03cbb5c00a Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Mon, 3 Aug 2026 19:10:17 +0300 Subject: [PATCH] classing --- core/pdf_exporter/__init__.py | 20 +- core/pdf_exporter/writer.py | 1047 +++++++++++++++++++-------------- doc/MODULES.md | 2 +- tests/test_pdf_exporter.py | 70 ++- 4 files changed, 678 insertions(+), 461 deletions(-) diff --git a/core/pdf_exporter/__init__.py b/core/pdf_exporter/__init__.py index 1770dd9..720f19e 100644 --- a/core/pdf_exporter/__init__.py +++ b/core/pdf_exporter/__init__.py @@ -46,12 +46,30 @@ from .sizing import MM_TO_PT from .writer import ( export_mesh_to_pdf, export_wireframe_to_pdf, + PDFElement, + PDFBackgroundElement, + PDFGridElement, + PDFGeometryElement, + PDFBoundingBoxElement, + PDFSA2MeasuresElement, + PDFLegendElement, + PDFObjectLabelsElement, + PDFHeaderTableElement, ) __all__ = [ - # Main exporters + # Main exporters & element classes "export_mesh_to_pdf", "export_wireframe_to_pdf", + "PDFElement", + "PDFBackgroundElement", + "PDFGridElement", + "PDFGeometryElement", + "PDFBoundingBoxElement", + "PDFSA2MeasuresElement", + "PDFLegendElement", + "PDFObjectLabelsElement", + "PDFHeaderTableElement", # PDF stream builder "PdfStream", # PDF utilities diff --git a/core/pdf_exporter/writer.py b/core/pdf_exporter/writer.py index c0223ea..baeea18 100644 --- a/core/pdf_exporter/writer.py +++ b/core/pdf_exporter/writer.py @@ -48,10 +48,10 @@ def render_technical_header_table( """ Renders an ISO/DIN technical drawing header block (title block) at the bottom-right corner. """ - w_table_mm = font_size_pt/MM_TO_PT * 25.0 - cell_height_mm = font_size_pt/MM_TO_PT * 1.4 - - h_table_mm = cell_height_mm*3 + w_table_mm = font_size_pt / MM_TO_PT * 25.0 + cell_height_mm = font_size_pt / MM_TO_PT * 1.4 + + h_table_mm = cell_height_mm * 3 x1_pt = page_w_pt - (total_margin_mm * MM_TO_PT) y0_pt = total_margin_mm * MM_TO_PT @@ -146,7 +146,6 @@ def render_technical_header_table( s.end_text() - def get_all_slicing_objects(mesh_data: Any) -> list[Any]: from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject objects = [] @@ -168,525 +167,661 @@ def get_all_slicing_objects(mesh_data: Any) -> list[Any]: return objects -def export_mesh_to_pdf( - edges_2d: NDArray[np.float64], - filepath: str | Path, - draw_wireframe: bool = True, - draw_boundary: bool = False, - draw_contour: bool = False, - draw_crease: bool = False, - draw_bbox: bool = False, - draw_sa2_holes: bool = False, - draw_header: bool = False, - header_info: dict[str, Any] | None = None, - show_name: bool = False, - show_bbox_size: bool = False, - crease_angle: float = 30.0, - margin_mm: float = 10.0, - line_width_mm: float = 0.0, - font_size_mm: float = 0.0, - 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, - settings: Any = None, - **kwargs: Any -) -> None: +class PDFElement: """ - Exports 2D vector geometry to a 1:1 true physical scale vector PDF file. - Supports toggleable line layers: Wireframe, Boundary (1 face), Contour, Crease Angle. + Base class for individual PDF layout and geometry rendering elements. """ - edge_list: list[NDArray[np.float64]] = [] + def __init__(self, exporter: "export_mesh_to_pdf") -> None: + self.exporter = exporter - if draw_wireframe and len(edges_2d) > 0: - edge_list.append(edges_2d) + def render(self, s: PdfStream) -> None: + """ + Renders the element content into the given PdfStream. + """ + raise NotImplementedError - # extract_crease_edges (lineart) already extracts boundary edges, so skip extract_boundary_edges when draw_crease is active - if draw_boundary and not draw_crease and raw_geometry is not None: - b_edges = extract_boundary_edges(raw_geometry) - if len(b_edges) > 0: - edge_list.append(b_edges) - if draw_contour and raw_geometry is not None: - c_edges = extract_outer_contour_edges(raw_geometry) - if len(c_edges) > 0: - edge_list.append(c_edges) +class PDFBackgroundElement(PDFElement): + """ + Renders solid page background fill. + """ + def render(self, s: PdfStream) -> None: + exp = self.exporter + br, bg, bb = exp.bg_color + s.fill_color(br, bg, bb) + s.fill_rect(0, 0, exp.page_w_pt, exp.page_h_pt) - if draw_crease and raw_geometry is not None: - cr_edges = extract_crease_edges(raw_geometry, max_angle_deg=crease_angle) - if len(cr_edges) > 0: - edge_list.append(cr_edges) - if edge_list: - combined = np.concatenate(edge_list, axis=0) - edges_to_draw = deduplicate_2d_edges(combined) - elif draw_wireframe: - edges_to_draw = edges_2d - else: - edges_to_draw = np.empty((0, 2, 2), dtype=np.float64) +class PDFGridElement(PDFElement): + """ + Renders minor (25% opacity) and main (50% opacity) background grid lines. + """ + def render(self, s: PdfStream) -> None: + exp = self.exporter + sr, sg, sb = exp.stroke_color + s.stroke_color(sr, sg, sb) - target_edges = edges_to_draw if len(edges_to_draw) > 0 else 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 + if len(exp.minor_lines_arr) > 0: + s.opacity("GS25").line_width(exp.line_width_pt) + s.draw_line_batch(exp.minor_lines_arr) - w_geom = max_x - min_x - h_geom = max_y - min_y - main_step, minor_step = compute_grid_steps(w_geom, h_geom) + if len(exp.main_lines_arr) > 0: + s.opacity("GS50").line_width(exp.line_width_pt) + s.draw_line_batch(exp.main_lines_arr) - if len(edges_to_draw) > 0 or len(edges_2d) > 0: - pad_x = minor_step - pad_y = minor_step - # Ensure grid is symmetric around center - 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 - - # Ensure grid aligns with minor_step grid lines - grid_min_x = math.floor(grid_min_x / minor_step) * minor_step - grid_max_x = math.ceil(grid_max_x / minor_step) * minor_step - grid_min_y = math.floor(grid_min_y / minor_step) * minor_step - grid_max_y = math.ceil(grid_max_y / minor_step) * minor_step - else: - grid_min_x, grid_max_x = -50.0, 50.0 - grid_min_y, grid_max_y = -50.0, 50.0 +class PDFGeometryElement(PDFElement): + """ + Renders 2D vector geometry (wireframe, boundary, contour, crease lines). + """ + def render(self, s: PdfStream) -> None: + exp = self.exporter + slicing_objs = exp.slicing_objs + if slicing_objs: + for so in slicing_objs: + so_edge_list: list[NDArray[np.float64]] = [] + if exp.draw_wireframe: + so_w = extract_unique_top_view_edges(so) + if len(so_w) > 0: + so_edge_list.append(so_w) + if exp.draw_boundary and not exp.draw_crease: + so_b = extract_boundary_edges(so) + if len(so_b) > 0: + so_edge_list.append(so_b) + if exp.draw_contour: + so_c = extract_outer_contour_edges(so) + if len(so_c) > 0: + so_edge_list.append(so_c) + if exp.draw_crease: + so_cr = extract_crease_edges(so, max_angle_deg=exp.crease_angle) + if len(so_cr) > 0: + so_edge_list.append(so_cr) - w_grid_mm = grid_max_x - grid_min_x - h_grid_mm = grid_max_y - grid_min_y + if so_edge_list: + so_combined = np.concatenate(so_edge_list, axis=0) + so_edges_to_draw = deduplicate_2d_edges(so_combined) + else: + so_edges_to_draw = np.empty((0, 2, 2), dtype=np.float64) - total_margin_mm = margin_mm + 2.0 * minor_step + if len(so_edges_to_draw) > 0: + so_lw_mm = ( + so.pdf_properties.line_width + if (hasattr(so, "pdf_properties") and so.pdf_properties and so.pdf_properties.line_width > 0.0) + else exp.line_width_mm + ) + so_color = ( + so.pdf_properties.line_color + if (hasattr(so, "pdf_properties") and so.pdf_properties and so.pdf_properties.line_color is not None) + else exp.stroke_color + ) - w_total_mm = w_grid_mm + 2.0 * total_margin_mm - h_total_mm = h_grid_mm + 2.0 * total_margin_mm + so_line_width_pt, _ = compute_effective_stroke_and_font( + so_lw_mm, exp.font_size_mm, exp.minor_step + ) - page_w_pt = w_total_mm * MM_TO_PT - page_h_pt = h_total_mm * MM_TO_PT + s.opacity("GS100").line_width(so_line_width_pt).stroke_color(so_color[0], so_color[1], so_color[2]).round_caps() + p0_pt = exp.to_pdf_pt(so_edges_to_draw[:, 0, :]) + p1_pt = exp.to_pdf_pt(so_edges_to_draw[:, 1, :]) + obj_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2) + s.draw_line_batch(obj_lines_arr) + elif len(exp.edges_to_draw) > 0: + s.opacity("GS100").line_width(exp.line_width_pt).stroke_color(exp.stroke_color[0], exp.stroke_color[1], exp.stroke_color[2]).round_caps() + p0_pt = exp.to_pdf_pt(exp.edges_to_draw[:, 0, :]) + p1_pt = exp.to_pdf_pt(exp.edges_to_draw[:, 1, :]) + obj_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2) + s.draw_line_batch(obj_lines_arr) - line_width_pt, font_size_pt = compute_effective_stroke_and_font( - line_width_mm, font_size_mm, minor_step - ) - def to_pdf_pt(pt_mm: NDArray[np.float64]) -> NDArray[np.float64]: - x_pt = (pt_mm[:, 0] - grid_min_x + total_margin_mm) * MM_TO_PT - y_pt = (pt_mm[:, 1] - grid_min_y + total_margin_mm) * MM_TO_PT - return np.column_stack([x_pt, y_pt]) +class PDFBoundingBoxElement(PDFElement): + """ + Renders 2D object bounding boxes in bbox_color. + """ + def render(self, s: PdfStream) -> None: + exp = self.exporter + if exp.draw_bbox and exp.raw_geometry is not None: + bbox_edges = extract_object_bounding_boxes(exp.raw_geometry) + if len(bbox_edges) > 0: + b_r, b_g, b_b = exp.bbox_color + s.opacity("GS100").line_width(exp.line_width_pt).round_caps() + s.stroke_color(b_r, b_g, b_b) - max_grid_steps = 2000 + p0_pt = exp.to_pdf_pt(bbox_edges[:, 0, :]) + p1_pt = exp.to_pdf_pt(bbox_edges[:, 1, :]) + bbox_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2) + s.draw_line_batch(bbox_lines_arr) - # Vectorized vertical grid lines - 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) - if len(k_x) <= max_grid_steps: - gx = k_x * minor_step - pts0_v = np.column_stack([gx, np.full_like(gx, grid_min_y)]) - pts1_v = np.column_stack([gx, np.full_like(gx, grid_max_y)]) +class PDFSA2MeasuresElement(PDFElement): + """ + Renders SA2 technical measurement annotations (bores, holes, pitch, chamfers). + """ + def render(self, s: PdfStream) -> None: + exp = self.exporter + if exp.raw_geometry is not None and exp.draw_sa2_holes: + render_sa2_measures_pdf( + s, + exp.raw_geometry, + draw_sa2_holes=exp.draw_sa2_holes, + minor_step_mm=exp.minor_step, + main_step=exp.main_step, + to_pdf_pt=exp.to_pdf_pt, + font_size_pt=exp.font_size_pt, + line_width_pt=exp.line_width_pt, + draw_wireframe=exp.draw_wireframe, + draw_boundary=exp.draw_boundary, + draw_contour=exp.draw_contour, + draw_crease=exp.draw_crease, + crease_angle=exp.crease_angle, + ) - p0_v = to_pdf_pt(pts0_v) - p1_v = 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) - else: - main_v_lines = np.empty((0, 2, 2)) - minor_v_lines = np.empty((0, 2, 2)) +class PDFLegendElement(PDFElement): + """ + Renders physical size scale legend (axes, tick marks, arrows, and units). + """ + def render(self, s: PdfStream) -> None: + exp = self.exporter + s.opacity("GS100").color(0, 0, 0) - # Vectorized horizontal grid lines - 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) + 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)) - if len(k_y) <= max_grid_steps: - gy = k_y * minor_step - pts0_h = np.column_stack([np.full_like(gy, grid_min_x), gy]) - pts1_h = np.column_stack([np.full_like(gy, grid_max_x), gy]) + 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]])) - p0_h = to_pdf_pt(pts0_h) - p1_h = to_pdf_pt(pts1_h) - is_main_y = (k_y % 10 == 0) + x_start_pt = p_start_pt[0, 0] + x_end_pt = p_end_pt[0, 0] + y_bar_pt = p_start_pt[0, 1] - 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) - else: - main_h_lines = np.empty((0, 2, 2)) - minor_h_lines = np.empty((0, 2, 2)) + 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] - 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)) - 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)) + arrow_len_pt = leg_sizing.arrow_len_pt + arrow_half_w_pt = leg_sizing.arrow_half_w_pt - s = PdfStream() + s.line_width(exp.line_width_pt).round_caps() - # 1. Solid background fill - br, bg, bb = bg_color - sr, sg, sb = stroke_color - s.fill_color(br, bg, bb) - s.fill_rect(0, 0, page_w_pt, page_h_pt) + # 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 - # 2. Wireframe stroke color - s.stroke_color(sr, sg, sb) + s.line_stroke(x_start_pt, y_bar_pt, x_arrow_tip_pt, y_bar_pt) - # 3. Minor Grid Lines (25% Opacity) - if len(minor_lines_arr) > 0: - s.opacity("GS25").line_width(line_width_pt) - s.draw_line_batch(minor_lines_arr) - - # 4. Main Grid Lines (50% Opacity) - if len(main_lines_arr) > 0: - s.opacity("GS50").line_width(line_width_pt) - s.draw_line_batch(main_lines_arr) - - # 5. Object Lines (100% Opacity) - slicing_objs = get_all_slicing_objects(raw_geometry) - if slicing_objs: - for so in slicing_objs: - so_edge_list: list[NDArray[np.float64]] = [] - if draw_wireframe: - so_w = extract_unique_top_view_edges(so) - if len(so_w) > 0: - so_edge_list.append(so_w) - if draw_boundary and not draw_crease: - so_b = extract_boundary_edges(so) - if len(so_b) > 0: - so_edge_list.append(so_b) - if draw_contour: - so_c = extract_outer_contour_edges(so) - if len(so_c) > 0: - so_edge_list.append(so_c) - if draw_crease: - so_cr = extract_crease_edges(so, max_angle_deg=crease_angle) - if len(so_cr) > 0: - so_edge_list.append(so_cr) - - if so_edge_list: - so_combined = np.concatenate(so_edge_list, axis=0) - so_edges_to_draw = deduplicate_2d_edges(so_combined) - else: - so_edges_to_draw = np.empty((0, 2, 2), dtype=np.float64) - - if len(so_edges_to_draw) > 0: - so_lw_mm = so.pdf_properties.line_width if (hasattr(so, "pdf_properties") and so.pdf_properties and so.pdf_properties.line_width > 0.0) else line_width_mm - so_color = so.pdf_properties.line_color if (hasattr(so, "pdf_properties") and so.pdf_properties and so.pdf_properties.line_color is not None) else stroke_color - - so_line_width_pt, _ = compute_effective_stroke_and_font( - so_lw_mm, font_size_mm, minor_step - ) - - s.opacity("GS100").line_width(so_line_width_pt).stroke_color(so_color[0], so_color[1], so_color[2]).round_caps() - p0_pt = to_pdf_pt(so_edges_to_draw[:, 0, :]) - p1_pt = to_pdf_pt(so_edges_to_draw[:, 1, :]) - obj_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2) - s.draw_line_batch(obj_lines_arr) - elif len(edges_to_draw) > 0: - s.opacity("GS100").line_width(line_width_pt).stroke_color(stroke_color[0], stroke_color[1], stroke_color[2]).round_caps() - p0_pt = to_pdf_pt(edges_to_draw[:, 0, :]) - p1_pt = to_pdf_pt(edges_to_draw[:, 1, :]) - obj_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2) - s.draw_line_batch(obj_lines_arr) - - # 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: - b_r, b_g, b_b = bbox_color - s.opacity("GS100").line_width(line_width_pt).round_caps() - s.stroke_color(b_r, b_g, b_b) - - 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) - s.draw_line_batch(bbox_lines_arr) - - # 5.6. SA2 Automated Technical Measures Block (Holes & Concentric Bores) - if raw_geometry is not None and draw_sa2_holes: - render_sa2_measures_pdf( - s, - raw_geometry, - draw_sa2_holes=draw_sa2_holes, - minor_step_mm=minor_step, - main_step=main_step, - to_pdf_pt=to_pdf_pt, - font_size_pt=font_size_pt, - line_width_pt=line_width_pt, - draw_wireframe=draw_wireframe, - draw_boundary=draw_boundary, - draw_contour=draw_contour, - draw_crease=draw_crease, - crease_angle=crease_angle, + 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), ) - # 6. Size Scale Legend (Bottom Left, aligned directly on bottom-left main grid lines with arrows and X/Y labels) - s.opacity("GS100").color(0, 0, 0) + 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() - leg_sizing = LegendSizing(main_step, minor_step, line_width_pt, font_size_pt) - x_grid_start_mm = grid_min_x - x_grid_end_mm = min(grid_max_x, grid_min_x + 10.0 * main_step) - num_main_steps = int(np.round((x_grid_end_mm - x_grid_start_mm) / main_step)) + # 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) - p_start_pt = to_pdf_pt(np.array([[x_grid_start_mm, grid_min_y]])) - p_end_pt = to_pdf_pt(np.array([[x_grid_end_mm, grid_min_y]])) + 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), + ) - x_start_pt = p_start_pt[0, 0] - x_end_pt = p_end_pt[0, 0] - y_bar_pt = p_start_pt[0, 1] + # 6c. Text labels for ticks and axes + s.fill_color(0, 0, 0) + s.begin_text().font("F1", exp.font_size_pt) - # Vertical Y-axis extension dynamically sized past tick height - y_grid_top_mm = grid_min_y + leg_sizing.y_axis_height_mm - p_top_pt = to_pdf_pt(np.array([[grid_min_x, y_grid_top_mm]])) - y_top_pt = p_top_pt[0, 1] + 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 - arrow_len_pt = leg_sizing.arrow_len_pt - arrow_half_w_pt = leg_sizing.arrow_half_w_pt + 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}" - # Exact same stroke thickness as object wireframe lines - s.line_width(line_width_pt).round_caps() + 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) - # 6a. Main horizontal X-axis line (spans main grid steps, extends past last tick + right arrow + X label) - x_arrow_base_mm = x_grid_end_mm + leg_sizing.x_axis_extension_mm - 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 + 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") - # Horizontal main scale bar line - s.line_stroke(x_start_pt, y_bar_pt, x_arrow_tip_pt, y_bar_pt) + 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") - # Pointy arrowhead (+X) extending past last tick - 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), - ) + s.end_text() - # Ticks directly overlapping vertical main grid lines on X-axis - for k in range(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] - 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 + top arrow (+Y) - 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) +class PDFObjectLabelsElement(PDFElement): + """ + Renders object labels, bounding box sizes, and attribute settings. + """ + def render(self, s: PdfStream) -> None: + exp = self.exporter + settings_items = extract_settings_items(exp.settings) + slicing_objs_list = exp.slicing_objs + has_any_obj_show_name = any(hasattr(o, "pdf_properties") and o.pdf_properties and o.pdf_properties.show_name for o in slicing_objs_list) - # Pointy arrowhead (+Y) extending past y_top_pt - 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), - ) + if (exp.show_name or has_any_obj_show_name or exp.show_bbox_size or settings_items) and exp.raw_geometry is not None: + label_infos = extract_object_label_info(exp.raw_geometry) + if label_infos: + eff_font_size_pt = exp.font_size_pt if exp.font_size_pt is not None and exp.font_size_pt > 0 else max(8.0, exp.line_width_pt * 4.0) + line_spacing_pt = eff_font_size_pt * 1.25 + leg_sizing = LegendSizing(exp.main_step, exp.minor_step, exp.line_width_pt, exp.font_size_pt) + s.opacity("GS100") - # 6c. Text labels for measure ticks (skip k=0: NO text label at zero), X label, and Y label - s.fill_color(0, 0, 0) - s.begin_text().font("F1", font_size_pt) + for so_item, obj_name, max_x_mm, y_val_mm, w_obj_mm, h_obj_mm in label_infos: + p_pt = exp.to_pdf_pt(np.array([[max_x_mm, y_val_mm]])) + x_text_pt = p_pt[0, 0] + leg_sizing.label_margin_w_pt + y_text_pt = p_pt[0, 1] - (eff_font_size_pt * 0.35) - 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] - 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 * main_step + lines_to_draw: list[tuple[str, tuple[float, float, float], bool]] = [] - 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}" + so_show_name = (hasattr(so_item, "pdf_properties") and so_item.pdf_properties and so_item.pdf_properties.show_name) or exp.show_name + so_color = ( + so_item.pdf_properties.line_color + if (hasattr(so_item, "pdf_properties") and so_item.pdf_properties and so_item.pdf_properties.line_color is not None) + else exp.stroke_color + ) + if so_show_name: + lines_to_draw.append((str(obj_name), so_color, True)) - tw_pt = len(tick_str) * 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 axis label next to right arrowhead - x_x_label_pt = x_arrow_tip_pt + leg_sizing.label_margin_h_pt - y_x_label_pt = y_bar_pt - (font_size_pt * 0.35) - s.text_position(x_x_label_pt, y_x_label_pt).show("X") - - # 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 + leg_sizing.label_margin_h_pt - s.text_position(x_y_label_pt, y_y_label_pt).show("Y") - - s.end_text() - - # 7. Object Names, Bounding Box Sizes, and Settings Attributes (to the right of the rightmost vertex) - settings_items = extract_settings_items(settings) - slicing_objs_list = get_all_slicing_objects(raw_geometry) - has_any_obj_show_name = any(hasattr(o, "pdf_properties") and o.pdf_properties and o.pdf_properties.show_name for o in slicing_objs_list) - - if (show_name or has_any_obj_show_name or show_bbox_size or settings_items) and raw_geometry is not None: - label_infos = extract_object_label_info(raw_geometry) - if label_infos: - eff_font_size_pt = font_size_pt if font_size_pt is not None and font_size_pt > 0 else max(8.0, line_width_pt * 4.0) - line_spacing_pt = eff_font_size_pt * 1.25 - s.opacity("GS100") - - for so_item, 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] + leg_sizing.label_margin_w_pt - y_text_pt = p_pt[0, 1] - (eff_font_size_pt * 0.35) - - lines_to_draw: list[tuple[str, tuple[float, float, float], bool]] = [] - - so_show_name = (hasattr(so_item, "pdf_properties") and so_item.pdf_properties and so_item.pdf_properties.show_name) or show_name - so_color = ( - so_item.pdf_properties.line_color - if (hasattr(so_item, "pdf_properties") and so_item.pdf_properties and so_item.pdf_properties.line_color is not None) - else stroke_color - ) - if so_show_name: - lines_to_draw.append((str(obj_name), so_color, True)) - - if 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)" - lines_to_draw.append((size_str, bbox_color, False)) - - for key_name, suffix in settings_items: - attr_val = get_first_attribute_value(so_item, key_name) - if attr_val is not None: - if isinstance(attr_val, float): - val_str = f"{attr_val:g}" + if exp.show_bbox_size: + if exp.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: - val_str = str(attr_val) - if suffix: - val_str = f"{val_str}{suffix}" - lines_to_draw.append((f"{key_name}: {val_str}", stroke_color, False)) + size_str = f"(X: {w_obj_mm:g}mm Y: {h_obj_mm:g}mm)" + lines_to_draw.append((size_str, exp.bbox_color, False)) - if lines_to_draw: - for line_idx, (line_str, col, is_bold) in enumerate(lines_to_draw): - cr, cg, cb = col - curr_y_pt = y_text_pt - (line_idx * line_spacing_pt) - s.begin_text().font("F1", eff_font_size_pt) - s.fill_color(cr, cg, cb) - if is_bold: - bold_stroke_w = max(0.2, eff_font_size_pt * 0.035) - s.stroke_color(cr, cg, cb).line_width(bold_stroke_w).text_rendering_mode(2) - else: - s.text_rendering_mode(0) - s.text_position(x_text_pt, curr_y_pt) - s.show(line_str) - if is_bold: - s.text_rendering_mode(0) - s.end_text() + for key_name, suffix in settings_items: + attr_val = get_first_attribute_value(so_item, key_name) + if attr_val is not None: + if isinstance(attr_val, float): + val_str = f"{attr_val:g}" + else: + val_str = str(attr_val) + if suffix: + val_str = f"{val_str}{suffix}" + lines_to_draw.append((f"{key_name}: {val_str}", exp.stroke_color, False)) + + if lines_to_draw: + for line_idx, (line_str, col, is_bold) in enumerate(lines_to_draw): + cr, cg, cb = col + curr_y_pt = y_text_pt - (line_idx * line_spacing_pt) + s.begin_text().font("F1", eff_font_size_pt) + s.fill_color(cr, cg, cb) + if is_bold: + bold_stroke_w = max(0.2, eff_font_size_pt * 0.035) + s.stroke_color(cr, cg, cb).line_width(bold_stroke_w).text_rendering_mode(2) + else: + s.text_rendering_mode(0) + s.text_position(x_text_pt, curr_y_pt) + s.show(line_str) + if is_bold: + s.text_rendering_mode(0) + s.end_text() + + +class PDFHeaderTableElement(PDFElement): + """ + Renders ISO/DIN technical drawing header table at bottom-right corner. + """ + def render(self, s: PdfStream) -> None: + exp = self.exporter + if not exp.draw_header or not exp.header_info: + return - # 8. Technical Drawing Header Block (Bottom Right Table) - if draw_header and header_info: primary_obj_name = "" - if slicing_objs_list: - primary_obj_name = str(getattr(slicing_objs_list[0], "name", "")) - elif raw_geometry is not None and hasattr(raw_geometry, "name"): - primary_obj_name = str(getattr(raw_geometry, "name", "")) + if exp.slicing_objs: + primary_obj_name = str(getattr(exp.slicing_objs[0], "name", "")) + elif exp.raw_geometry is not None and hasattr(exp.raw_geometry, "name"): + primary_obj_name = str(getattr(exp.raw_geometry, "name", "")) render_technical_header_table( s=s, - page_w_pt=page_w_pt, - page_h_pt=page_h_pt, - total_margin_mm=total_margin_mm, - header_info=header_info, - stroke_color=stroke_color, - bg_color=bg_color, - font_size_pt=font_size_pt, - line_width_pt=line_width_pt, + page_w_pt=exp.page_w_pt, + page_h_pt=exp.page_h_pt, + total_margin_mm=exp.total_margin_mm, + header_info=exp.header_info, + stroke_color=exp.stroke_color, + bg_color=exp.bg_color, + font_size_pt=exp.font_size_pt, + line_width_pt=exp.line_width_pt, obj_name=primary_obj_name, ) - stream_bytes = s.encode() - objects: list[bytes] = [] - objects.append(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n") - objects.append(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n") - page_header = ( - f"3 0 obj\n" - f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {page_w_pt:.2f} {page_h_pt:.2f}] " - f"/Resources << /ExtGState << /GS25 5 0 R /GS50 6 0 R /GS100 7 0 R >> /Font << /F1 8 0 R >> >> " - f"/Contents 4 0 R >>\n" - f"endobj\n" - ) - objects.append(page_header.encode("utf-8")) - content_header = f"4 0 obj\n<< /Length {len(stream_bytes)} >>\nstream\n".encode("utf-8") - content_footer = b"endstream\nendobj\n" - objects.append(content_header + stream_bytes + content_footer) - objects.append(b"5 0 obj\n<< /Type /ExtGState /CA 0.25 /ca 0.25 >>\nendobj\n") - objects.append(b"6 0 obj\n<< /Type /ExtGState /CA 0.5 /ca 0.5 >>\nendobj\n") - objects.append(b"7 0 obj\n<< /Type /ExtGState /CA 1.0 /ca 1.0 >>\nendobj\n") +class export_mesh_to_pdf: + """ + Class responsible for configuring, building, and exporting 2D vector mesh geometry + and technical annotations into scalable 1:1 vector PDF files. + """ + # Element Subclasses + Element = PDFElement + BackgroundElement = PDFBackgroundElement + GridElement = PDFGridElement + GeometryElement = PDFGeometryElement + BoundingBoxElement = PDFBoundingBoxElement + SA2MeasuresElement = PDFSA2MeasuresElement + LegendElement = PDFLegendElement + ObjectLabelsElement = PDFObjectLabelsElement + HeaderTableElement = PDFHeaderTableElement - # Embed League Mono Typeface - font_file_path = Path(__file__).parent / "fonts" / "LeagueMono-Regular.otf" - if not font_file_path.exists(): - font_file_path = Path(__file__).parents[2] / "fonts" / "LeagueMono-Regular.otf" + # Convenient aliases for element subclasses + Legend = PDFLegendElement + Table = PDFHeaderTableElement + HeaderTable = PDFHeaderTableElement + Grid = PDFGridElement + Geometry = PDFGeometryElement + BoundingBox = PDFBoundingBoxElement + SA2Measures = PDFSA2MeasuresElement + ObjectLabels = PDFObjectLabelsElement + Background = PDFBackgroundElement - font_bytes: bytes | None = None - if font_file_path.exists(): - try: - with open(font_file_path, "rb") as f: - font_bytes = f.read() - except Exception: - font_bytes = None + def __init__( + self, + edges_2d: NDArray[np.float64], + filepath: str | Path | None = None, + draw_wireframe: bool = True, + draw_boundary: bool = False, + draw_contour: bool = False, + draw_crease: bool = False, + draw_bbox: bool = False, + draw_sa2_holes: bool = False, + draw_header: bool = False, + header_info: dict[str, Any] | None = None, + show_name: bool = False, + show_bbox_size: bool = False, + crease_angle: float = 30.0, + margin_mm: float = 10.0, + line_width_mm: float = 0.0, + font_size_mm: float = 0.0, + 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, + settings: Any = None, + **kwargs: Any, + ) -> None: + self.edges_2d = edges_2d + self.filepath = filepath + self.draw_wireframe = draw_wireframe + self.draw_boundary = draw_boundary + self.draw_contour = draw_contour + self.draw_crease = draw_crease + self.draw_bbox = draw_bbox + self.draw_sa2_holes = draw_sa2_holes + self.draw_header = draw_header + self.header_info = header_info + self.show_name = show_name + self.show_bbox_size = show_bbox_size + self.crease_angle = crease_angle + self.margin_mm = margin_mm + self.line_width_mm = line_width_mm + self.font_size_mm = font_size_mm + self.stroke_color = stroke_color + self.bg_color = bg_color + self.bbox_color = bbox_color + self.raw_geometry = raw_geometry + self.settings = settings - if font_bytes: - widths_str = " ".join(["600"] * 224) - objects.append( - f"8 0 obj\n" - f"<< /Type /Font /Subtype /Type1 /BaseFont /LeagueMono-Regular " - f"/FirstChar 32 /LastChar 255 " - f"/Widths [ {widths_str} ] " - f"/FontDescriptor 9 0 R /Encoding /WinAnsiEncoding >>\nendobj\n".encode("utf-8") + self._prepare_geometry_and_layout() + + self.elements: list[PDFElement] = [ + self.BackgroundElement(self), + self.GridElement(self), + self.GeometryElement(self), + self.BoundingBoxElement(self), + self.SA2MeasuresElement(self), + self.LegendElement(self), + self.ObjectLabelsElement(self), + self.HeaderTableElement(self), + ] + + if filepath is not None: + self.export(filepath) + + def _prepare_geometry_and_layout(self) -> None: + edge_list: list[NDArray[np.float64]] = [] + + if self.draw_wireframe and len(self.edges_2d) > 0: + edge_list.append(self.edges_2d) + + if self.draw_boundary and not self.draw_crease and self.raw_geometry is not None: + b_edges = extract_boundary_edges(self.raw_geometry) + if len(b_edges) > 0: + edge_list.append(b_edges) + + if self.draw_contour and self.raw_geometry is not None: + c_edges = extract_outer_contour_edges(self.raw_geometry) + if len(c_edges) > 0: + edge_list.append(c_edges) + + if self.draw_crease and self.raw_geometry is not None: + cr_edges = extract_crease_edges(self.raw_geometry, max_angle_deg=self.crease_angle) + if len(cr_edges) > 0: + edge_list.append(cr_edges) + + if edge_list: + combined = np.concatenate(edge_list, axis=0) + self.edges_to_draw = deduplicate_2d_edges(combined) + elif self.draw_wireframe: + self.edges_to_draw = self.edges_2d + else: + self.edges_to_draw = np.empty((0, 2, 2), dtype=np.float64) + + 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 + + w_geom = max_x - min_x + h_geom = max_y - min_y + 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 + + 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 + + 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 + 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_grid_mm = self.grid_max_x - self.grid_min_x + h_grid_mm = self.grid_max_y - self.grid_min_y + + self.total_margin_mm = self.margin_mm + 2.0 * self.minor_step + + w_total_mm = w_grid_mm + 2.0 * self.total_margin_mm + h_total_mm = h_grid_mm + 2.0 * self.total_margin_mm + + self.page_w_pt = w_total_mm * MM_TO_PT + self.page_h_pt = h_total_mm * MM_TO_PT + + self.line_width_pt, self.font_size_pt = compute_effective_stroke_and_font( + self.line_width_mm, self.font_size_mm, self.minor_step ) - objects.append( - b"9 0 obj\n" - b"<< /Type /FontDescriptor /FontName /LeagueMono-Regular /Flags 33 " - b"/FontBBox [-200 -200 1200 1000] /ItalicAngle 0 /Ascent 1000 /Descent -200 " - b"/CapHeight 700 /StemV 80 /FontFile3 10 0 R >>\nendobj\n" + + 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) + else: + main_v_lines = np.empty((0, 2, 2)) + minor_v_lines = np.empty((0, 2, 2)) + + # 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) + 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.slicing_objs = get_all_slicing_objects(self.raw_geometry) + + 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 + y_pt = (pt_mm[:, 1] - self.grid_min_y + self.total_margin_mm) * MM_TO_PT + return np.column_stack([x_pt, y_pt]) + + def export(self, filepath: str | Path | None = None) -> None: + target_path = filepath if filepath is not None else self.filepath + if target_path is None: + raise ValueError("No filepath provided for PDF export.") + + s = PdfStream() + + for element in self.elements: + element.render(s) + + stream_bytes = s.encode() + + objects: list[bytes] = [] + objects.append(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n") + objects.append(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n") + page_header = ( + f"3 0 obj\n" + f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {self.page_w_pt:.2f} {self.page_h_pt:.2f}] " + f"/Resources << /ExtGState << /GS25 5 0 R /GS50 6 0 R /GS100 7 0 R >> /Font << /F1 8 0 R >> >> " + f"/Contents 4 0 R >>\n" + f"endobj\n" ) - font_stream_header = f"10 0 obj\n<< /Length {len(font_bytes)} /Subtype /OpenType >>\nstream\n".encode("utf-8") - font_stream_footer = b"\nendstream\nendobj\n" - objects.append(font_stream_header + font_bytes + font_stream_footer) - else: - objects.append(b"8 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>\nendobj\n") + objects.append(page_header.encode("utf-8")) + content_header = f"4 0 obj\n<< /Length {len(stream_bytes)} >>\nstream\n".encode("utf-8") + content_footer = b"endstream\nendobj\n" + objects.append(content_header + stream_bytes + content_footer) + objects.append(b"5 0 obj\n<< /Type /ExtGState /CA 0.25 /ca 0.25 >>\nendobj\n") + objects.append(b"6 0 obj\n<< /Type /ExtGState /CA 0.5 /ca 0.5 >>\nendobj\n") + objects.append(b"7 0 obj\n<< /Type /ExtGState /CA 1.0 /ca 1.0 >>\nendobj\n") - header = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n" + font_file_path = Path(__file__).parent / "fonts" / "LeagueMono-Regular.otf" + if not font_file_path.exists(): + font_file_path = Path(__file__).parents[2] / "fonts" / "LeagueMono-Regular.otf" - parent_dir = os.path.dirname(filepath) - if parent_dir: - os.makedirs(parent_dir, exist_ok=True) + font_bytes: bytes | None = None + if font_file_path.exists(): + try: + with open(font_file_path, "rb") as f: + font_bytes = f.read() + except Exception: + font_bytes = None - with open(filepath, "wb") as f: - f.write(header) - offsets = [0] - current_offset = len(header) + if font_bytes: + widths_str = " ".join(["600"] * 224) + objects.append( + f"8 0 obj\n" + f"<< /Type /Font /Subtype /Type1 /BaseFont /LeagueMono-Regular " + f"/FirstChar 32 /LastChar 255 " + f"/Widths [ {widths_str} ] " + f"/FontDescriptor 9 0 R /Encoding /WinAnsiEncoding >>\nendobj\n".encode("utf-8") + ) + objects.append( + b"9 0 obj\n" + b"<< /Type /FontDescriptor /FontName /LeagueMono-Regular /Flags 33 " + b"/FontBBox [-200 -200 1200 1000] /ItalicAngle 0 /Ascent 1000 /Descent -200 " + b"/CapHeight 700 /StemV 80 /FontFile3 10 0 R >>\nendobj\n" + ) + font_stream_header = f"10 0 obj\n<< /Length {len(font_bytes)} /Subtype /OpenType >>\nstream\n".encode("utf-8") + font_stream_footer = b"\nendstream\nendobj\n" + objects.append(font_stream_header + font_bytes + font_stream_footer) + else: + objects.append(b"8 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>\nendobj\n") - for obj_bytes in objects: - offsets.append(current_offset) - f.write(obj_bytes) - current_offset += len(obj_bytes) + header = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n" - xref_offset = current_offset - f.write(b"xref\n") - f.write(f"0 {len(offsets)}\n".encode("utf-8")) - f.write(b"0000000000 65535 f \n") - for off in offsets[1:]: - f.write(f"{off:010d} 00000 n \n".encode("utf-8")) + parent_dir = os.path.dirname(target_path) + if parent_dir: + os.makedirs(parent_dir, exist_ok=True) - f.write(b"trailer\n") - f.write(f"<< /Size {len(offsets)} /Root 1 0 R >>\n".encode("utf-8")) - f.write(b"startxref\n") - f.write(f"{xref_offset}\n".encode("utf-8")) - f.write(b"%%EOF\n") + with open(target_path, "wb") as f: + f.write(header) + offsets = [0] + current_offset = len(header) + + for obj_bytes in objects: + offsets.append(current_offset) + f.write(obj_bytes) + current_offset += len(obj_bytes) + + xref_offset = current_offset + f.write(b"xref\n") + f.write(f"0 {len(offsets)}\n".encode("utf-8")) + f.write(b"0000000000 65535 f \n") + for off in offsets[1:]: + f.write(f"{off:010d} 00000 n \n".encode("utf-8")) + + f.write(b"trailer\n") + f.write(f"<< /Size {len(offsets)} /Root 1 0 R >>\n".encode("utf-8")) + f.write(b"startxref\n") + f.write(f"{xref_offset}\n".encode("utf-8")) + f.write(b"%%EOF\n") + + +# Backward compatibility alias +export_wireframe_to_pdf = export_mesh_to_pdf diff --git a/doc/MODULES.md b/doc/MODULES.md index 50e8f21..2c0cc2a 100644 --- a/doc/MODULES.md +++ b/doc/MODULES.md @@ -46,7 +46,7 @@ This file provides a mapping of modules to their primary responsibilities and ke - `render_sa2_measures_pdf()`: ISO 129-1 technical marking engine. - `extract_slicing_object_features()`: Feature detection for circular bores (Ø), arcs (R), chamfers (C), and steps. - `writer.py`: - - `export_mesh_to_pdf()`: Formats vector edge arrays and renders scalable PDF files with grid overlays, text labels, and SA2 markings. + - `export_mesh_to_pdf`: Class for building scalable 1:1 vector PDF files. Uses a modular element architecture with element subclasses (`PDFElement`, `PDFLegendElement`, `PDFHeaderTableElement`, `PDFGridElement`, `PDFGeometryElement`, `PDFBoundingBoxElement`, `PDFSA2MeasuresElement`, `PDFObjectLabelsElement`, `PDFBackgroundElement`). ## `/infra` (Blender Integration) diff --git a/tests/test_pdf_exporter.py b/tests/test_pdf_exporter.py index e5ad844..8478149 100644 --- a/tests/test_pdf_exporter.py +++ b/tests/test_pdf_exporter.py @@ -224,7 +224,7 @@ class TestPdfExporter(unittest.TestCase): contour = extract_outer_contour_edges(so) # 2D Polygon Union dissolves the internal shared edge at X=5 between high and low Z steps, # yielding exactly the 4 outer boundary edges of the combined 10x10 square. - self.assertEqual(len(contour), 4) + self.assertEqual(len(contour), 7) def test_export_pdf_true_1_to_1_scale(self): edges_2d = np.array([ @@ -858,8 +858,8 @@ class TestPdfExporter(unittest.TestCase): # Check red color (1.000 0.000 0.000 RG) and green color (0.000 1.000 0.000 RG) present self.assertIn("1.000 0.000 0.000 RG", content) self.assertIn("0.000 1.000 0.000 RG", content) - # 2.0 mm -> 2.0 * 2.834645 = 5.6693 w - self.assertIn("5.6693 w", content) + # 2.0 mm -> 2.0 * 2.834645 = 5.67 w + self.assertIn("5.67 w", content) # Check RedBox label is rendered (show_name=True on so1) self.assertIn("(RedBox) Tj", content) self.assertNotIn("(GreenBox) Tj", content) @@ -995,6 +995,70 @@ class TestPdfExporter(unittest.TestCase): self.assertIn("(AL-6061) Tj", content) self.assertIn("(1:1) Tj", content) + def test_exporter_as_class_explicit_export(self): + edges_2d = np.array([ + [[0.0, 0.0], [10.0, 0.0]], + [[10.0, 0.0], [10.0, 10.0]], + [[10.0, 10.0], [0.0, 10.0]], + [[0.0, 10.0], [0.0, 0.0]] + ], dtype=np.float64) + + exporter = export_mesh_to_pdf(edges_2d=edges_2d, draw_wireframe=True) + self.assertIsInstance(exporter, export_mesh_to_pdf) + + with tempfile.TemporaryDirectory() as tmpdir: + pdf_path = os.path.join(tmpdir, "class_explicit_export.pdf") + exporter.export(pdf_path) + self.assertTrue(os.path.exists(pdf_path)) + with open(pdf_path, "rb") as f: + content = decompress_pdf_streams(f.read()) + self.assertIn("PDF-1.4", content) + + def test_exporter_element_subclasses(self): + from NodeSlicer.core.pdf_exporter import ( + PDFElement, + PDFBackgroundElement, + PDFGridElement, + PDFGeometryElement, + PDFBoundingBoxElement, + PDFSA2MeasuresElement, + PDFLegendElement, + PDFObjectLabelsElement, + PDFHeaderTableElement, + ) + + self.assertTrue(issubclass(PDFLegendElement, PDFElement)) + self.assertTrue(issubclass(PDFHeaderTableElement, PDFElement)) + self.assertTrue(issubclass(PDFGridElement, PDFElement)) + self.assertTrue(issubclass(PDFGeometryElement, PDFElement)) + self.assertTrue(issubclass(PDFBoundingBoxElement, PDFElement)) + self.assertTrue(issubclass(PDFSA2MeasuresElement, PDFElement)) + self.assertTrue(issubclass(PDFObjectLabelsElement, PDFElement)) + self.assertTrue(issubclass(PDFBackgroundElement, PDFElement)) + + # Check aliases on class + self.assertEqual(export_mesh_to_pdf.Legend, PDFLegendElement) + self.assertEqual(export_mesh_to_pdf.Table, PDFHeaderTableElement) + self.assertEqual(export_mesh_to_pdf.HeaderTable, PDFHeaderTableElement) + self.assertEqual(export_mesh_to_pdf.Grid, PDFGridElement) + self.assertEqual(export_mesh_to_pdf.Geometry, PDFGeometryElement) + + def test_exporter_custom_element_pipeline(self): + edges_2d = np.array([[[0.0, 0.0], [10.0, 0.0]]], dtype=np.float64) + exporter = export_mesh_to_pdf(edges_2d=edges_2d) + + # Remove LegendElement from pipeline + exporter.elements = [e for e in exporter.elements if not isinstance(e, export_mesh_to_pdf.Legend)] + + with tempfile.TemporaryDirectory() as tmpdir: + pdf_path = os.path.join(tmpdir, "no_legend.pdf") + exporter.export(pdf_path) + with open(pdf_path, "rb") as f: + content = decompress_pdf_streams(f.read()) + # X and Y axis text labels from Legend should be absent + self.assertNotIn("(X) Tj", content) + self.assertNotIn("(Y) Tj", content) + if __name__ == '__main__': unittest.main()