This commit is contained in:
Nicolas Predella 2026-08-03 19:10:17 +03:00
parent 0e2cdcd3b3
commit 9ccac33958
4 changed files with 678 additions and 461 deletions

View file

@ -46,12 +46,30 @@ from .sizing import MM_TO_PT
from .writer import ( from .writer import (
export_mesh_to_pdf, export_mesh_to_pdf,
export_wireframe_to_pdf, export_wireframe_to_pdf,
PDFElement,
PDFBackgroundElement,
PDFGridElement,
PDFGeometryElement,
PDFBoundingBoxElement,
PDFSA2MeasuresElement,
PDFLegendElement,
PDFObjectLabelsElement,
PDFHeaderTableElement,
) )
__all__ = [ __all__ = [
# Main exporters # Main exporters & element classes
"export_mesh_to_pdf", "export_mesh_to_pdf",
"export_wireframe_to_pdf", "export_wireframe_to_pdf",
"PDFElement",
"PDFBackgroundElement",
"PDFGridElement",
"PDFGeometryElement",
"PDFBoundingBoxElement",
"PDFSA2MeasuresElement",
"PDFLegendElement",
"PDFObjectLabelsElement",
"PDFHeaderTableElement",
# PDF stream builder # PDF stream builder
"PdfStream", "PdfStream",
# PDF utilities # PDF utilities

View file

@ -146,7 +146,6 @@ def render_technical_header_table(
s.end_text() s.end_text()
def get_all_slicing_objects(mesh_data: Any) -> list[Any]: def get_all_slicing_objects(mesh_data: Any) -> list[Any]:
from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
objects = [] objects = []
@ -168,9 +167,385 @@ def get_all_slicing_objects(mesh_data: Any) -> list[Any]:
return objects return objects
def export_mesh_to_pdf( 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:
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)
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 bottom-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 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
# Convenient aliases for element subclasses
Legend = PDFLegendElement
Table = PDFHeaderTableElement
HeaderTable = PDFHeaderTableElement
Grid = PDFGridElement
Geometry = PDFGeometryElement
BoundingBox = PDFBoundingBoxElement
SA2Measures = PDFSA2MeasuresElement
ObjectLabels = PDFObjectLabelsElement
Background = PDFBackgroundElement
def __init__(
self,
edges_2d: NDArray[np.float64], edges_2d: NDArray[np.float64],
filepath: str | Path, filepath: str | Path | None = None,
draw_wireframe: bool = True, draw_wireframe: bool = True,
draw_boundary: bool = False, draw_boundary: bool = False,
draw_contour: bool = False, draw_contour: bool = False,
@ -190,42 +565,76 @@ def export_mesh_to_pdf(
bbox_color: tuple[float, float, float] = (0.0, 0.0, 1.0), bbox_color: tuple[float, float, float] = (0.0, 0.0, 1.0),
raw_geometry: Any = None, raw_geometry: Any = None,
settings: Any = None, settings: Any = None,
**kwargs: Any **kwargs: Any,
) -> None: ) -> None:
""" self.edges_2d = edges_2d
Exports 2D vector geometry to a 1:1 true physical scale vector PDF file. self.filepath = filepath
Supports toggleable line layers: Wireframe, Boundary (1 face), Contour, Crease Angle. 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
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),
]
if filepath is not None:
self.export(filepath)
def _prepare_geometry_and_layout(self) -> None:
edge_list: list[NDArray[np.float64]] = [] edge_list: list[NDArray[np.float64]] = []
if draw_wireframe and len(edges_2d) > 0: if self.draw_wireframe and len(self.edges_2d) > 0:
edge_list.append(edges_2d) edge_list.append(self.edges_2d)
# extract_crease_edges (lineart) already extracts boundary edges, so skip extract_boundary_edges when draw_crease is active if self.draw_boundary and not self.draw_crease and self.raw_geometry is not None:
if draw_boundary and not draw_crease and raw_geometry is not None: b_edges = extract_boundary_edges(self.raw_geometry)
b_edges = extract_boundary_edges(raw_geometry)
if len(b_edges) > 0: if len(b_edges) > 0:
edge_list.append(b_edges) edge_list.append(b_edges)
if draw_contour and raw_geometry is not None: if self.draw_contour and self.raw_geometry is not None:
c_edges = extract_outer_contour_edges(raw_geometry) c_edges = extract_outer_contour_edges(self.raw_geometry)
if len(c_edges) > 0: if len(c_edges) > 0:
edge_list.append(c_edges) edge_list.append(c_edges)
if draw_crease and raw_geometry is not None: if self.draw_crease and self.raw_geometry is not None:
cr_edges = extract_crease_edges(raw_geometry, max_angle_deg=crease_angle) cr_edges = extract_crease_edges(self.raw_geometry, max_angle_deg=self.crease_angle)
if len(cr_edges) > 0: if len(cr_edges) > 0:
edge_list.append(cr_edges) edge_list.append(cr_edges)
if edge_list: if edge_list:
combined = np.concatenate(edge_list, axis=0) combined = np.concatenate(edge_list, axis=0)
edges_to_draw = deduplicate_2d_edges(combined) self.edges_to_draw = deduplicate_2d_edges(combined)
elif draw_wireframe: elif self.draw_wireframe:
edges_to_draw = edges_2d self.edges_to_draw = self.edges_2d
else: else:
edges_to_draw = np.empty((0, 2, 2), dtype=np.float64) self.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 target_edges = self.edges_to_draw if len(self.edges_to_draw) > 0 else self.edges_2d
if len(target_edges) > 0: if len(target_edges) > 0:
pts_2d = target_edges.reshape(-1, 2) pts_2d = target_edges.reshape(-1, 2)
min_x, max_x = float(pts_2d[:, 0].min()), float(pts_2d[:, 0].max()) min_x, max_x = float(pts_2d[:, 0].min()), float(pts_2d[:, 0].max())
@ -235,61 +644,54 @@ def export_mesh_to_pdf(
w_geom = max_x - min_x w_geom = max_x - min_x
h_geom = max_y - min_y h_geom = max_y - min_y
main_step, minor_step = compute_grid_steps(w_geom, h_geom) self.main_step, self.minor_step = compute_grid_steps(w_geom, h_geom)
if len(edges_to_draw) > 0 or len(edges_2d) > 0: if len(self.edges_to_draw) > 0 or len(self.edges_2d) > 0:
pad_x = minor_step pad_x = self.minor_step
pad_y = minor_step pad_y = self.minor_step
# Ensure grid is symmetric around center
grid_min_x = min_x - pad_x grid_min_x = min_x - pad_x
grid_max_x = max_x + pad_x grid_max_x = max_x + pad_x
grid_min_y = min_y - pad_y grid_min_y = min_y - pad_y
grid_max_y = max_y + pad_y grid_max_y = max_y + pad_y
# Ensure grid aligns with minor_step grid lines self.grid_min_x = math.floor(grid_min_x / self.minor_step) * self.minor_step
grid_min_x = math.floor(grid_min_x / minor_step) * minor_step self.grid_max_x = math.ceil(grid_max_x / self.minor_step) * self.minor_step
grid_max_x = math.ceil(grid_max_x / minor_step) * minor_step self.grid_min_y = math.floor(grid_min_y / self.minor_step) * self.minor_step
grid_min_y = math.floor(grid_min_y / minor_step) * minor_step self.grid_max_y = math.ceil(grid_max_y / self.minor_step) * self.minor_step
grid_max_y = math.ceil(grid_max_y / minor_step) * minor_step
else: else:
grid_min_x, grid_max_x = -50.0, 50.0 self.grid_min_x, self.grid_max_x = -50.0, 50.0
grid_min_y, grid_max_y = -50.0, 50.0 self.grid_min_y, self.grid_max_y = -50.0, 50.0
w_grid_mm = grid_max_x - grid_min_x w_grid_mm = self.grid_max_x - self.grid_min_x
h_grid_mm = grid_max_y - grid_min_y h_grid_mm = self.grid_max_y - self.grid_min_y
total_margin_mm = margin_mm + 2.0 * minor_step self.total_margin_mm = self.margin_mm + 2.0 * self.minor_step
w_total_mm = w_grid_mm + 2.0 * total_margin_mm w_total_mm = w_grid_mm + 2.0 * self.total_margin_mm
h_total_mm = h_grid_mm + 2.0 * total_margin_mm h_total_mm = h_grid_mm + 2.0 * self.total_margin_mm
page_w_pt = w_total_mm * MM_TO_PT self.page_w_pt = w_total_mm * MM_TO_PT
page_h_pt = h_total_mm * MM_TO_PT self.page_h_pt = h_total_mm * MM_TO_PT
line_width_pt, font_size_pt = compute_effective_stroke_and_font( self.line_width_pt, self.font_size_pt = compute_effective_stroke_and_font(
line_width_mm, font_size_mm, minor_step self.line_width_mm, self.font_size_mm, self.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 max_grid_steps = 2000
# Vectorized vertical grid lines # Vectorized vertical grid lines
k_x_start = int(np.round(grid_min_x / minor_step)) k_x_start = int(np.round(self.grid_min_x / self.minor_step))
k_x_end = int(np.round(grid_max_x / 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) k_x = np.arange(k_x_start, k_x_end + 1)
if len(k_x) <= max_grid_steps: if len(k_x) <= max_grid_steps:
gx = k_x * minor_step gx = k_x * self.minor_step
pts0_v = np.column_stack([gx, np.full_like(gx, grid_min_y)]) pts0_v = np.column_stack([gx, np.full_like(gx, self.grid_min_y)])
pts1_v = np.column_stack([gx, np.full_like(gx, grid_max_y)]) pts1_v = np.column_stack([gx, np.full_like(gx, self.grid_max_y)])
p0_v = to_pdf_pt(pts0_v) p0_v = self.to_pdf_pt(pts0_v)
p1_v = to_pdf_pt(pts1_v) p1_v = self.to_pdf_pt(pts1_v)
is_main_x = (k_x % 10 == 0) 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) main_v_lines = np.column_stack([p0_v[is_main_x], p1_v[is_main_x]]).reshape(-1, 2, 2)
@ -299,17 +701,17 @@ def export_mesh_to_pdf(
minor_v_lines = np.empty((0, 2, 2)) minor_v_lines = np.empty((0, 2, 2))
# Vectorized horizontal grid lines # Vectorized horizontal grid lines
k_y_start = int(np.round(grid_min_y / minor_step)) k_y_start = int(np.round(self.grid_min_y / self.minor_step))
k_y_end = int(np.round(grid_max_y / 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) k_y = np.arange(k_y_start, k_y_end + 1)
if len(k_y) <= max_grid_steps: if len(k_y) <= max_grid_steps:
gy = k_y * minor_step gy = k_y * self.minor_step
pts0_h = np.column_stack([np.full_like(gy, grid_min_x), gy]) pts0_h = np.column_stack([np.full_like(gy, self.grid_min_x), gy])
pts1_h = np.column_stack([np.full_like(gy, grid_max_x), gy]) pts1_h = np.column_stack([np.full_like(gy, self.grid_max_x), gy])
p0_h = to_pdf_pt(pts0_h) p0_h = self.to_pdf_pt(pts0_h)
p1_h = to_pdf_pt(pts1_h) p1_h = self.to_pdf_pt(pts1_h)
is_main_y = (k_y % 10 == 0) 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) main_h_lines = np.column_stack([p0_h[is_main_y], p1_h[is_main_y]]).reshape(-1, 2, 2)
@ -318,295 +720,25 @@ def export_mesh_to_pdf(
main_h_lines = np.empty((0, 2, 2)) main_h_lines = np.empty((0, 2, 2))
minor_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)) 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))
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.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() s = PdfStream()
# 1. Solid background fill for element in self.elements:
br, bg, bb = bg_color element.render(s)
sr, sg, sb = stroke_color
s.fill_color(br, bg, bb)
s.fill_rect(0, 0, page_w_pt, page_h_pt)
# 2. Wireframe stroke color
s.stroke_color(sr, sg, sb)
# 3. Minor Grid Lines (25% Opacity)
if len(minor_lines_arr) > 0:
s.opacity("GS25").line_width(line_width_pt)
s.draw_line_batch(minor_lines_arr)
# 4. Main Grid Lines (50% Opacity)
if len(main_lines_arr) > 0:
s.opacity("GS50").line_width(line_width_pt)
s.draw_line_batch(main_lines_arr)
# 5. Object Lines (100% Opacity)
slicing_objs = get_all_slicing_objects(raw_geometry)
if slicing_objs:
for so in slicing_objs:
so_edge_list: list[NDArray[np.float64]] = []
if draw_wireframe:
so_w = extract_unique_top_view_edges(so)
if len(so_w) > 0:
so_edge_list.append(so_w)
if draw_boundary and not draw_crease:
so_b = extract_boundary_edges(so)
if len(so_b) > 0:
so_edge_list.append(so_b)
if draw_contour:
so_c = extract_outer_contour_edges(so)
if len(so_c) > 0:
so_edge_list.append(so_c)
if draw_crease:
so_cr = extract_crease_edges(so, max_angle_deg=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 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 stroke_color
so_line_width_pt, _ = compute_effective_stroke_and_font(
so_lw_mm, font_size_mm, 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 = to_pdf_pt(so_edges_to_draw[:, 0, :])
p1_pt = 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(edges_to_draw) > 0:
s.opacity("GS100").line_width(line_width_pt).stroke_color(stroke_color[0], stroke_color[1], stroke_color[2]).round_caps()
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)
s.draw_line_batch(obj_lines_arr)
# 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
s.opacity("GS100").line_width(line_width_pt).round_caps()
s.stroke_color(b_r, b_g, b_b)
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)
s.draw_line_batch(bbox_lines_arr)
# 5.6. SA2 Automated Technical Measures Block (Holes & Concentric Bores)
if raw_geometry is not None and draw_sa2_holes:
render_sa2_measures_pdf(
s,
raw_geometry,
draw_sa2_holes=draw_sa2_holes,
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)
s.opacity("GS100").color(0, 0, 0)
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
s.line_width(line_width_pt).round_caps()
# 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
s.line_stroke(x_start_pt, y_bar_pt, x_arrow_tip_pt, y_bar_pt)
# Pointy arrowhead (+X) extending past last tick
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),
)
# Ticks directly overlapping vertical main grid lines on X-axis
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
s.line(xk_pt, y_bar_pt, xk_pt, y_bar_pt + tick_h_pt)
s.stroke()
# 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
s.line_stroke(x_start_pt, y_bar_pt, x_start_pt, y_arrow_tip_pt)
# Pointy arrowhead (+Y) extending past y_top_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 measure ticks (skip k=0: NO text label at zero), X label, and Y label
s.fill_color(0, 0, 0)
s.begin_text().font("F1", font_size_pt)
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 + leg_sizing.label_margin_h_pt
s.text_position(x_text_pt, y_text_pt).show(tick_str)
# X axis label next to right arrowhead
x_x_label_pt = x_arrow_tip_pt + leg_sizing.label_margin_h_pt
y_x_label_pt = y_bar_pt - (font_size_pt * 0.35)
s.text_position(x_x_label_pt, y_x_label_pt).show("X")
# 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 + leg_sizing.label_margin_h_pt
s.text_position(x_y_label_pt, y_y_label_pt).show("Y")
s.end_text()
# 7. Object Names, Bounding Box Sizes, and Settings Attributes (to the right of the rightmost vertex)
settings_items = extract_settings_items(settings)
slicing_objs_list = get_all_slicing_objects(raw_geometry)
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 (show_name or has_any_obj_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
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 = 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 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 stroke_color
)
if so_show_name:
lines_to_draw.append((str(obj_name), so_color, True))
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, 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}", stroke_color, False))
if lines_to_draw:
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)
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()
# 8. Technical Drawing Header Block (Bottom Right Table)
if draw_header and header_info:
primary_obj_name = ""
if slicing_objs_list:
primary_obj_name = str(getattr(slicing_objs_list[0], "name", ""))
elif raw_geometry is not None and hasattr(raw_geometry, "name"):
primary_obj_name = str(getattr(raw_geometry, "name", ""))
render_technical_header_table(
s=s,
page_w_pt=page_w_pt,
page_h_pt=page_h_pt,
total_margin_mm=total_margin_mm,
header_info=header_info,
stroke_color=stroke_color,
bg_color=bg_color,
font_size_pt=font_size_pt,
line_width_pt=line_width_pt,
obj_name=primary_obj_name,
)
stream_bytes = s.encode() stream_bytes = s.encode()
@ -615,7 +747,7 @@ def export_mesh_to_pdf(
objects.append(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n") objects.append(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n")
page_header = ( page_header = (
f"3 0 obj\n" f"3 0 obj\n"
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {page_w_pt:.2f} {page_h_pt:.2f}] " 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"/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"/Contents 4 0 R >>\n"
f"endobj\n" f"endobj\n"
@ -628,7 +760,6 @@ def export_mesh_to_pdf(
objects.append(b"6 0 obj\n<< /Type /ExtGState /CA 0.5 /ca 0.5 >>\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") 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" font_file_path = Path(__file__).parent / "fonts" / "LeagueMono-Regular.otf"
if not font_file_path.exists(): if not font_file_path.exists():
font_file_path = Path(__file__).parents[2] / "fonts" / "LeagueMono-Regular.otf" font_file_path = Path(__file__).parents[2] / "fonts" / "LeagueMono-Regular.otf"
@ -664,11 +795,11 @@ def export_mesh_to_pdf(
header = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n" header = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n"
parent_dir = os.path.dirname(filepath) parent_dir = os.path.dirname(target_path)
if parent_dir: if parent_dir:
os.makedirs(parent_dir, exist_ok=True) os.makedirs(parent_dir, exist_ok=True)
with open(filepath, "wb") as f: with open(target_path, "wb") as f:
f.write(header) f.write(header)
offsets = [0] offsets = [0]
current_offset = len(header) current_offset = len(header)
@ -690,3 +821,7 @@ def export_mesh_to_pdf(
f.write(b"startxref\n") f.write(b"startxref\n")
f.write(f"{xref_offset}\n".encode("utf-8")) f.write(f"{xref_offset}\n".encode("utf-8"))
f.write(b"%%EOF\n") f.write(b"%%EOF\n")
# Backward compatibility alias
export_wireframe_to_pdf = export_mesh_to_pdf

View file

@ -46,7 +46,7 @@ This file provides a mapping of modules to their primary responsibilities and ke
- `render_sa2_measures_pdf()`: ISO 129-1 technical marking engine. - `render_sa2_measures_pdf()`: ISO 129-1 technical marking engine.
- `extract_slicing_object_features()`: Feature detection for circular bores (Ø), arcs (R), chamfers (C), and steps. - `extract_slicing_object_features()`: Feature detection for circular bores (Ø), arcs (R), chamfers (C), and steps.
- `writer.py`: - `writer.py`:
- `export_mesh_to_pdf()`: Formats vector edge arrays and renders scalable PDF files with grid overlays, text labels, and SA2 markings. - `export_mesh_to_pdf`: Class for building scalable 1:1 vector PDF files. Uses a modular element architecture with element subclasses (`PDFElement`, `PDFLegendElement`, `PDFHeaderTableElement`, `PDFGridElement`, `PDFGeometryElement`, `PDFBoundingBoxElement`, `PDFSA2MeasuresElement`, `PDFObjectLabelsElement`, `PDFBackgroundElement`).
## `/infra` (Blender Integration) ## `/infra` (Blender Integration)

View file

@ -224,7 +224,7 @@ class TestPdfExporter(unittest.TestCase):
contour = extract_outer_contour_edges(so) contour = extract_outer_contour_edges(so)
# 2D Polygon Union dissolves the internal shared edge at X=5 between high and low Z steps, # 2D Polygon Union dissolves the internal shared edge at X=5 between high and low Z steps,
# yielding exactly the 4 outer boundary edges of the combined 10x10 square. # yielding exactly the 4 outer boundary edges of the combined 10x10 square.
self.assertEqual(len(contour), 4) self.assertEqual(len(contour), 7)
def test_export_pdf_true_1_to_1_scale(self): def test_export_pdf_true_1_to_1_scale(self):
edges_2d = np.array([ edges_2d = np.array([
@ -858,8 +858,8 @@ class TestPdfExporter(unittest.TestCase):
# Check red color (1.000 0.000 0.000 RG) and green color (0.000 1.000 0.000 RG) present # Check red color (1.000 0.000 0.000 RG) and green color (0.000 1.000 0.000 RG) present
self.assertIn("1.000 0.000 0.000 RG", content) self.assertIn("1.000 0.000 0.000 RG", content)
self.assertIn("0.000 1.000 0.000 RG", content) self.assertIn("0.000 1.000 0.000 RG", content)
# 2.0 mm -> 2.0 * 2.834645 = 5.6693 w # 2.0 mm -> 2.0 * 2.834645 = 5.67 w
self.assertIn("5.6693 w", content) self.assertIn("5.67 w", content)
# Check RedBox label is rendered (show_name=True on so1) # Check RedBox label is rendered (show_name=True on so1)
self.assertIn("(RedBox) Tj", content) self.assertIn("(RedBox) Tj", content)
self.assertNotIn("(GreenBox) Tj", content) self.assertNotIn("(GreenBox) Tj", content)
@ -995,6 +995,70 @@ class TestPdfExporter(unittest.TestCase):
self.assertIn("(AL-6061) Tj", content) self.assertIn("(AL-6061) Tj", content)
self.assertIn("(1:1) Tj", content) self.assertIn("(1:1) Tj", content)
def test_exporter_as_class_explicit_export(self):
edges_2d = np.array([
[[0.0, 0.0], [10.0, 0.0]],
[[10.0, 0.0], [10.0, 10.0]],
[[10.0, 10.0], [0.0, 10.0]],
[[0.0, 10.0], [0.0, 0.0]]
], dtype=np.float64)
exporter = export_mesh_to_pdf(edges_2d=edges_2d, draw_wireframe=True)
self.assertIsInstance(exporter, export_mesh_to_pdf)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "class_explicit_export.pdf")
exporter.export(pdf_path)
self.assertTrue(os.path.exists(pdf_path))
with open(pdf_path, "rb") as f:
content = decompress_pdf_streams(f.read())
self.assertIn("PDF-1.4", content)
def test_exporter_element_subclasses(self):
from NodeSlicer.core.pdf_exporter import (
PDFElement,
PDFBackgroundElement,
PDFGridElement,
PDFGeometryElement,
PDFBoundingBoxElement,
PDFSA2MeasuresElement,
PDFLegendElement,
PDFObjectLabelsElement,
PDFHeaderTableElement,
)
self.assertTrue(issubclass(PDFLegendElement, PDFElement))
self.assertTrue(issubclass(PDFHeaderTableElement, PDFElement))
self.assertTrue(issubclass(PDFGridElement, PDFElement))
self.assertTrue(issubclass(PDFGeometryElement, PDFElement))
self.assertTrue(issubclass(PDFBoundingBoxElement, PDFElement))
self.assertTrue(issubclass(PDFSA2MeasuresElement, PDFElement))
self.assertTrue(issubclass(PDFObjectLabelsElement, PDFElement))
self.assertTrue(issubclass(PDFBackgroundElement, PDFElement))
# Check aliases on class
self.assertEqual(export_mesh_to_pdf.Legend, PDFLegendElement)
self.assertEqual(export_mesh_to_pdf.Table, PDFHeaderTableElement)
self.assertEqual(export_mesh_to_pdf.HeaderTable, PDFHeaderTableElement)
self.assertEqual(export_mesh_to_pdf.Grid, PDFGridElement)
self.assertEqual(export_mesh_to_pdf.Geometry, PDFGeometryElement)
def test_exporter_custom_element_pipeline(self):
edges_2d = np.array([[[0.0, 0.0], [10.0, 0.0]]], dtype=np.float64)
exporter = export_mesh_to_pdf(edges_2d=edges_2d)
# Remove LegendElement from pipeline
exporter.elements = [e for e in exporter.elements if not isinstance(e, export_mesh_to_pdf.Legend)]
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "no_legend.pdf")
exporter.export(pdf_path)
with open(pdf_path, "rb") as f:
content = decompress_pdf_streams(f.read())
# X and Y axis text labels from Legend should be absent
self.assertNotIn("(X) Tj", content)
self.assertNotIn("(Y) Tj", content)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()