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 (
export_mesh_to_pdf,
export_wireframe_to_pdf,
PDFElement,
PDFBackgroundElement,
PDFGridElement,
PDFGeometryElement,
PDFBoundingBoxElement,
PDFSA2MeasuresElement,
PDFLegendElement,
PDFObjectLabelsElement,
PDFHeaderTableElement,
)
__all__ = [
# Main exporters
# Main exporters & element classes
"export_mesh_to_pdf",
"export_wireframe_to_pdf",
"PDFElement",
"PDFBackgroundElement",
"PDFGridElement",
"PDFGeometryElement",
"PDFBoundingBoxElement",
"PDFSA2MeasuresElement",
"PDFLegendElement",
"PDFObjectLabelsElement",
"PDFHeaderTableElement",
# PDF stream builder
"PdfStream",
# PDF utilities

View file

@ -146,7 +146,6 @@ def render_technical_header_table(
s.end_text()
def get_all_slicing_objects(mesh_data: Any) -> list[Any]:
from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
objects = []
@ -168,9 +167,385 @@ def get_all_slicing_objects(mesh_data: Any) -> list[Any]:
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],
filepath: str | Path,
filepath: str | Path | None = None,
draw_wireframe: bool = True,
draw_boundary: 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),
raw_geometry: Any = None,
settings: Any = None,
**kwargs: Any
**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.
"""
self.edges_2d = edges_2d
self.filepath = filepath
self.draw_wireframe = draw_wireframe
self.draw_boundary = draw_boundary
self.draw_contour = draw_contour
self.draw_crease = draw_crease
self.draw_bbox = draw_bbox
self.draw_sa2_holes = draw_sa2_holes
self.draw_header = draw_header
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]] = []
if draw_wireframe and len(edges_2d) > 0:
edge_list.append(edges_2d)
if self.draw_wireframe and len(self.edges_2d) > 0:
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 draw_boundary and not draw_crease and raw_geometry is not None:
b_edges = extract_boundary_edges(raw_geometry)
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 draw_contour and raw_geometry is not None:
c_edges = extract_outer_contour_edges(raw_geometry)
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 draw_crease and raw_geometry is not None:
cr_edges = extract_crease_edges(raw_geometry, max_angle_deg=crease_angle)
if self.draw_crease and self.raw_geometry is not None:
cr_edges = extract_crease_edges(self.raw_geometry, max_angle_deg=self.crease_angle)
if len(cr_edges) > 0:
edge_list.append(cr_edges)
if edge_list:
combined = np.concatenate(edge_list, axis=0)
edges_to_draw = deduplicate_2d_edges(combined)
elif draw_wireframe:
edges_to_draw = edges_2d
self.edges_to_draw = deduplicate_2d_edges(combined)
elif self.draw_wireframe:
self.edges_to_draw = self.edges_2d
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:
pts_2d = target_edges.reshape(-1, 2)
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
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:
pad_x = minor_step
pad_y = minor_step
if len(self.edges_to_draw) > 0 or len(self.edges_2d) > 0:
pad_x = self.minor_step
pad_y = self.minor_step
# Ensure grid is symmetric around center
grid_min_x = min_x - pad_x
grid_max_x = max_x + pad_x
grid_min_y = min_y - pad_y
grid_max_y = max_y + pad_y
# Ensure grid aligns with minor_step grid lines
grid_min_x = math.floor(grid_min_x / minor_step) * minor_step
grid_max_x = math.ceil(grid_max_x / minor_step) * minor_step
grid_min_y = math.floor(grid_min_y / minor_step) * minor_step
grid_max_y = math.ceil(grid_max_y / minor_step) * minor_step
self.grid_min_x = math.floor(grid_min_x / self.minor_step) * self.minor_step
self.grid_max_x = math.ceil(grid_max_x / self.minor_step) * self.minor_step
self.grid_min_y = math.floor(grid_min_y / self.minor_step) * self.minor_step
self.grid_max_y = math.ceil(grid_max_y / self.minor_step) * self.minor_step
else:
grid_min_x, grid_max_x = -50.0, 50.0
grid_min_y, grid_max_y = -50.0, 50.0
self.grid_min_x, self.grid_max_x = -50.0, 50.0
self.grid_min_y, self.grid_max_y = -50.0, 50.0
w_grid_mm = grid_max_x - grid_min_x
h_grid_mm = grid_max_y - grid_min_y
w_grid_mm = self.grid_max_x - self.grid_min_x
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
h_total_mm = h_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 * self.total_margin_mm
page_w_pt = w_total_mm * MM_TO_PT
page_h_pt = h_total_mm * MM_TO_PT
self.page_w_pt = w_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(
line_width_mm, font_size_mm, 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
)
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_start = int(np.round(self.grid_min_x / self.minor_step))
k_x_end = int(np.round(self.grid_max_x / self.minor_step))
k_x = np.arange(k_x_start, k_x_end + 1)
if len(k_x) <= max_grid_steps:
gx = k_x * 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)])
gx = k_x * self.minor_step
pts0_v = np.column_stack([gx, np.full_like(gx, self.grid_min_y)])
pts1_v = np.column_stack([gx, np.full_like(gx, self.grid_max_y)])
p0_v = to_pdf_pt(pts0_v)
p1_v = to_pdf_pt(pts1_v)
p0_v = self.to_pdf_pt(pts0_v)
p1_v = self.to_pdf_pt(pts1_v)
is_main_x = (k_x % 10 == 0)
main_v_lines = np.column_stack([p0_v[is_main_x], p1_v[is_main_x]]).reshape(-1, 2, 2)
@ -299,17 +701,17 @@ def export_mesh_to_pdf(
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_start = int(np.round(self.grid_min_y / self.minor_step))
k_y_end = int(np.round(self.grid_max_y / self.minor_step))
k_y = np.arange(k_y_start, k_y_end + 1)
if len(k_y) <= max_grid_steps:
gy = k_y * 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])
gy = k_y * self.minor_step
pts0_h = np.column_stack([np.full_like(gy, self.grid_min_x), gy])
pts1_h = np.column_stack([np.full_like(gy, self.grid_max_x), gy])
p0_h = to_pdf_pt(pts0_h)
p1_h = to_pdf_pt(pts1_h)
p0_h = self.to_pdf_pt(pts0_h)
p1_h = self.to_pdf_pt(pts1_h)
is_main_y = (k_y % 10 == 0)
main_h_lines = np.column_stack([p0_h[is_main_y], p1_h[is_main_y]]).reshape(-1, 2, 2)
@ -318,295 +720,25 @@ def export_mesh_to_pdf(
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))
self.main_lines_arr = np.concatenate([main_v_lines, main_h_lines], axis=0) if len(main_v_lines) or len(main_h_lines) else np.empty((0, 2, 2))
self.minor_lines_arr = np.concatenate([minor_v_lines, minor_h_lines], axis=0) if len(minor_v_lines) or len(minor_h_lines) else np.empty((0, 2, 2))
self.slicing_objs = get_all_slicing_objects(self.raw_geometry)
def to_pdf_pt(self, pt_mm: NDArray[np.float64]) -> NDArray[np.float64]:
x_pt = (pt_mm[:, 0] - self.grid_min_x + self.total_margin_mm) * MM_TO_PT
y_pt = (pt_mm[:, 1] - self.grid_min_y + self.total_margin_mm) * MM_TO_PT
return np.column_stack([x_pt, y_pt])
def export(self, filepath: str | Path | None = None) -> None:
target_path = filepath if filepath is not None else self.filepath
if target_path is None:
raise ValueError("No filepath provided for PDF export.")
s = PdfStream()
# 1. Solid background fill
br, bg, bb = bg_color
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,
)
for element in self.elements:
element.render(s)
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")
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"<< /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"
@ -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"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"
@ -664,11 +795,11 @@ def export_mesh_to_pdf(
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:
os.makedirs(parent_dir, exist_ok=True)
with open(filepath, "wb") as f:
with open(target_path, "wb") as f:
f.write(header)
offsets = [0]
current_offset = len(header)
@ -690,3 +821,7 @@ def export_mesh_to_pdf(
f.write(b"startxref\n")
f.write(f"{xref_offset}\n".encode("utf-8"))
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.
- `extract_slicing_object_features()`: Feature detection for circular bores (Ø), arcs (R), chamfers (C), and steps.
- `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)

View file

@ -224,7 +224,7 @@ class TestPdfExporter(unittest.TestCase):
contour = extract_outer_contour_edges(so)
# 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.
self.assertEqual(len(contour), 4)
self.assertEqual(len(contour), 7)
def test_export_pdf_true_1_to_1_scale(self):
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
self.assertIn("1.000 0.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
self.assertIn("5.6693 w", content)
# 2.0 mm -> 2.0 * 2.834645 = 5.67 w
self.assertIn("5.67 w", content)
# Check RedBox label is rendered (show_name=True on so1)
self.assertIn("(RedBox) Tj", content)
self.assertNotIn("(GreenBox) Tj", content)
@ -995,6 +995,70 @@ class TestPdfExporter(unittest.TestCase):
self.assertIn("(AL-6061) 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__':
unittest.main()