nodeslicer/core/pdf_exporter/writer.py
2026-08-07 16:04:51 +03:00

1127 lines
45 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,
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, "object_properties", None) is not None and len(getattr(so, "object_properties", [])) > 0
]
if not objs_with_props:
return
w_table_mm = (font_size_pt / MM_TO_PT) * 25.0
w_box_pt = w_table_mm * MM_TO_PT
cell_height_mm = (font_size_pt / MM_TO_PT) * 1.4
cell_h_pt = cell_height_mm * MM_TO_PT
gap_pt = cell_h_pt * 0.5
x1_pt = page_w_pt - (total_margin_mm * MM_TO_PT)
x0_pt = x1_pt - w_box_pt
top_y_pt = page_h_pt - (total_margin_mm * MM_TO_PT)
if draw_header and header_info is not None:
h_header_table_mm = cell_height_mm * 3.0
header_table_h_pt = h_header_table_mm * MM_TO_PT
curr_y_pt = top_y_pt - header_table_h_pt - gap_pt
else:
curr_y_pt = top_y_pt
table_lw_pt = line_width_pt * 1.2
grid_lw_pt = line_width_pt * 0.8
br, bg, bb = bg_color
sr, sg, sb = stroke_color
eff_font = font_size_pt if font_size_pt is not None and font_size_pt > 0 else 10.0
val_font_pt = eff_font * 0.75
lbl_font_pt = eff_font * 0.65
label_color = (0.35, 0.35, 0.35)
for so in objs_with_props:
raw_props = getattr(so, "object_properties", [])
props = resolve_object_properties(so, raw_props)
if not props:
continue
obj_name = str(getattr(so, "name", "Object"))
num_rows = 1 + len(props)
h_box_pt = num_rows * cell_h_pt
y1_box_pt = curr_y_pt
y0_box_pt = y1_box_pt - h_box_pt
# Fill background
s.opacity("GS100")
s.fill_color(br, bg, bb)
s.fill_rect(x0_pt, y0_box_pt, w_box_pt, h_box_pt)
# Outer border
s.stroke_color(sr, sg, sb).line_width(table_lw_pt).round_caps()
s.rect(x0_pt, y0_box_pt, w_box_pt, h_box_pt).stroke()
# Divider between Subbox 1 (Object Name) and Subbox 2 (Properties)
y_name_div_pt = y1_box_pt - cell_h_pt
s.line_width(grid_lw_pt)
s.line_stroke(x0_pt, y_name_div_pt, x1_pt, y_name_div_pt)
# Subbox 1: Object Name (in bold)
name_vx = x0_pt + (1.5 * MM_TO_PT)
name_vy = y_name_div_pt + (1.5 * MM_TO_PT)
s.begin_text().font("F1", val_font_pt)
s.fill_color(sr, sg, sb)
bold_w = max(0.15, val_font_pt * 0.035)
s.stroke_color(sr, sg, sb).line_width(bold_w).text_rendering_mode(2)
s.text_position(name_vx, name_vy)
s.show(obj_name)
s.text_rendering_mode(0)
s.end_text()
# Subbox 2: Properties
y_prop_curr_pt = y_name_div_pt
x_div_40_pt = x0_pt + (0.40 * w_box_pt)
for prop_idx, (prop_id, prop_val) in enumerate(props):
row_top_pt = y_prop_curr_pt - prop_idx * cell_h_pt
row_bot_pt = row_top_pt - cell_h_pt
if prop_idx > 0:
s.line_width(grid_lw_pt)
s.line_stroke(x0_pt, row_top_pt, x1_pt, row_top_pt)
str_val = str(prop_val)
if not prop_id:
# Single line text (just the value in a cell)
vx = x0_pt + (1.5 * MM_TO_PT)
vy = row_bot_pt + (1.5 * MM_TO_PT)
s.begin_text().font("F1", val_font_pt)
s.fill_color(sr, sg, sb)
s.text_position(vx, vy)
s.show(str_val)
s.end_text()
else:
# Pair ID-value (two cells with divider at 40% of width)
s.line_width(grid_lw_pt)
s.line_stroke(x_div_40_pt, row_bot_pt, x_div_40_pt, row_top_pt)
# Left cell: ID
lx = x0_pt + (1.5 * MM_TO_PT)
ly = row_bot_pt + (1.5 * MM_TO_PT)
s.begin_text().font("F1", lbl_font_pt)
s.fill_color(*label_color)
s.text_position(lx, ly)
s.show(str(prop_id))
s.end_text()
# Right cell: Value
vx = x_div_40_pt + (1.5 * MM_TO_PT)
vy = row_bot_pt + (1.5 * MM_TO_PT)
s.begin_text().font("F1", val_font_pt)
s.fill_color(sr, sg, sb)
s.text_position(vx, vy)
s.show(str_val)
s.end_text()
curr_y_pt = y0_box_pt - gap_pt
def get_all_slicing_objects(mesh_data: Any) -> list[Any]:
from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
from ..slice import SlicePaths
objects = []
def process_item(item: Any):
if item is None:
return
elif isinstance(item, (SlicingObject, SlicePaths)):
objects.append(item)
elif isinstance(item, (list, tuple)):
for sub in item:
process_item(sub)
elif isinstance(item, SlicingGroup):
for coll in item.collections.values():
process_item(coll)
elif isinstance(item, SlicingCollection):
for so in item.objects:
process_item(so)
process_item(mesh_data)
return objects
class PDFElement:
"""
Base class for individual PDF layout and geometry rendering elements.
"""
def __init__(self, exporter: "export_mesh_to_pdf") -> None:
self.exporter = exporter
def render(self, s: PdfStream) -> None:
"""
Renders the element content into the given PdfStream.
"""
raise NotImplementedError
class PDFBackgroundElement(PDFElement):
"""
Renders solid page background fill.
"""
def render(self, s: PdfStream) -> None:
exp = self.exporter
br, bg, bb = exp.bg_color
s.fill_color(br, bg, bb)
s.fill_rect(0, 0, exp.page_w_pt, exp.page_h_pt)
class PDFGridElement(PDFElement):
"""
Renders minor (25% opacity) and main (50% opacity) background grid lines.
"""
def render(self, s: PdfStream) -> None:
exp = self.exporter
sr, sg, sb = exp.stroke_color
s.stroke_color(sr, sg, sb)
if len(exp.minor_lines_arr) > 0:
s.opacity("GS25").line_width(exp.line_width_pt)
s.draw_line_batch(exp.minor_lines_arr)
if len(exp.main_lines_arr) > 0:
s.opacity("GS50").line_width(exp.line_width_pt)
s.draw_line_batch(exp.main_lines_arr)
class PDFGeometryElement(PDFElement):
"""
Renders 2D vector geometry (wireframe, boundary, contour, crease lines, and slice paths).
"""
def render(self, s: PdfStream) -> None:
exp = self.exporter
slicing_objs = exp.slicing_objs
from ..slice import SlicePaths
if slicing_objs:
for so in slicing_objs:
so_edge_list: list[NDArray[np.float64]] = []
if isinstance(so, SlicePaths):
sp_edges = extract_slice_paths_edges(so)
if len(sp_edges) > 0:
so_edge_list.append(sp_edges)
else:
if exp.draw_wireframe:
so_w = extract_unique_top_view_edges(so)
if len(so_w) > 0:
so_edge_list.append(so_w)
if exp.draw_boundary and not exp.draw_crease:
so_b = extract_boundary_edges(so)
if len(so_b) > 0:
so_edge_list.append(so_b)
if exp.draw_contour:
so_c = extract_outer_contour_edges(so)
if len(so_c) > 0:
so_edge_list.append(so_c)
if exp.draw_crease:
so_cr = extract_crease_edges(so, max_angle_deg=exp.crease_angle)
if len(so_cr) > 0:
so_edge_list.append(so_cr)
if so_edge_list:
so_combined = np.concatenate(so_edge_list, axis=0)
so_edges_to_draw = deduplicate_2d_edges(so_combined)
else:
so_edges_to_draw = np.empty((0, 2, 2), dtype=np.float64)
if len(so_edges_to_draw) > 0:
so_lw_mm = (
so.pdf_properties.line_width
if (hasattr(so, "pdf_properties") and so.pdf_properties and so.pdf_properties.line_width > 0.0)
else exp.line_width_mm
)
so_color = (
so.pdf_properties.line_color
if (hasattr(so, "pdf_properties") and so.pdf_properties and so.pdf_properties.line_color is not None)
else exp.stroke_color
)
so_line_width_pt, _ = compute_effective_stroke_and_font(
so_lw_mm, exp.font_size_mm, exp.minor_step
)
s.opacity("GS100").line_width(so_line_width_pt).stroke_color(so_color[0], so_color[1], so_color[2]).round_caps()
p0_pt = exp.to_pdf_pt(so_edges_to_draw[:, 0, :])
p1_pt = exp.to_pdf_pt(so_edges_to_draw[:, 1, :])
obj_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2)
s.draw_line_batch(obj_lines_arr)
elif len(exp.edges_to_draw) > 0:
s.opacity("GS100").line_width(exp.line_width_pt).stroke_color(exp.stroke_color[0], exp.stroke_color[1], exp.stroke_color[2]).round_caps()
p0_pt = exp.to_pdf_pt(exp.edges_to_draw[:, 0, :])
p1_pt = exp.to_pdf_pt(exp.edges_to_draw[:, 1, :])
obj_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2)
s.draw_line_batch(obj_lines_arr)
class PDFBoundingBoxElement(PDFElement):
"""
Renders 2D object bounding boxes in bbox_color.
"""
def render(self, s: PdfStream) -> None:
exp = self.exporter
if exp.draw_bbox and exp.raw_geometry is not None:
bbox_edges = extract_object_bounding_boxes(exp.raw_geometry)
if len(bbox_edges) > 0:
b_r, b_g, b_b = exp.bbox_color
s.opacity("GS100").line_width(exp.line_width_pt).round_caps()
s.stroke_color(b_r, b_g, b_b)
p0_pt = exp.to_pdf_pt(bbox_edges[:, 0, :])
p1_pt = exp.to_pdf_pt(bbox_edges[:, 1, :])
bbox_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2)
s.draw_line_batch(bbox_lines_arr)
class PDFSA2MeasuresElement(PDFElement):
"""
Renders SA2 technical measurement annotations (bores, holes, pitch, chamfers).
"""
def render(self, s: PdfStream) -> None:
exp = self.exporter
if exp.raw_geometry is not None and exp.draw_sa2_holes:
render_sa2_measures_pdf(
s,
exp.raw_geometry,
draw_sa2_holes=exp.draw_sa2_holes,
minor_step_mm=exp.minor_step,
main_step=exp.main_step,
to_pdf_pt=exp.to_pdf_pt,
font_size_pt=exp.font_size_pt,
line_width_pt=exp.line_width_pt,
draw_wireframe=exp.draw_wireframe,
draw_boundary=exp.draw_boundary,
draw_contour=exp.draw_contour,
draw_crease=exp.draw_crease,
crease_angle=exp.crease_angle,
)
class PDFLegendElement(PDFElement):
"""
Renders physical size scale legend (axes, tick marks, arrows, and units)
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, 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, 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, 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, "object_properties", None) is not None and len(getattr(so, "object_properties", [])) > 0
]
w_table_mm = 0.0
h_table_col_mm = 0.0
cell_height_mm = (self.font_size_pt / MM_TO_PT) * 1.4
gap_mm = cell_height_mm * 0.5
if self.draw_header and self.header_info is not None:
w_table_mm = (self.font_size_pt / MM_TO_PT) * 25.0
h_header_table_mm = cell_height_mm * 3.0
h_table_col_mm = h_header_table_mm
if objs_with_props:
w_table_mm = (self.font_size_pt / MM_TO_PT) * 25.0
h_props_total_mm = sum(
(1 + len(getattr(so, "object_properties", []))) * cell_height_mm
for so in objs_with_props
) + len(objs_with_props) * gap_mm
if h_table_col_mm > 0.0:
h_table_col_mm += gap_mm + h_props_total_mm
else:
h_table_col_mm = h_props_total_mm
w_total_mm = w_grid_mm + w_table_mm + 2.0 * self.total_margin_mm
h_total_mm = max(h_grid_mm, h_table_col_mm) + 2.0 * self.total_margin_mm
self.page_w_pt = w_total_mm * MM_TO_PT
self.page_h_pt = h_total_mm * MM_TO_PT
# Convert coordinates to PDF point coordinates
if len(minor_edges_2d) > 0:
p0_pt = self.to_pdf_pt(minor_edges_2d[:, 0, :])
p1_pt = self.to_pdf_pt(minor_edges_2d[:, 1, :])
self.minor_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2)
else:
self.minor_lines_arr = np.empty((0, 2, 2), dtype=np.float64)
if len(main_edges_2d) > 0:
p0_pt = self.to_pdf_pt(main_edges_2d[:, 0, :])
p1_pt = self.to_pdf_pt(main_edges_2d[:, 1, :])
self.main_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2)
else:
self.main_lines_arr = np.empty((0, 2, 2), dtype=np.float64)
def to_pdf_pt(self, pt_mm: NDArray[np.float64]) -> NDArray[np.float64]:
x_pt = (pt_mm[:, 0] - self.grid_min_x + self.total_margin_mm) * MM_TO_PT
y_pt = (pt_mm[:, 1] - self.grid_min_y + self.total_margin_mm) * MM_TO_PT
return np.column_stack([x_pt, y_pt])
def export(self, filepath: str | Path | None = None) -> None:
target_path = filepath if filepath is not None else self.filepath
if target_path is None:
raise ValueError("No filepath provided for PDF export.")
s = PdfStream()
for element in self.elements:
element.render(s)
stream_bytes = s.encode()
objects: list[bytes] = []
objects.append(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n")
objects.append(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n")
page_header = (
f"3 0 obj\n"
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {self.page_w_pt:.2f} {self.page_h_pt:.2f}] "
f"/Resources << /ExtGState << /GS25 5 0 R /GS50 6 0 R /GS100 7 0 R >> /Font << /F1 8 0 R >> >> "
f"/Contents 4 0 R >>\n"
f"endobj\n"
)
objects.append(page_header.encode("utf-8"))
content_header = f"4 0 obj\n<< /Length {len(stream_bytes)} >>\nstream\n".encode("utf-8")
content_footer = b"endstream\nendobj\n"
objects.append(content_header + stream_bytes + content_footer)
objects.append(b"5 0 obj\n<< /Type /ExtGState /CA 0.25 /ca 0.25 >>\nendobj\n")
objects.append(b"6 0 obj\n<< /Type /ExtGState /CA 0.5 /ca 0.5 >>\nendobj\n")
objects.append(b"7 0 obj\n<< /Type /ExtGState /CA 1.0 /ca 1.0 >>\nendobj\n")
font_file_path = Path(__file__).parent / "fonts" / "LeagueMono-Regular.otf"
if not font_file_path.exists():
font_file_path = Path(__file__).parents[2] / "fonts" / "LeagueMono-Regular.otf"
font_bytes: bytes | None = None
if font_file_path.exists():
try:
with open(font_file_path, "rb") as f:
font_bytes = f.read()
except Exception:
font_bytes = None
if font_bytes:
widths_str = " ".join(["600"] * 224)
objects.append(
f"8 0 obj\n"
f"<< /Type /Font /Subtype /Type1 /BaseFont /LeagueMono-Regular "
f"/FirstChar 32 /LastChar 255 "
f"/Widths [ {widths_str} ] "
f"/FontDescriptor 9 0 R /Encoding /WinAnsiEncoding >>\nendobj\n".encode("utf-8")
)
objects.append(
b"9 0 obj\n"
b"<< /Type /FontDescriptor /FontName /LeagueMono-Regular /Flags 33 "
b"/FontBBox [-200 -200 1200 1000] /ItalicAngle 0 /Ascent 1000 /Descent -200 "
b"/CapHeight 700 /StemV 80 /FontFile3 10 0 R >>\nendobj\n"
)
font_stream_header = f"10 0 obj\n<< /Length {len(font_bytes)} /Subtype /OpenType >>\nstream\n".encode("utf-8")
font_stream_footer = b"\nendstream\nendobj\n"
objects.append(font_stream_header + font_bytes + font_stream_footer)
else:
objects.append(b"8 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>\nendobj\n")
header = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n"
parent_dir = os.path.dirname(target_path)
if parent_dir:
os.makedirs(parent_dir, exist_ok=True)
with open(target_path, "wb") as f:
f.write(header)
offsets = [0]
current_offset = len(header)
for obj_bytes in objects:
offsets.append(current_offset)
f.write(obj_bytes)
current_offset += len(obj_bytes)
xref_offset = current_offset
f.write(b"xref\n")
f.write(f"0 {len(offsets)}\n".encode("utf-8"))
f.write(b"0000000000 65535 f \n")
for off in offsets[1:]:
f.write(f"{off:010d} 00000 n \n".encode("utf-8"))
f.write(b"trailer\n")
f.write(f"<< /Size {len(offsets)} /Root 1 0 R >>\n".encode("utf-8"))
f.write(b"startxref\n")
f.write(f"{xref_offset}\n".encode("utf-8"))
f.write(b"%%EOF\n")