""" 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, generate_legend_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, "pdf_object_properties", getattr(so, "object_properties", None)) is not None and len(getattr(so, "pdf_object_properties", 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, "pdf_object_properties", 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) using 3D SlicePaths geometry oriented with object rotation. """ def render(self, s: PdfStream) -> None: exp = self.exporter # Extract 3D rotation of the first object rot_vec = get_first_object_rotation( exp.slicing_objs, exp.raw_geometry, exp.slice_paths ) r_arr = np.array(rot_vec, dtype=np.float64) # Generate legend 3D SlicePaths & label metadata legend_sp, label_meta = generate_legend_slice_paths( exp.local_min_x_3d, exp.local_min_y_3d, exp.local_max_x_3d, exp.main_step, exp.minor_step, exp.line_width_pt, exp.font_size_pt, draw_x=getattr(exp, "draw_x_axis", True), draw_y=getattr(exp, "draw_y_axis", True), draw_z=getattr(exp, "draw_z_axis", False), local_origin_3d=getattr(exp, "legend_origin_3d", None), local_max_3d=getattr(exp, "legend_max_3d", None), ) # Rotate legend SlicePaths with existing transform_slice_paths rot_legend_sp = transform_slice_paths(legend_sp, np.zeros(3), r_arr, np.ones(3)) # Extract 2D edge segments using existing extract_slice_paths_edges legend_edges_2d = extract_slice_paths_edges(rot_legend_sp) # Render vector lines if len(legend_edges_2d) > 0: s.opacity("GS100").line_width(exp.line_width_pt).stroke_color(0, 0, 0).round_caps() p0_pt = exp.to_pdf_pt(legend_edges_2d[:, 0, :]) p1_pt = exp.to_pdf_pt(legend_edges_2d[:, 1, :]) lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2) s.draw_line_batch(lines_arr) # Calculate rotation transpose matrix for text label placement 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) s.fill_color(0, 0, 0) s.begin_text().font("F1", exp.font_size_pt) for meta in label_meta: local_pt = np.array(meta["local_pt"], dtype=np.float64) world_pt = local_pt @ R.T pt_pdf = exp.to_pdf_pt(world_pt.reshape(1, 3)[:, :2])[0] txt = meta["text"] if meta["type"] == "tick": tw_pt = len(txt) * exp.font_size_pt * 0.60 s.text_position(pt_pdf[0] - (tw_pt / 2.0), pt_pdf[1]).show(txt) elif meta["type"] == "x_axis": s.text_position(pt_pdf[0], pt_pdf[1] - (exp.font_size_pt * 0.35)).show(txt) elif meta["type"] == "y_axis": s.text_position(pt_pdf[0] - (exp.font_size_pt * 0.35), pt_pdf[1]).show(txt) 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, projection_plane: str | None = None, draw_grid_xy: bool = True, draw_grid_xz: bool = False, draw_grid_yz: bool = False, **kwargs: Any, ) -> None: if projection_plane is not None: p_up = projection_plane.upper() draw_grid_xy = (p_up == "XY") draw_grid_xz = (p_up == "XZ") draw_grid_yz = (p_up == "YZ") self.draw_grid_xy = draw_grid_xy self.draw_grid_xz = draw_grid_xz self.draw_grid_yz = draw_grid_yz self.projection_plane = "XY" if draw_grid_xy else ("XZ" if draw_grid_xz else "YZ") 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) pts_3d_list: list[NDArray[np.float64]] = [] for obj in self.slicing_objs: mesh_arr = getattr(obj, "mesh", None) if isinstance(mesh_arr, np.ndarray) and mesh_arr.size > 0: if mesh_arr.ndim == 3 and mesh_arr.shape[2] >= 3: pts_3d_list.append(mesh_arr[:, :3, :3].reshape(-1, 3)) elif mesh_arr.ndim == 2 and mesh_arr.shape[1] >= 3: pts_3d_list.append(mesh_arr[:, :3]) elif isinstance(obj, list) or (hasattr(obj, "__iter__") and not isinstance(obj, (str, bytes))): for path in obj: if isinstance(path, np.ndarray) and path.ndim == 2: if path.shape[1] >= 3: pts_3d_list.append(path[:, :3]) elif path.shape[1] == 2: pts_3d_list.append(np.column_stack([path, np.zeros(len(path), dtype=np.float64)])) if pts_3d_list: pts_3d_world = np.vstack(pts_3d_list) elif len(pts_2d) > 0: pts_3d_world = np.column_stack([pts_2d, np.zeros(len(pts_2d), dtype=np.float64)]) else: pts_3d_world = np.empty((0, 3), dtype=np.float64) if len(pts_3d_world) > 0: 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()) l_min_z, l_max_z = float(pts_local[:, 2].min()), float(pts_local[:, 2].max()) else: l_min_x, l_max_x, l_min_y, l_max_y, l_min_z, l_max_z = -50.0, 50.0, -50.0, 50.0, -50.0, 50.0 w_geom, h_geom = max(l_max_x - l_min_x, 1.0), max(l_max_y - l_min_y, 1.0) if self.draw_grid_xz and not self.draw_grid_xy: w_geom, h_geom = max(l_max_x - l_min_x, 1.0), max(l_max_z - l_min_z, 1.0) elif self.draw_grid_yz and not self.draw_grid_xy and not self.draw_grid_xz: w_geom, h_geom = max(l_max_y - l_min_y, 1.0), max(l_max_z - l_min_z, 1.0) self.main_step, self.minor_step = compute_grid_steps(w_geom, h_geom) self.grid_min_x = math.floor((l_min_x - self.minor_step) / self.minor_step) * self.minor_step self.grid_max_x = math.ceil((l_max_x + self.minor_step) / self.minor_step) * self.minor_step self.grid_min_y = math.floor((l_min_y - self.minor_step) / self.minor_step) * self.minor_step self.grid_max_y = math.ceil((l_max_y + self.minor_step) / self.minor_step) * self.minor_step # Object sits on XY grid: exact min Z (no margin below), margin over object on max Z self.grid_min_z = l_min_z self.grid_max_z = math.ceil((l_max_z + self.minor_step) / self.minor_step) * self.minor_step self.local_min_x_3d = self.grid_min_x self.local_max_x_3d = self.grid_max_x self.local_min_y_3d = self.grid_min_y self.local_max_y_3d = self.grid_max_y self.local_min_z_3d = self.grid_min_z self.local_max_z_3d = self.grid_max_z # Shared origin point (min X, max Y, min Z) x0 = self.grid_min_x y0 = self.grid_max_y z0 = self.grid_min_z self.legend_origin_3d = (x0, y0, z0) self.legend_max_3d = (self.grid_max_x, self.grid_min_y, self.grid_max_z) self.draw_x_axis = self.draw_grid_xy or self.draw_grid_xz self.draw_y_axis = self.draw_grid_xy or self.draw_grid_yz self.draw_z_axis = self.draw_grid_xz or self.draw_grid_yz self.local_min_x = self.grid_min_x self.local_max_x = self.grid_max_x self.local_min_y = self.grid_min_y self.local_max_y = self.grid_max_y self.local_min_z = z0 grid_minor_paths: list[NDArray[np.float64]] = [] grid_main_paths: list[NDArray[np.float64]] = [] if self.draw_grid_xy: min_sp, main_sp = generate_grid_slice_paths( self.grid_min_x, self.grid_max_x, self.grid_min_y, self.grid_max_y, self.minor_step, u0=x0, v0=y0, z_level=z0, plane="XY" ) grid_minor_paths.extend(min_sp) grid_main_paths.extend(main_sp) if self.draw_grid_xz: min_sp, main_sp = generate_grid_slice_paths( self.grid_min_x, self.grid_max_x, self.grid_min_z, self.grid_max_z, self.minor_step, u0=x0, v0=z0, z_level=y0, plane="XZ" ) grid_minor_paths.extend(min_sp) grid_main_paths.extend(main_sp) if self.draw_grid_yz: min_sp, main_sp = generate_grid_slice_paths( self.grid_min_y, self.grid_max_y, self.grid_min_z, self.grid_max_z, self.minor_step, u0=y0, v0=z0, z_level=x0, plane="YZ" ) grid_minor_paths.extend(min_sp) grid_main_paths.extend(main_sp) from ..slice import SlicePaths minor_grid_sp = SlicePaths(grid_minor_paths) main_grid_sp = SlicePaths(grid_main_paths) # 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)) 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, "pdf_object_properties", getattr(so, "object_properties", None)) is not None and len(getattr(so, "pdf_object_properties", 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, "pdf_object_properties", 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")