590 lines
23 KiB
Python
590 lines
23 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 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
|
||
from .sizing import (
|
||
compute_grid_steps,
|
||
compute_effective_stroke_and_font,
|
||
LegendSizing,
|
||
MM_TO_PT,
|
||
)
|
||
|
||
|
||
def escape_pdf_text(text: str) -> str:
|
||
"""
|
||
Escapes special PDF characters (parentheses, backslashes) and converts
|
||
WinAnsiEncoding symbols (Ø -> \330, ° -> \260, × -> \327, ± -> \261, µ -> \265)
|
||
into PDF octal escape codes.
|
||
"""
|
||
res = []
|
||
for ch in text:
|
||
if ch == "(":
|
||
res.append("\\(")
|
||
elif ch == ")":
|
||
res.append("\\)")
|
||
elif ch == "\\":
|
||
res.append("\\\\")
|
||
else:
|
||
code = ord(ch)
|
||
if code < 128:
|
||
res.append(ch)
|
||
elif code <= 255:
|
||
res.append(f"\\{code:03o}")
|
||
else:
|
||
res.append("?")
|
||
return "".join(res)
|
||
|
||
|
||
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_holes: bool = False,
|
||
draw_sa2_arcs: bool = False,
|
||
draw_sa2_chamfers: bool = False,
|
||
draw_sa2_step_features: 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_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,
|
||
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)
|
||
|
||
# extract_crease_edges (lineart) already extracts boundary edges, so skip extract_boundary_edges when draw_crease is active
|
||
if draw_boundary and not draw_crease 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)
|
||
elif draw_wireframe:
|
||
edges_to_draw = edges_2d
|
||
else:
|
||
edges_to_draw = np.empty((0, 2, 2), dtype=np.float64)
|
||
|
||
target_edges = edges_to_draw if len(edges_to_draw) > 0 else 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
|
||
center_x = (min_x + max_x) / 2.0
|
||
center_y = (min_y + max_y) / 2.0
|
||
main_step, minor_step = compute_grid_steps(w_geom, h_geom)
|
||
|
||
if len(edges_to_draw) > 0 or len(edges_2d) > 0:
|
||
pad_x = minor_step
|
||
pad_y = minor_step
|
||
|
||
# Calculate required grid span with padding
|
||
span_x = max(abs(max_x - center_x), abs(min_x - center_x)) + pad_x
|
||
span_y = max(abs(max_y - center_y), abs(min_y - center_y)) + pad_y
|
||
|
||
# Round up to nearest main_step
|
||
half_span_x = math.ceil(span_x / main_step) * main_step
|
||
half_span_y = math.ceil(span_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
|
||
|
||
# Ensure grid is symmetric around center
|
||
grid_min_x = center_x - half_span_x
|
||
grid_max_x = center_x + half_span_x
|
||
grid_min_y = center_y - half_span_y
|
||
grid_max_y = center_y + half_span_y
|
||
|
||
# Ensure grid aligns with main_step grid lines
|
||
grid_min_x = math.floor(grid_min_x / main_step) * main_step
|
||
grid_max_x = math.ceil(grid_max_x / main_step) * main_step
|
||
grid_min_y = math.floor(grid_min_y / main_step) * main_step
|
||
grid_max_y = math.ceil(grid_max_y / main_step) * 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, font_size_pt = compute_effective_stroke_and_font(
|
||
line_width_mm, font_size_mm, minor_step
|
||
)
|
||
|
||
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 raw_geometry is not None and (draw_sa2_holes or draw_sa2_arcs or draw_sa2_chamfers or draw_sa2_step_features):
|
||
render_sa2_measures_pdf(
|
||
stream_cmds,
|
||
raw_geometry,
|
||
draw_sa2_holes=draw_sa2_holes,
|
||
draw_sa2_arcs=draw_sa2_arcs,
|
||
draw_sa2_chamfers=draw_sa2_chamfers,
|
||
draw_sa2_step_features=draw_sa2_step_features,
|
||
minor_step_mm=minor_step,
|
||
main_step=main_step,
|
||
to_pdf_pt=to_pdf_pt,
|
||
font_size_pt=font_size_pt,
|
||
line_width_pt=line_width_pt,
|
||
draw_wireframe=draw_wireframe,
|
||
draw_boundary=draw_boundary,
|
||
draw_contour=draw_contour,
|
||
draw_crease=draw_crease,
|
||
crease_angle=crease_angle,
|
||
)
|
||
|
||
# 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")
|
||
stream_cmds.append("0.000 0.000 0.000 RG")
|
||
stream_cmds.append("0.000 0.000 0.000 rg")
|
||
|
||
leg_sizing = LegendSizing(main_step, minor_step, line_width_pt, font_size_pt)
|
||
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 dynamically sized past tick height
|
||
y_grid_top_mm = grid_min_y + leg_sizing.y_axis_height_mm
|
||
p_top_pt = to_pdf_pt(np.array([[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
|
||
|
||
# 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 main grid steps, extends past last tick + right arrow + X label)
|
||
x_arrow_base_mm = x_grid_end_mm + leg_sizing.x_axis_extension_mm
|
||
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 last tick
|
||
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
|
||
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_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
|
||
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 + 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 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("0.000 0.000 0.000 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_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 * 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}"
|
||
|
||
tw_pt = len(tick_str) * font_size_pt * 0.60
|
||
x_text_pt = xk_pt - (tw_pt / 2.0)
|
||
y_text_pt = y_bar_pt + tick_h_pt + (2.0 * MM_TO_PT)
|
||
stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {y_text_pt:.2f} Tm")
|
||
stream_cmds.append(f"({escape_pdf_text(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:
|
||
eff_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)
|
||
line_spacing_pt = eff_font_size_pt * 1.25
|
||
stream_cmds.append("/GS100 gs")
|
||
|
||
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] - (eff_font_size_pt * 0.35)
|
||
|
||
lines_to_draw: list[tuple[str, tuple[float, float, float]]] = []
|
||
|
||
if show_name:
|
||
lines_to_draw.append((str(obj_name), stroke_color))
|
||
|
||
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, bbox_color))
|
||
|
||
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}", stroke_color))
|
||
|
||
if lines_to_draw:
|
||
stream_cmds.append("BT")
|
||
stream_cmds.append(f"/F1 {eff_font_size_pt:.1f} Tf")
|
||
|
||
for line_idx, (line_str, col) in enumerate(lines_to_draw):
|
||
cr, cg, cb = col
|
||
stream_cmds.append(f"{cr:.3f} {cg:.3f} {cb:.3f} rg")
|
||
clean_str = escape_pdf_text(line_str)
|
||
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 League Mono Typeface
|
||
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(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
|