nodeslicer/core/pdf_exporter/writer.py

1034 lines
40 KiB
Python

"""
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,
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)
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 "-")
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 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 hasattr(so, "object_properties") and so.object_properties
]
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
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:
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
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 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],
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
if self.draw_header and header_info is None:
header_info = {}
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),
self.ObjectPropertiesTableElement(self),
]
if filepath is not None:
self.export(filepath)
def _prepare_geometry_and_layout(self) -> None:
self.slicing_objs = get_all_slicing_objects(self.raw_geometry)
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
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 hasattr(so, "object_properties") and so.object_properties
]
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
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")
# Alias for backward compatibility
export_wireframe_to_pdf = export_mesh_to_pdf