""" PDF Export Stream Assembly & Writer Module. Formats vector edge arrays and renders scalable PDF files with grid overlays, text labels, and SA2 technical markings. """ import datetime import math import os from pathlib import Path from typing import Any import numpy as np from numpy.typing import NDArray from .metadata import ( extract_object_label_info, extract_object_bounding_boxes, extract_settings_items, get_first_attribute_value, resolve_object_properties, ) from .geometry import ( extract_unique_top_view_edges, extract_boundary_edges, extract_outer_contour_edges, 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 ( compute_grid_steps, compute_effective_stroke_and_font, LegendSizing, MM_TO_PT, ) from .pdf_utils import PdfStream def render_technical_header_table( s: PdfStream, page_w_pt: float, page_h_pt: float, total_margin_mm: float, header_info: dict[str, Any], stroke_color: tuple[float, float, float], bg_color: tuple[float, float, float], font_size_pt: float, line_width_pt: float, obj_name: str = "", ) -> None: """ 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 x1_pt = page_w_pt - (total_margin_mm * MM_TO_PT) y1_pt = page_h_pt - (total_margin_mm * MM_TO_PT) x0_pt = x1_pt - (w_table_mm * MM_TO_PT) y0_pt = y1_pt - (h_table_mm * MM_TO_PT) # Fill background box br, bg, bb = bg_color sr, sg, sb = stroke_color s.opacity("GS100") s.fill_color(br, bg, bb) s.fill_rect(x0_pt, y0_pt, w_table_mm * MM_TO_PT, h_table_mm * MM_TO_PT) # Table borders and grid table_lw_pt = line_width_pt * 1.2 grid_lw_pt = line_width_pt * 0.8 s.stroke_color(sr, sg, sb).line_width(table_lw_pt).round_caps() s.rect(x0_pt, y0_pt, w_table_mm * MM_TO_PT, h_table_mm * MM_TO_PT).stroke() h_row_pt = cell_height_mm * MM_TO_PT y_row1_pt = y0_pt + h_row_pt y_row2_pt = y0_pt + 2.0 * h_row_pt s.line_width(grid_lw_pt) # Horizontal lines s.line_stroke(x0_pt, y_row1_pt, x1_pt, y_row1_pt) s.line_stroke(x0_pt, y_row2_pt, x1_pt, y_row2_pt) # Vertical dividers x_div1_pt = x0_pt + (0.5 * w_table_mm * MM_TO_PT) x_div2_pt = x0_pt + (0.8 * w_table_mm * MM_TO_PT) s.line_stroke(x_div1_pt, y_row1_pt, x_div1_pt, y1_pt) s.line_stroke(x_div2_pt, y_row1_pt, x_div2_pt, y1_pt) x_r2_div1_pt = x0_pt + (0.4 * w_table_mm * MM_TO_PT) x_r2_div2_pt = x0_pt + (0.8 * w_table_mm * MM_TO_PT) s.line_stroke(x_r2_div1_pt, y0_pt, x_r2_div1_pt, y_row1_pt) s.line_stroke(x_r2_div2_pt, y0_pt, x_r2_div2_pt, y_row1_pt) # Extract field values with fallbacks company_val = str(header_info.get("company", "") or "-") raw_part_name = header_info.get("part_name", "") part_name_val = str((raw_part_name or "").strip() or obj_name or "-") dwg_val = str(header_info.get("drawing_number", "") or "-") author_val = str(header_info.get("author", "") or "-") rev_val = str(header_info.get("revision", "") or "A") mat_val = str(header_info.get("material", "") or "-") tol_val = str(header_info.get("tolerance", "") or "-") date_val = str(header_info.get("date", "") or datetime.date.today().isoformat()) scale_val = str(header_info.get("scale", "") or "1:1") cells = [ # Row 0 (Top) ("COMPANY", company_val, x0_pt, y_row2_pt, h_row_pt), ("DWG NO", dwg_val, x_div1_pt, y_row2_pt, h_row_pt), ("REV", rev_val, x_div2_pt, y_row2_pt, h_row_pt), # Row 1 (Middle) ("PART NAME", part_name_val, x0_pt, y_row1_pt, h_row_pt), ("MATERIAL", mat_val, x_div1_pt, y_row1_pt, h_row_pt), ("TOLERANCE", tol_val, x_div2_pt, y_row1_pt, h_row_pt), # Row 2 (Bottom) ("AUTHOR", author_val, x0_pt, y0_pt, h_row_pt), ("DATE", date_val, x_r2_div1_pt, y0_pt, h_row_pt), ("SCALE", scale_val, x_r2_div2_pt, y0_pt, h_row_pt), ] eff_font = font_size_pt if font_size_pt is not None and font_size_pt > 0 else 10.0 lbl_font_pt = eff_font * 0.5 val_font_pt = eff_font * 0.75 label_color = (0.35, 0.35, 0.35) for lbl_text, val_text, cx, cy, ch in cells: # Small field label lx = cx + (1.5 * MM_TO_PT) ly = cy + ch - (lbl_font_pt * 1.1) s.begin_text().font("F1", lbl_font_pt) s.fill_color(*label_color) s.text_position(lx, ly) s.show(lbl_text) s.end_text() # Field value (bold) vx = cx + (1.5 * MM_TO_PT) vy = cy + (1.5 * MM_TO_PT) s.begin_text().font("F1", val_font_pt) s.fill_color(sr, sg, sb) bold_w = max(0.15, val_font_pt * 0.035) s.stroke_color(sr, sg, sb).line_width(bold_w).text_rendering_mode(2) s.text_position(vx, vy) s.show(val_text) s.text_rendering_mode(0) s.end_text() def render_object_properties_table( s: PdfStream, page_w_pt: float, page_h_pt: float, total_margin_mm: float, slicing_objs: list[Any], draw_header: bool, header_info: dict[str, Any] | None, stroke_color: tuple[float, float, float], bg_color: tuple[float, float, float], font_size_pt: float, line_width_pt: float, ) -> None: """ Renders per-object properties boxes under the technical header table on the right side of the drawing. """ objs_with_props = [ so for so in slicing_objs if getattr(so, "object_properties", None) is not None and len(getattr(so, "object_properties", [])) > 0 ] if not objs_with_props: return w_table_mm = (font_size_pt / MM_TO_PT) * 25.0 w_box_pt = w_table_mm * MM_TO_PT cell_height_mm = (font_size_pt / MM_TO_PT) * 1.4 cell_h_pt = cell_height_mm * MM_TO_PT gap_pt = cell_h_pt * 0.5 x1_pt = page_w_pt - (total_margin_mm * MM_TO_PT) x0_pt = x1_pt - w_box_pt top_y_pt = page_h_pt - (total_margin_mm * MM_TO_PT) if draw_header and header_info is not None: h_header_table_mm = cell_height_mm * 3.0 header_table_h_pt = h_header_table_mm * MM_TO_PT curr_y_pt = top_y_pt - header_table_h_pt - gap_pt else: curr_y_pt = top_y_pt table_lw_pt = line_width_pt * 1.2 grid_lw_pt = line_width_pt * 0.8 br, bg, bb = bg_color sr, sg, sb = stroke_color eff_font = font_size_pt if font_size_pt is not None and font_size_pt > 0 else 10.0 val_font_pt = eff_font * 0.75 lbl_font_pt = eff_font * 0.65 label_color = (0.35, 0.35, 0.35) for so in objs_with_props: raw_props = getattr(so, "object_properties", []) props = resolve_object_properties(so, raw_props) if not props: continue obj_name = str(getattr(so, "name", "Object")) num_rows = 1 + len(props) h_box_pt = num_rows * cell_h_pt y1_box_pt = curr_y_pt y0_box_pt = y1_box_pt - h_box_pt # Fill background s.opacity("GS100") s.fill_color(br, bg, bb) s.fill_rect(x0_pt, y0_box_pt, w_box_pt, h_box_pt) # Outer border s.stroke_color(sr, sg, sb).line_width(table_lw_pt).round_caps() s.rect(x0_pt, y0_box_pt, w_box_pt, h_box_pt).stroke() # Divider between Subbox 1 (Object Name) and Subbox 2 (Properties) y_name_div_pt = y1_box_pt - cell_h_pt s.line_width(grid_lw_pt) s.line_stroke(x0_pt, y_name_div_pt, x1_pt, y_name_div_pt) # Subbox 1: Object Name (in bold) name_vx = x0_pt + (1.5 * MM_TO_PT) name_vy = y_name_div_pt + (1.5 * MM_TO_PT) s.begin_text().font("F1", val_font_pt) s.fill_color(sr, sg, sb) bold_w = max(0.15, val_font_pt * 0.035) s.stroke_color(sr, sg, sb).line_width(bold_w).text_rendering_mode(2) s.text_position(name_vx, name_vy) s.show(obj_name) s.text_rendering_mode(0) s.end_text() # Subbox 2: Properties y_prop_curr_pt = y_name_div_pt x_div_40_pt = x0_pt + (0.40 * w_box_pt) for prop_idx, (prop_id, prop_val) in enumerate(props): row_top_pt = y_prop_curr_pt - prop_idx * cell_h_pt row_bot_pt = row_top_pt - cell_h_pt if prop_idx > 0: s.line_width(grid_lw_pt) s.line_stroke(x0_pt, row_top_pt, x1_pt, row_top_pt) str_val = str(prop_val) if not prop_id: # Single line text (just the value in a cell) vx = x0_pt + (1.5 * MM_TO_PT) vy = row_bot_pt + (1.5 * MM_TO_PT) s.begin_text().font("F1", val_font_pt) s.fill_color(sr, sg, sb) s.text_position(vx, vy) s.show(str_val) s.end_text() else: # Pair ID-value (two cells with divider at 40% of width) s.line_width(grid_lw_pt) s.line_stroke(x_div_40_pt, row_bot_pt, x_div_40_pt, row_top_pt) # Left cell: ID lx = x0_pt + (1.5 * MM_TO_PT) ly = row_bot_pt + (1.5 * MM_TO_PT) s.begin_text().font("F1", lbl_font_pt) s.fill_color(*label_color) s.text_position(lx, ly) s.show(str(prop_id)) s.end_text() # Right cell: Value vx = x_div_40_pt + (1.5 * MM_TO_PT) vy = row_bot_pt + (1.5 * MM_TO_PT) s.begin_text().font("F1", val_font_pt) s.fill_color(sr, sg, sb) s.text_position(vx, vy) s.show(str_val) s.end_text() curr_y_pt = y0_box_pt - gap_pt def get_all_slicing_objects(mesh_data: Any) -> list[Any]: from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject from ..slice import SlicePaths objects = [] def process_item(item: Any): if item is None: return elif isinstance(item, (SlicingObject, SlicePaths)): objects.append(item) 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) process_item(mesh_data) return objects class PDFElement: """ Base class for individual PDF layout and geometry rendering elements. """ def __init__(self, exporter: "export_mesh_to_pdf") -> None: self.exporter = exporter def render(self, s: PdfStream) -> None: """ Renders the element content into the given PdfStream. """ raise NotImplementedError 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) 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) if len(exp.minor_lines_arr) > 0: s.opacity("GS25").line_width(exp.line_width_pt) s.draw_line_batch(exp.minor_lines_arr) if len(exp.main_lines_arr) > 0: s.opacity("GS50").line_width(exp.line_width_pt) s.draw_line_batch(exp.main_lines_arr) class PDFGeometryElement(PDFElement): """ Renders 2D vector geometry (wireframe, boundary, contour, crease lines, and slice paths). """ def render(self, s: PdfStream) -> None: exp = self.exporter slicing_objs = exp.slicing_objs from ..slice import SlicePaths if slicing_objs: for so in slicing_objs: so_edge_list: list[NDArray[np.float64]] = [] if isinstance(so, SlicePaths): sp_edges = extract_slice_paths_edges(so) if len(sp_edges) > 0: so_edge_list.append(sp_edges) else: 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) 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 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 ) so_line_width_pt, _ = compute_effective_stroke_and_font( so_lw_mm, exp.font_size_mm, exp.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 = 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) 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) 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) 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, ) 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) 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)) 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]])) x_start_pt = p_start_pt[0, 0] x_end_pt = p_end_pt[0, 0] y_bar_pt = p_start_pt[0, 1] 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] arrow_len_pt = leg_sizing.arrow_len_pt arrow_half_w_pt = leg_sizing.arrow_half_w_pt s.line_width(exp.line_width_pt).round_caps() # 6a. Main horizontal X-axis line x_arrow_base_mm = x_grid_end_mm + leg_sizing.x_axis_extension_mm p_arrow_base_pt = exp.to_pdf_pt(np.array([[x_arrow_base_mm, exp.grid_min_y]])) x_arrow_base_pt = p_arrow_base_pt[0, 0] x_arrow_tip_pt = x_arrow_base_pt + arrow_len_pt s.line_stroke(x_start_pt, y_bar_pt, x_arrow_tip_pt, y_bar_pt) s.polyline_stroke( (x_arrow_base_pt, y_bar_pt + arrow_half_w_pt), (x_arrow_tip_pt, y_bar_pt), (x_arrow_base_pt, y_bar_pt - arrow_half_w_pt), ) for k in range(num_main_steps + 1): 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() # 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) 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), ) # 6c. Text labels for ticks and axes s.fill_color(0, 0, 0) s.begin_text().font("F1", exp.font_size_pt) 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 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}" tw_pt = len(tick_str) * exp.font_size_pt * 0.60 x_text_pt = xk_pt - (tw_pt / 2.0) y_text_pt = y_bar_pt + tick_h_pt + leg_sizing.label_margin_h_pt s.text_position(x_text_pt, y_text_pt).show(tick_str) x_x_label_pt = x_arrow_tip_pt + leg_sizing.label_margin_h_pt y_x_label_pt = y_bar_pt - (exp.font_size_pt * 0.35) s.text_position(x_x_label_pt, y_x_label_pt).show("X") x_y_label_pt = x_start_pt - (exp.font_size_pt * 0.35) y_y_label_pt = y_arrow_tip_pt + leg_sizing.label_margin_h_pt s.text_position(x_y_label_pt, y_y_label_pt).show("Y") s.end_text() 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) if (exp.show_name or has_any_obj_show_name or settings_items) and (exp.raw_geometry is not None or exp.slice_paths is not None): geom_source = [g for g in (exp.raw_geometry, exp.slice_paths) if g is not None] label_infos = extract_object_label_info(geom_source) 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") 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) 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 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)) 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: contour_w = max(0.5, eff_font_size_pt * 0.12) 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) # 1. White contour outline pass (behind text for contrast against geometry) s.begin_text().font("F1", eff_font_size_pt) s.fill_color(1.0, 1.0, 1.0).stroke_color(1.0, 1.0, 1.0) s.line_width(contour_w).text_rendering_mode(2) s.text_position(x_text_pt, curr_y_pt) s.show(line_str) s.text_rendering_mode(0) s.end_text() # 2. Foreground text pass 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 top-right corner. """ def render(self, s: PdfStream) -> None: exp = self.exporter if not exp.draw_header or not exp.header_info: return from .metadata import get_first_object_name primary_obj_name = "" if exp.slicing_objs: for so in exp.slicing_objs: if hasattr(so, "name") and getattr(so, "name"): primary_obj_name = str(getattr(so, "name")) break if not primary_obj_name: primary_obj_name = get_first_object_name(exp.raw_geometry) or get_first_object_name(exp.slice_paths) render_technical_header_table( s=s, 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, ) class PDFObjectPropertiesTableElement(PDFElement): """ Renders object properties table under the technical header table. """ def render(self, s: PdfStream) -> None: exp = self.exporter if not exp.slicing_objs: return render_object_properties_table( s=s, page_w_pt=exp.page_w_pt, page_h_pt=exp.page_h_pt, total_margin_mm=exp.total_margin_mm, slicing_objs=exp.slicing_objs, draw_header=exp.draw_header, 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, ) 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 ObjectPropertiesTableElement = PDFObjectPropertiesTableElement # Convenient aliases for element subclasses Legend = PDFLegendElement Table = PDFHeaderTableElement HeaderTable = PDFHeaderTableElement Grid = PDFGridElement Geometry = PDFGeometryElement BoundingBox = PDFBoundingBoxElement SA2Measures = PDFSA2MeasuresElement ObjectLabels = PDFObjectLabelsElement Background = PDFBackgroundElement ObjectPropertiesTable = PDFObjectPropertiesTableElement def __init__( self, edges_2d: NDArray[np.float64] | None = None, 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, 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, slice_paths: Any = None, settings: Any = None, **kwargs: Any, ) -> None: self.edges_2d = edges_2d if edges_2d is not None else np.empty((0, 2, 2), dtype=np.float64) 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 if self.draw_header and header_info is None: header_info = {} self.header_info = header_info self.show_name = show_name 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.slice_paths = slice_paths self.settings = settings 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), self.ObjectPropertiesTableElement(self), ] if filepath is not None: self.export(filepath) def _prepare_geometry_and_layout(self) -> None: objs_raw = get_all_slicing_objects(self.raw_geometry) objs_paths = get_all_slicing_objects(self.slice_paths) self.slicing_objs = list(objs_raw) for po in objs_paths: if not any(po is existing for existing in self.slicing_objs): self.slicing_objs.append(po) 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 self.slice_paths is not None: sp_edges = extract_slice_paths_edges(self.slice_paths) if len(sp_edges) > 0: edge_list.append(sp_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) else: pts_2d = np.empty((0, 2), dtype=np.float64) # 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) 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 # 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, ) # 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: 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 self.total_margin_mm = self.margin_mm + 2.0 * self.minor_step self.line_width_pt, self.font_size_pt = compute_effective_stroke_and_font( self.line_width_mm, self.font_size_mm, self.minor_step ) objs_with_props = [ so for so in self.slicing_objs if getattr(so, "object_properties", None) is not None and len(getattr(so, "object_properties", [])) > 0 ] w_table_mm = 0.0 h_table_col_mm = 0.0 cell_height_mm = (self.font_size_pt / MM_TO_PT) * 1.4 gap_mm = cell_height_mm * 0.5 if self.draw_header and self.header_info is not None: w_table_mm = (self.font_size_pt / MM_TO_PT) * 25.0 h_header_table_mm = cell_height_mm * 3.0 h_table_col_mm = h_header_table_mm if objs_with_props: w_table_mm = (self.font_size_pt / MM_TO_PT) * 25.0 h_props_total_mm = sum( (1 + len(getattr(so, "object_properties", []))) * cell_height_mm for so in objs_with_props ) + len(objs_with_props) * gap_mm if h_table_col_mm > 0.0: h_table_col_mm += gap_mm + h_props_total_mm else: h_table_col_mm = h_props_total_mm w_total_mm = w_grid_mm + w_table_mm + 2.0 * self.total_margin_mm h_total_mm = max(h_grid_mm, h_table_col_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 # 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: self.minor_lines_arr = np.empty((0, 2, 2), dtype=np.float64) 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: 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 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" ) 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") 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" 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 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") header = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n" parent_dir = os.path.dirname(target_path) if parent_dir: os.makedirs(parent_dir, exist_ok=True) 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")