""" 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 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_boundary_edges, extract_outer_contour_edges, extract_crease_edges, deduplicate_2d_edges, ) from .sa2_measures import render_sa2_measures_pdf MM_TO_PT = 2.834645669291339 def _format_pdf_lines(lines_arr: NDArray[np.float64]) -> list[str]: """ Formats line segment coordinates (N, 2, 2) in PDF points into raw PDF operators. """ p0 = lines_arr[:, 0, :] p1 = lines_arr[:, 1, :] lines_str = [ f"{x1:.2f} {y1:.2f} m {x2:.2f} {y2:.2f} l" for (x1, y1), (x2, y2) in zip(p0, p1) ] return lines_str def export_mesh_to_pdf( edges_2d: NDArray[np.float64], filepath: str | Path, draw_wireframe: bool = True, draw_boundary: bool = False, draw_contour: bool = False, draw_crease: bool = False, draw_bbox: bool = False, draw_sa2_measures: bool = False, show_name: bool = False, show_bbox_size: bool = False, crease_angle: float = 30.0, margin_mm: float = 10.0, line_width_mm: float = 0.5, font_size_pt: float | None = None, 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, style: str | None = None, **kwargs: Any ) -> None: """ Exports 2D vector geometry to a 1:1 true physical scale vector PDF file. Supports toggleable line layers: Wireframe, Boundary (1 face), Contour, Crease Angle. """ # Handle legacy style argument if provided if style == "LINEART": draw_wireframe = False draw_boundary = True draw_crease = True elif style == "OUTER_CONTOUR": draw_wireframe = False draw_contour = True edge_list: list[NDArray[np.float64]] = [] if draw_wireframe and len(edges_2d) > 0: edge_list.append(edges_2d) if draw_boundary and raw_geometry is not None: b_edges = extract_boundary_edges(raw_geometry) if len(b_edges) > 0: edge_list.append(b_edges) if draw_contour and raw_geometry is not None: c_edges = extract_outer_contour_edges(raw_geometry) if len(c_edges) > 0: edge_list.append(c_edges) if draw_crease and raw_geometry is not None: cr_edges = extract_crease_edges(raw_geometry, max_angle_deg=crease_angle) if len(cr_edges) > 0: edge_list.append(cr_edges) if edge_list: combined = np.concatenate(edge_list, axis=0) edges_to_draw = deduplicate_2d_edges(combined) else: edges_to_draw = edges_2d if len(edges_to_draw) > 0: min_x = float(edges_to_draw[:, :, 0].min()) max_x = float(edges_to_draw[:, :, 0].max()) min_y = float(edges_to_draw[:, :, 1].min()) max_y = float(edges_to_draw[:, :, 1].max()) elif len(edges_2d) > 0: min_x = float(edges_2d[:, :, 0].min()) max_x = float(edges_2d[:, :, 0].max()) min_y = float(edges_2d[:, :, 1].min()) max_y = float(edges_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 center_x = (min_x + max_x) / 2.0 center_y = (min_y + max_y) / 2.0 L = max(w_geom, h_geom) if L > 1e-6: step_raw = L / 10.0 exponent = int(np.round(np.log10(step_raw))) main_step = 10.0 ** exponent else: main_step = 10.0 minor_step = main_step / 10.0 if len(edges_to_draw) > 0 or len(edges_2d) > 0: pad_x = minor_step pad_y = minor_step half_span_x = math.ceil(max(abs((max_x + pad_x) - center_x), abs((min_x - pad_x) - center_x)) / main_step) * main_step half_span_y = math.ceil(max(abs((max_y + pad_y) - center_y), abs((min_y - pad_y) - center_y)) / main_step) * main_step if half_span_x == 0: half_span_x = main_step if half_span_y == 0: half_span_y = main_step grid_min_x = math.floor((center_x - half_span_x) / main_step) * main_step grid_max_x = math.ceil((center_x + half_span_x) / main_step) * main_step grid_min_y = math.floor((center_y - half_span_y) / main_step) * main_step grid_max_y = math.ceil((center_y + half_span_y) / main_step) * main_step if min_x - grid_min_x < minor_step: grid_min_x -= main_step if grid_max_x - max_x < minor_step: grid_max_x += main_step if min_y - grid_min_y < minor_step: grid_min_y -= main_step if grid_max_y - max_y < minor_step: grid_max_y += main_step if grid_min_x == grid_max_x: grid_max_x += main_step if grid_min_y == grid_max_y: grid_max_y += main_step else: grid_min_x, grid_max_x = -50.0, 50.0 grid_min_y, grid_max_y = -50.0, 50.0 w_grid_mm = grid_max_x - grid_min_x h_grid_mm = grid_max_y - grid_min_y total_margin_mm = margin_mm + 2.0 * minor_step w_total_mm = w_grid_mm + 2.0 * total_margin_mm h_total_mm = h_grid_mm + 2.0 * total_margin_mm page_w_pt = w_total_mm * MM_TO_PT page_h_pt = h_total_mm * MM_TO_PT line_width_pt = line_width_mm * MM_TO_PT def to_pdf_pt(pt_mm: NDArray[np.float64]) -> NDArray[np.float64]: x_pt = (pt_mm[:, 0] - grid_min_x + total_margin_mm) * MM_TO_PT y_pt = (pt_mm[:, 1] - grid_min_y + total_margin_mm) * MM_TO_PT return np.column_stack([x_pt, y_pt]) max_grid_steps = 2000 # Vectorized vertical grid lines k_x_start = int(np.round(grid_min_x / minor_step)) k_x_end = int(np.round(grid_max_x / minor_step)) k_x = np.arange(k_x_start, k_x_end + 1) if len(k_x) <= max_grid_steps: gx = k_x * minor_step pts0_v = np.column_stack([gx, np.full_like(gx, grid_min_y)]) pts1_v = np.column_stack([gx, np.full_like(gx, grid_max_y)]) p0_v = to_pdf_pt(pts0_v) p1_v = to_pdf_pt(pts1_v) is_main_x = (k_x % 10 == 0) main_v_lines = np.column_stack([p0_v[is_main_x], p1_v[is_main_x]]).reshape(-1, 2, 2) minor_v_lines = np.column_stack([p0_v[~is_main_x], p1_v[~is_main_x]]).reshape(-1, 2, 2) else: main_v_lines = np.empty((0, 2, 2)) minor_v_lines = np.empty((0, 2, 2)) # Vectorized horizontal grid lines k_y_start = int(np.round(grid_min_y / minor_step)) k_y_end = int(np.round(grid_max_y / minor_step)) k_y = np.arange(k_y_start, k_y_end + 1) if len(k_y) <= max_grid_steps: gy = k_y * minor_step pts0_h = np.column_stack([np.full_like(gy, grid_min_x), gy]) pts1_h = np.column_stack([np.full_like(gy, grid_max_x), gy]) p0_h = to_pdf_pt(pts0_h) p1_h = 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)) main_lines_arr = np.concatenate([main_v_lines, main_h_lines], axis=0) if len(main_v_lines) or len(main_h_lines) else np.empty((0, 2, 2)) minor_lines_arr = np.concatenate([minor_v_lines, minor_h_lines], axis=0) if len(minor_v_lines) or len(minor_h_lines) else np.empty((0, 2, 2)) stream_cmds: list[str] = [] # 1. Solid background fill br, bg, bb = bg_color sr, sg, sb = stroke_color stream_cmds.append(f"{br:.3f} {bg:.3f} {bb:.3f} rg") stream_cmds.append(f"0 0 {page_w_pt:.2f} {page_h_pt:.2f} re f") # 2. Wireframe stroke color stream_cmds.append(f"{sr:.3f} {sg:.3f} {sb:.3f} RG") # 3. Minor Grid Lines (25% Opacity) if len(minor_lines_arr) > 0: stream_cmds.append("/GS25 gs") minor_grid_pt = min(0.3 * MM_TO_PT, line_width_pt) stream_cmds.append(f"{minor_grid_pt:.2f} w") lines_str = _format_pdf_lines(minor_lines_arr) for start_idx in range(0, len(lines_str), 5000): stream_cmds.extend(lines_str[start_idx:start_idx + 5000]) stream_cmds.append("S") # 4. Main Grid Lines (50% Opacity) if len(main_lines_arr) > 0: stream_cmds.append("/GS50 gs") main_grid_pt = min(0.4 * MM_TO_PT, line_width_pt) stream_cmds.append(f"{main_grid_pt:.2f} w") lines_str = _format_pdf_lines(main_lines_arr) for start_idx in range(0, len(lines_str), 5000): stream_cmds.extend(lines_str[start_idx:start_idx + 5000]) stream_cmds.append("S") # 5. Object Lines (100% Opacity) if len(edges_to_draw) > 0: stream_cmds.append("/GS100 gs") stream_cmds.append(f"{line_width_pt:.2f} w") stream_cmds.append("1 j 1 J") # round join and cap p0_pt = to_pdf_pt(edges_to_draw[:, 0, :]) p1_pt = to_pdf_pt(edges_to_draw[:, 1, :]) obj_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2) lines_str = _format_pdf_lines(obj_lines_arr) for start_idx in range(0, len(lines_str), 5000): stream_cmds.extend(lines_str[start_idx:start_idx + 5000]) stream_cmds.append("S") # 5.5. Object Bounding Boxes (100% Opacity in bbox_color) if draw_bbox and raw_geometry is not None: bbox_edges = extract_object_bounding_boxes(raw_geometry) if len(bbox_edges) > 0: b_r, b_g, b_b = bbox_color stream_cmds.append("/GS100 gs") stream_cmds.append(f"{line_width_pt:.2f} w") stream_cmds.append("1 j 1 J") stream_cmds.append(f"{b_r:.3f} {b_g:.3f} {b_b:.3f} RG") p0_pt = to_pdf_pt(bbox_edges[:, 0, :]) p1_pt = to_pdf_pt(bbox_edges[:, 1, :]) bbox_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2) lines_str = _format_pdf_lines(bbox_lines_arr) for start_idx in range(0, len(lines_str), 5000): stream_cmds.extend(lines_str[start_idx:start_idx + 5000]) stream_cmds.append("S") # 5.6. SA2 Automated Technical Measures Block if draw_sa2_measures and raw_geometry is not None: render_sa2_measures_pdf(stream_cmds, raw_geometry, draw_sa2_measures, line_width_pt, main_step, to_pdf_pt, font_size_pt=font_size_pt) # 6. Size Scale Legend (Bottom Left, aligned directly on bottom-left main grid lines with arrows and X/Y labels) stream_cmds.append("/GS100 gs") x_grid_start_mm = grid_min_x x_grid_end_mm = min(grid_max_x, grid_min_x + 10.0 * main_step) num_main_steps = int(np.round((x_grid_end_mm - x_grid_start_mm) / main_step)) p_start_pt = to_pdf_pt(np.array([[x_grid_start_mm, grid_min_y]])) p_end_pt = to_pdf_pt(np.array([[x_grid_end_mm, grid_min_y]])) x_start_pt = p_start_pt[0, 0] x_end_pt = p_end_pt[0, 0] y_bar_pt = p_start_pt[0, 1] # Vertical Y-axis extension: 5 minor grid lines up from origin y_grid_top_mm = grid_min_y + 5.0 * minor_step p_top_pt = to_pdf_pt(np.array([[grid_min_x, y_grid_top_mm]])) y_top_pt = p_top_pt[0, 1] # Smart arrowhead scaling based on line_width_pt and font size font_size_pt = font_size_pt if font_size_pt is not None and font_size_pt > 0 else max(8.0, line_width_pt * 4.0) arrow_len_pt = max(3.5 * MM_TO_PT, line_width_pt * 4.5) arrow_half_w_pt = arrow_len_pt * 0.40 # Exact same stroke thickness as object wireframe lines stream_cmds.append(f"{line_width_pt:.2f} w") stream_cmds.append("1 j 1 J") # 6a. Main horizontal X-axis line (spans 10 main grid steps, extends 5 minor grid lines past last tick + right arrow + X label) x_arrow_base_mm = x_grid_end_mm + 5.0 * minor_step p_arrow_base_pt = to_pdf_pt(np.array([[x_arrow_base_mm, grid_min_y]])) x_arrow_base_pt = p_arrow_base_pt[0, 0] x_arrow_tip_pt = x_arrow_base_pt + arrow_len_pt # Horizontal main scale bar line stream_cmds.append(f"{x_start_pt:.2f} {y_bar_pt:.2f} m {x_arrow_tip_pt:.2f} {y_bar_pt:.2f} l S") # Pointy arrowhead (+X) extending past 5th minor grid line beyond x_end_pt stream_cmds.append( f"{x_arrow_base_pt:.2f} {y_bar_pt + arrow_half_w_pt:.2f} m " f"{x_arrow_tip_pt:.2f} {y_bar_pt:.2f} l " f"{x_arrow_base_pt:.2f} {y_bar_pt - arrow_half_w_pt:.2f} l S" ) # Ticks directly overlapping vertical main grid lines on X-axis (full 10 main grid steps including k=0) tick_cmds = [] for k in range(num_main_steps + 1): xk_mm = x_grid_start_mm + k * main_step pk_pt = to_pdf_pt(np.array([[xk_mm, grid_min_y]])) xk_pt = pk_pt[0, 0] tick_h_pt = (5.0 if k in (0, 5, num_main_steps) else 3.0) * MM_TO_PT tick_cmds.append(f"{xk_pt:.2f} {y_bar_pt:.2f} m {xk_pt:.2f} {y_bar_pt + tick_h_pt:.2f} l") stream_cmds.extend(tick_cmds) stream_cmds.append("S") # 6b. Main vertical Y-axis line (5 minor grid lines up with NO ticks) + top arrow (+Y) y_arrow_base_pt = y_top_pt y_arrow_tip_pt = y_arrow_base_pt + arrow_len_pt stream_cmds.append(f"{x_start_pt:.2f} {y_bar_pt:.2f} m {x_start_pt:.2f} {y_arrow_tip_pt:.2f} l S") # Pointy arrowhead (+Y) extending past 5th minor grid line (y_top_pt) stream_cmds.append( f"{x_start_pt - arrow_half_w_pt:.2f} {y_arrow_base_pt:.2f} m " f"{x_start_pt:.2f} {y_arrow_tip_pt:.2f} l " f"{x_start_pt + arrow_half_w_pt:.2f} {y_arrow_base_pt:.2f} l S" ) # 6c. Text labels for measure ticks (skip k=0: NO text label at zero), X label, and Y label stream_cmds.append(f"{sr:.3f} {sg:.3f} {sb:.3f} rg") stream_cmds.append("BT") stream_cmds.append(f"/F1 {font_size_pt:.1f} Tf") for k in range(1, num_main_steps + 1): xk_mm = x_grid_start_mm + k * main_step pk_pt = to_pdf_pt(np.array([[xk_mm, grid_min_y]])) xk_pt = pk_pt[0, 0] tick_h_pt = (5.0 if k in (0, 5, num_main_steps) else 3.0) * MM_TO_PT val_mm = k * main_step if main_step >= 1000.0: val_num = val_mm / 1000.0 unit = " m" if k == num_main_steps else "" tick_str = f"{val_num:g}{unit}" else: unit = " mm" if k == num_main_steps else "" tick_str = f"{val_mm:g}{unit}" x_text_pt = xk_pt - (len(tick_str) * font_size_pt * 0.28) y_text_pt = y_bar_pt + tick_h_pt + (2.5 * MM_TO_PT) stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {y_text_pt:.2f} Tm") stream_cmds.append(f"({tick_str}) Tj") # X axis label next to right arrowhead x_x_label_pt = x_arrow_tip_pt + (2.0 * MM_TO_PT) y_x_label_pt = y_bar_pt - (font_size_pt * 0.35) stream_cmds.append(f"1 0 0 1 {x_x_label_pt:.2f} {y_x_label_pt:.2f} Tm") stream_cmds.append("(X) Tj") # Y axis label next to top arrowhead x_y_label_pt = x_start_pt - (font_size_pt * 0.35) y_y_label_pt = y_arrow_tip_pt + (2.0 * MM_TO_PT) stream_cmds.append(f"1 0 0 1 {x_y_label_pt:.2f} {y_y_label_pt:.2f} Tm") stream_cmds.append("(Y) Tj") stream_cmds.append("ET") # 7. Object Names, Bounding Box Sizes, and Settings Attributes (to the right of the rightmost vertex) settings_items = extract_settings_items(settings) if (show_name or show_bbox_size or settings_items) and raw_geometry is not None: label_infos = extract_object_label_info(raw_geometry) if label_infos: font_size_pt = max(8.0, line_width_pt * 4.0) line_spacing_pt = font_size_pt * 1.2 stream_cmds.append("/GS100 gs") stream_cmds.append(f"{sr:.3f} {sg:.3f} {sb:.3f} rg") stream_cmds.append("BT") stream_cmds.append(f"/F1 {font_size_pt:.1f} Tf") for so_item, obj_name, max_x_mm, y_val_mm, w_obj_mm, h_obj_mm in label_infos: p_pt = to_pdf_pt(np.array([[max_x_mm, y_val_mm]])) x_text_pt = p_pt[0, 0] + (2.5 * MM_TO_PT) y_text_pt = p_pt[0, 1] - (font_size_pt * 0.35) lines_to_draw: list[str] = [] if show_name: lines_to_draw.append(str(obj_name)) if show_bbox_size: if main_step >= 1000.0: w_val = w_obj_mm / 1000.0 h_val = h_obj_mm / 1000.0 size_str = f"(X: {w_val:g}m Y: {h_val:g}m)" else: size_str = f"(X: {w_obj_mm:g}mm Y: {h_obj_mm:g}mm)" lines_to_draw.append(size_str) 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}") for line_idx, line_str in enumerate(lines_to_draw): clean_str = line_str.replace("(", "\\(").replace(")", "\\)") curr_y_pt = y_text_pt - (line_idx * line_spacing_pt) stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {curr_y_pt:.2f} Tm") stream_cmds.append(f"({clean_str}) Tj") stream_cmds.append("ET") stream_content = "\n".join(stream_cmds) + "\n" stream_bytes = stream_content.encode("utf-8") objects: list[bytes] = [] objects.append(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n") objects.append(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n") page_header = ( f"3 0 obj\n" f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {page_w_pt:.2f} {page_h_pt:.2f}] " f"/Resources << /ExtGState << /GS25 5 0 R /GS50 6 0 R /GS100 7 0 R >> /Font << /F1 8 0 R >> >> " f"/Contents 4 0 R >>\n" f"endobj\n" ) objects.append(page_header.encode("utf-8")) content_header = f"4 0 obj\n<< /Length {len(stream_bytes)} >>\nstream\n".encode("utf-8") content_footer = b"endstream\nendobj\n" objects.append(content_header + stream_bytes + content_footer) objects.append(b"5 0 obj\n<< /Type /ExtGState /CA 0.25 /ca 0.25 >>\nendobj\n") objects.append(b"6 0 obj\n<< /Type /ExtGState /CA 0.5 /ca 0.5 >>\nendobj\n") objects.append(b"7 0 obj\n<< /Type /ExtGState /CA 1.0 /ca 1.0 >>\nendobj\n") # Embed Open Gorton Typeface font_file_path = Path(__file__).parent / "fonts" / "OpenGorton-Regular.otf" if not font_file_path.exists(): font_file_path = Path(__file__).parents[2] / "fonts" / "OpenGorton-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 /OpenGorton-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 /OpenGorton-Regular /Flags 32 " b"/FontBBox [-500 -300 1500 1000] /ItalicAngle 0 /Ascent 800 /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 /Helvetica >>\nendobj\n") header = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n" parent_dir = os.path.dirname(filepath) if parent_dir: os.makedirs(parent_dir, exist_ok=True) with open(filepath, "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") # Alias for backward compatibility export_wireframe_to_pdf = export_mesh_to_pdf