""" 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, ) from .geometry import ( extract_unique_top_view_edges, extract_boundary_edges, extract_outer_contour_edges, extract_crease_edges, deduplicate_2d_edges, ) 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) y0_pt = total_margin_mm * MM_TO_PT x0_pt = x1_pt - (w_table_mm * MM_TO_PT) y1_pt = y0_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 "-") part_name_val = str(header_info.get("part_name", "") 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 get_all_slicing_objects(mesh_data: Any) -> list[Any]: from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject objects = [] 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): objects.append(item) 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). """ 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) 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 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") 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)) 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: 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)) 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 primary_obj_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=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 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 # Convenient aliases for element subclasses Legend = PDFLegendElement Table = PDFHeaderTableElement HeaderTable = PDFHeaderTableElement Grid = PDFGridElement Geometry = PDFGeometryElement BoundingBox = PDFBoundingBoxElement SA2Measures = PDFSA2MeasuresElement ObjectLabels = PDFObjectLabelsElement Background = PDFBackgroundElement 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 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 ) 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" ) 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")