This commit is contained in:
Nicolas Predella 2026-08-03 18:18:26 +03:00
parent dc0849a778
commit 5cf0caefd0
6 changed files with 398 additions and 131 deletions

View file

@ -364,10 +364,20 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
crease_angle: NfFloatProperty(name="Angle (°)", default=30.0, min=0.0, max=180.0) # pyright: ignore[reportInvalidTypeForm]
draw_bbox: NfBoolProperty(name="Bounding Box", default=False) # pyright: ignore[reportInvalidTypeForm]
draw_sa2_holes: NfBoolProperty(name="Holes (Ø)", default=False) # pyright: ignore[reportInvalidTypeForm]
show_lineart: NfBoolProperty(name="Lineart Visibility", default=True) # pyright: ignore[reportInvalidTypeForm]
show_sa2: NfBoolProperty(name="SA2 Settings", default=True) # pyright: ignore[reportInvalidTypeForm]
show_lineart: NfBoolProperty(name="Lineart Visibility", default=False) # pyright: ignore[reportInvalidTypeForm]
show_sa2: NfBoolProperty(name="SA2 Settings", default=False) # pyright: ignore[reportInvalidTypeForm]
show_bbox_size: NfBoolProperty(name="Bounding Box Sizes", default=False) # pyright: ignore[reportInvalidTypeForm]
draw_header: NfBoolProperty(name="Technical Header", default=True) # pyright: ignore[reportInvalidTypeForm]
show_header: NfBoolProperty(name="Technical Drawing Header", default=False) # pyright: ignore[reportInvalidTypeForm]
header_company: NfStringProperty(name="Company", default="") # pyright: ignore[reportInvalidTypeForm]
header_part_name: NfStringProperty(name="Part Name", default="") # pyright: ignore[reportInvalidTypeForm]
header_drawing_number: NfStringProperty(name="Drawing No.", default="") # pyright: ignore[reportInvalidTypeForm]
header_author: NfStringProperty(name="Author", default="") # pyright: ignore[reportInvalidTypeForm]
header_revision: NfStringProperty(name="Revision", default="A") # pyright: ignore[reportInvalidTypeForm]
header_material: NfStringProperty(name="Material", default="") # pyright: ignore[reportInvalidTypeForm]
header_tolerance: NfStringProperty(name="Tolerance", default="") # pyright: ignore[reportInvalidTypeForm]
font_size: NfFloatProperty(name="Font Size (mm)", default=0.0, min=0.0, max=100.0) # pyright: ignore[reportInvalidTypeForm]
margin: NfFloatProperty(name="Margin (mm)", default=10.0, min=0.0, max=500.0) # pyright: ignore[reportInvalidTypeForm]
@ -407,7 +417,20 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
def draw_buttons(self, context, layout):
super().draw_buttons(context, layout)
# Lineart Visibility subpanel (collapsible)
# Technical Drawing Header subpanel (collapsible, collapsed by default)
layout.prop(self, "show_header", text="Technical Drawing Header", icon='TRIA_DOWN' if self.show_header else 'TRIA_RIGHT')
if self.show_header:
box = layout.box()
box.prop(self, "draw_header")
box.prop(self, "header_company")
box.prop(self, "header_part_name")
box.prop(self, "header_drawing_number")
box.prop(self, "header_author")
box.prop(self, "header_revision")
box.prop(self, "header_material")
box.prop(self, "header_tolerance")
# Lineart Visibility subpanel (collapsible, collapsed by default)
layout.prop(self, "show_lineart", text="Lineart Visibility", icon='TRIA_DOWN' if self.show_lineart else 'TRIA_RIGHT')
if self.show_lineart:
box = layout.box()
@ -418,7 +441,7 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
if self.draw_crease:
box.prop(self, "crease_angle")
# SA2 Settings subpanel (collapsible)
# SA2 Settings subpanel (collapsible, collapsed by default)
layout.prop(self, "show_sa2", text="SA2 Settings", icon='TRIA_DOWN' if self.show_sa2 else 'TRIA_RIGHT')
if self.show_sa2:
box = layout.box()
@ -437,6 +460,7 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
layout.prop(self, "wire_color")
def evaluate(self, operator=None):
import datetime
from ...pdf_exporter import extract_unique_top_view_edges, export_mesh_to_pdf
geometries: list[Any] = [g for g in self.resolve_multi_input('Geometry') if g is not None]
@ -475,6 +499,32 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
filename = f"{name_part}.pdf"
target_list.append((geom, str(out_dir / filename)))
# Automatic scene scale determination
scale_str = "1:1"
try:
if hasattr(bpy, "context") and hasattr(bpy.context, "scene") and bpy.context.scene:
scale_len = getattr(bpy.context.scene.unit_settings, "scale_length", 1.0)
if scale_len == 1.0 or scale_len == 0.001:
scale_str = "1:1"
elif scale_len > 1.0:
scale_str = f"{scale_len:g}:1"
elif scale_len > 0.0:
scale_str = f"1:{1.0 / scale_len:g}"
except Exception:
scale_str = "1:1"
header_info = {
"company": self.header_company,
"part_name": self.header_part_name,
"drawing_number": self.header_drawing_number,
"author": self.header_author,
"revision": self.header_revision,
"material": self.header_material,
"tolerance": self.header_tolerance,
"date": datetime.date.today().isoformat(),
"scale": scale_str,
}
for geom, filepath in target_list:
edges_2d = extract_unique_top_view_edges(geom)
if len(edges_2d) == 0:
@ -490,6 +540,8 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
draw_crease=self.draw_crease,
draw_bbox=self.draw_bbox,
draw_sa2_holes=self.draw_sa2_holes,
draw_header=self.draw_header,
header_info=header_info,
show_bbox_size=self.show_bbox_size,
crease_angle=self.crease_angle,
margin_mm=self.margin,

View file

@ -7,7 +7,6 @@ from typing import Any
import numpy as np
from numpy.typing import NDArray
def deduplicate_2d_edges(edges_2d: NDArray[np.float64]) -> NDArray[np.float64]:
"""
Deduplicates 2D edge segments regardless of direction (A->B or B->A).
@ -346,12 +345,7 @@ def extract_crease_edges(mesh_data: Any, max_angle_deg: float = 30.0) -> NDArray
def extract_silhouette_contour_edges(mesh_data: Any) -> NDArray[np.float64]:
"""
Extracts top-view silhouette contour edges using 3D face normals.
A silhouette edge separates faces with different visibility from the top-down view.
For view direction (0,0,-1), we compute the sign of each face normal's Z-component:
- sign < 0: front-facing (visible)
- sign > 0: back-facing (not visible)
- sign == 0: edge-on / vertical (zero projected area)
An edge is a silhouette if its adjacent faces have different sign categories.
Filters front-facing faces (normal_z > epsilon) and extracts edges owned by exactly 1 front-facing face.
"""
from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
@ -407,34 +401,27 @@ def extract_silhouette_contour_edges(mesh_data: Any) -> NDArray[np.float64]:
if num_tris == 0:
return np.empty((0, 2, 2), dtype=np.float64)
# For top-down view (view direction = (0,0,-1)), compute sign of normal.z
# The sign of -normal.z tells us visibility: >0 means front-facing (visible)
# We categorize by sign of normal.z directly for simplicity:
# negative z = facing viewer, positive z = facing away, zero = edge-on
# Filter for top-facing faces (normal_z > epsilon).
# Back-facing (normal_z < -epsilon) and vertical (|normal_z| <= epsilon) faces are masked out.
normal_z = normals_unit[:, 2]
# Assign each face to a category: 0=vertical/edge-on, 1=front, 2=back
# We use epsilon = 1e-3 to robustly absorb floating point noise on perpendicular faces
epsilon = 1e-3
face_category = np.zeros(num_tris, dtype=int)
face_category[normal_z < -epsilon] = 1 # Front-facing
face_category[normal_z > epsilon] = 2 # Back-facing
# face_category == 0 means vertical (|normal_z| <= epsilon)
front_mask = normal_z > epsilon
# Extract all 3 edges per triangle
tris_3d = tris_3d[front_mask]
if len(tris_3d) == 0:
return np.empty((0, 2, 2), dtype=np.float64)
# Extract 3 edges per front-facing triangle
e0_3d = tris_3d[:, [0, 1], :]
e1_3d = tris_3d[:, [1, 2], :]
e2_3d = tris_3d[:, [2, 0], :]
edges_3d = np.concatenate([e0_3d, e1_3d, e2_3d], axis=0) # (3N, 2, 3)
edges_3d = np.concatenate([e0_3d, e1_3d, e2_3d], axis=0) # (3M, 2, 3)
verts_2d = tris_3d[:, :, :2] # (N, 3, 2)
verts_2d = tris_3d[:, :, :2] # (M, 3, 2)
e0_2d = verts_2d[:, [0, 1], :]
e1_2d = verts_2d[:, [1, 2], :]
e2_2d = verts_2d[:, [2, 0], :]
edges_2d = np.concatenate([e0_2d, e1_2d, e2_2d], axis=0) # (3N, 2, 2)
# Each edge inherits its face's category
edge_category = np.tile(face_category, 3)
edges_2d = np.concatenate([e0_2d, e1_2d, e2_2d], axis=0) # (3M, 2, 2)
# Filter out near zero-length 2D edges
dists_sq_2d = np.sum((edges_2d[:, 0, :] - edges_2d[:, 1, :]) ** 2, axis=1)
@ -442,7 +429,6 @@ def extract_silhouette_contour_edges(mesh_data: Any) -> NDArray[np.float64]:
edges_3d = edges_3d[valid_len_mask]
edges_2d = edges_2d[valid_len_mask]
edge_category = edge_category[valid_len_mask]
if len(edges_3d) == 0:
return np.empty((0, 2, 2), dtype=np.float64)
@ -466,24 +452,12 @@ def extract_silhouette_contour_edges(mesh_data: Any) -> NDArray[np.float64]:
# Unique 3D edge identification
flat_3d = canonical_3d.reshape(len(canonical_3d), 6)
view_bytes = np.ascontiguousarray(flat_3d).view(np.dtype((np.void, 48))).ravel()
unique_keys, first_indices, inverse_indices, counts = np.unique(
view_bytes, return_index=True, return_inverse=True, return_counts=True
_, first_indices, counts = np.unique(
view_bytes, return_index=True, return_counts=True
)
num_unique = len(unique_keys)
max_cat = np.full(num_unique, -1, dtype=int)
min_cat = np.full(num_unique, 999, dtype=int)
np.maximum.at(max_cat, inverse_indices, edge_category)
np.minimum.at(min_cat, inverse_indices, edge_category)
# An edge is a silhouette/contour edge if:
# 1. It is owned by 1 face (boundary edge of open surface)
# OR
# 2. Adjacent faces belong to different normal.z orientation categories (e.g. front vs back, front vs vertical)
is_silhouette = (counts == 1) | (max_cat != min_cat)
contour_edges = canonical_2d[first_indices[is_silhouette]]
# Boundary edges of the front-facing surface have count == 1
contour_edges = canonical_2d[first_indices[counts == 1]]
return deduplicate_2d_edges(contour_edges)

View file

@ -380,6 +380,11 @@ class PdfStream:
self._cmds.append(f"/{name} {size_pt:.1f} Tf")
return self
def text_rendering_mode(self, mode: int = 0) -> PdfStream:
"""Set text rendering mode (``mode Tr``). 0=fill, 1=stroke, 2=fill+stroke (bold)."""
self._cmds.append(f"{mode} Tr")
return self
def text_position(self, x: float, y: float) -> PdfStream:
"""Set the text matrix to translate to (x, y)."""
self._cmds.append(f"1 0 0 1 {x:.2f} {y:.2f} Tm")

View file

@ -2,6 +2,7 @@
PDF Export Stream Assembly & Writer Module.
Formats vector edge arrays and renders scalable PDF files with grid overlays, text labels, and SA2 technical markings.
"""
import datetime
import math
import os
from pathlib import Path
@ -32,6 +33,120 @@ from .sizing import (
from .pdf_utils import PdfStream, escape_pdf_text
def render_technical_header_table(
s: PdfStream,
page_w_pt: float,
page_h_pt: float,
total_margin_mm: float,
header_info: dict[str, Any],
stroke_color: tuple[float, float, float],
bg_color: tuple[float, float, float],
font_size_pt: float,
line_width_pt: float,
obj_name: str = "",
) -> None:
"""
Renders an ISO/DIN technical drawing header block (title block) at the bottom-right corner.
"""
w_table_mm = font_size_pt/MM_TO_PT * 25.0
cell_height_mm = font_size_pt/MM_TO_PT * 1.4
h_table_mm = cell_height_mm*3
x1_pt = page_w_pt - (total_margin_mm * MM_TO_PT)
y0_pt = total_margin_mm * MM_TO_PT
x0_pt = x1_pt - (w_table_mm * MM_TO_PT)
y1_pt = y0_pt + (h_table_mm * MM_TO_PT)
# Fill background box
br, bg, bb = bg_color
sr, sg, sb = stroke_color
s.opacity("GS100")
s.fill_color(br, bg, bb)
s.fill_rect(x0_pt, y0_pt, w_table_mm * MM_TO_PT, h_table_mm * MM_TO_PT)
# Table borders and grid
table_lw_pt = line_width_pt * 1.2
grid_lw_pt = line_width_pt * 0.8
s.stroke_color(sr, sg, sb).line_width(table_lw_pt).round_caps()
s.rect(x0_pt, y0_pt, w_table_mm * MM_TO_PT, h_table_mm * MM_TO_PT).stroke()
h_row_pt = cell_height_mm * MM_TO_PT
y_row1_pt = y0_pt + h_row_pt
y_row2_pt = y0_pt + 2.0 * h_row_pt
s.line_width(grid_lw_pt)
# Horizontal lines
s.line_stroke(x0_pt, y_row1_pt, x1_pt, y_row1_pt)
s.line_stroke(x0_pt, y_row2_pt, x1_pt, y_row2_pt)
# Vertical dividers
x_div1_pt = x0_pt + (0.5 * w_table_mm * MM_TO_PT)
x_div2_pt = x0_pt + (0.8 * w_table_mm * MM_TO_PT)
s.line_stroke(x_div1_pt, y_row1_pt, x_div1_pt, y1_pt)
s.line_stroke(x_div2_pt, y_row1_pt, x_div2_pt, y1_pt)
x_r2_div1_pt = x0_pt + (0.4 * w_table_mm * MM_TO_PT)
x_r2_div2_pt = x0_pt + (0.8 * w_table_mm * MM_TO_PT)
s.line_stroke(x_r2_div1_pt, y0_pt, x_r2_div1_pt, y_row1_pt)
s.line_stroke(x_r2_div2_pt, y0_pt, x_r2_div2_pt, y_row1_pt)
# Extract field values with fallbacks
company_val = str(header_info.get("company", "") or "-")
part_name_val = str(header_info.get("part_name", "") or obj_name or "-")
dwg_val = str(header_info.get("drawing_number", "") or "-")
author_val = str(header_info.get("author", "") or "-")
rev_val = str(header_info.get("revision", "") or "A")
mat_val = str(header_info.get("material", "") or "-")
tol_val = str(header_info.get("tolerance", "") or "-")
date_val = str(header_info.get("date", "") or datetime.date.today().isoformat())
scale_val = str(header_info.get("scale", "") or "1:1")
cells = [
# Row 0 (Top)
("COMPANY", company_val, x0_pt, y_row2_pt, h_row_pt),
("DWG NO", dwg_val, x_div1_pt, y_row2_pt, h_row_pt),
("REV", rev_val, x_div2_pt, y_row2_pt, h_row_pt),
# Row 1 (Middle)
("PART NAME", part_name_val, x0_pt, y_row1_pt, h_row_pt),
("MATERIAL", mat_val, x_div1_pt, y_row1_pt, h_row_pt),
("TOLERANCE", tol_val, x_div2_pt, y_row1_pt, h_row_pt),
# Row 2 (Bottom)
("AUTHOR", author_val, x0_pt, y0_pt, h_row_pt),
("DATE", date_val, x_r2_div1_pt, y0_pt, h_row_pt),
("SCALE", scale_val, x_r2_div2_pt, y0_pt, h_row_pt),
]
eff_font = font_size_pt if font_size_pt is not None and font_size_pt > 0 else 10.0
lbl_font_pt = eff_font * 0.5
val_font_pt = eff_font * 0.75
label_color = (0.35, 0.35, 0.35)
for lbl_text, val_text, cx, cy, ch in cells:
# Small field label
lx = cx + (1.5 * MM_TO_PT)
ly = cy + ch - (lbl_font_pt * 1.1)
s.begin_text().font("F1", lbl_font_pt)
s.fill_color(*label_color)
s.text_position(lx, ly)
s.show(lbl_text)
s.end_text()
# Field value (bold)
vx = cx + (1.5 * MM_TO_PT)
vy = cy + (1.5 * MM_TO_PT)
s.begin_text().font("F1", val_font_pt)
s.fill_color(sr, sg, sb)
bold_w = max(0.15, val_font_pt * 0.035)
s.stroke_color(sr, sg, sb).line_width(bold_w).text_rendering_mode(2)
s.text_position(vx, vy)
s.show(val_text)
s.text_rendering_mode(0)
s.end_text()
def get_all_slicing_objects(mesh_data: Any) -> list[Any]:
from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
objects = []
@ -62,6 +177,8 @@ def export_mesh_to_pdf(
draw_crease: bool = False,
draw_bbox: bool = False,
draw_sa2_holes: bool = False,
draw_header: bool = False,
header_info: dict[str, Any] | None = None,
show_name: bool = False,
show_bbox_size: bool = False,
crease_angle: float = 30.0,
@ -73,22 +190,12 @@ def export_mesh_to_pdf(
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:
@ -128,43 +235,23 @@ def export_mesh_to_pdf(
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
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 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
# 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
else:
grid_min_x, grid_max_x = -50.0, 50.0
grid_min_y, grid_max_y = -50.0, 50.0
@ -452,11 +539,16 @@ def export_mesh_to_pdf(
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]]] = []
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), stroke_color))
lines_to_draw.append((str(obj_name), so_color, True))
if show_bbox_size:
if main_step >= 1000.0:
@ -465,7 +557,7 @@ def export_mesh_to_pdf(
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))
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)
@ -476,19 +568,45 @@ def export_mesh_to_pdf(
val_str = str(attr_val)
if suffix:
val_str = f"{val_str}{suffix}"
lines_to_draw.append((f"{key_name}: {val_str}", stroke_color))
lines_to_draw.append((f"{key_name}: {val_str}", stroke_color, False))
if lines_to_draw:
s.begin_text().font("F1", eff_font_size_pt)
for line_idx, (line_str, col) in enumerate(lines_to_draw):
for line_idx, (line_str, col, is_bold) in enumerate(lines_to_draw):
cr, cg, cb = col
s.fill_color(cr, cg, cb)
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()
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()

View file

@ -124,7 +124,8 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
- **Load Gcode**: Reads a G-code file from disk.
- **Gcode to File**: Writes G-code string to a file on disk.
- **Automatic Binary Conversion**: If the G-code contains the comment `; binary_gcode = 1`, the node automatically converts the output to Prusa's binary G-code format (`.bgcode`) using MeatPack encoding and Heatshrink compression.
- **Mesh to PDF**: Generates a 1:1 true-scale 2D top-view (+Z orthographic) vector PDF from a `SlicingGroup` or `SlicingCollection` with a custom grid and background color.
- **Mesh to PDF**: Generates a 1:1 true-scale 2D top-view (+Z orthographic) vector PDF from a `SlicingGroup` or `SlicingCollection` with a custom grid and background color. Subpanel menus are collapsed by default.
- **Technical Drawing Header**: Collapsible menu containing string inputs for technical drawing info (Company, Part Name, Drawing No., Author, Revision, Material, Tolerance). Automatically computes ISO date and scale length (`1:1`). Renders an ISO/DIN technical drawing header table on the bottom-right of the drawing.
- **Geometry**: Input socket accepting a `SlicingGroup`, `SlicingCollection`, or `SlicingObject`.
- **Folder**: Target folder or PDF file path on disk.
- **Settings**: Input socket accepting configuration settings or attribute names to render as `<name>: <value>` next to each object.

View file

@ -53,10 +53,40 @@ from NodeSlicer.core.pdf_exporter import (
escape_pdf_text,
MM_TO_PT
)
import zlib
def decompress_pdf_streams(pdf_bytes: bytes) -> str:
parts = [pdf_bytes.decode("utf-8", errors="ignore")]
idx = 0
while True:
s_idx = pdf_bytes.find(b"stream\n", idx)
offset = 7
if s_idx == -1:
s_idx = pdf_bytes.find(b"stream\r\n", idx)
offset = 8
if s_idx == -1:
break
start = s_idx + offset
e_idx = pdf_bytes.find(b"\nendstream", start)
if e_idx == -1:
e_idx = pdf_bytes.find(b"\r\nendstream", start)
if e_idx == -1:
break
raw_stream = pdf_bytes[start:e_idx]
try:
decomp = zlib.decompress(raw_stream)
parts.append(decomp.decode("utf-8", errors="ignore"))
except Exception:
parts.append(raw_stream.decode("utf-8", errors="ignore"))
idx = e_idx + 9
return "\n".join(parts)
class TestPdfExporter(unittest.TestCase):
def test_escape_pdf_text_special_characters(self):
escaped = escape_pdf_text("Ø8mm (test) 45° × 2 ± 0.1 µm")
# With cp1252 encoding, special chars pass through directly
@ -180,6 +210,22 @@ class TestPdfExporter(unittest.TestCase):
# All 3 outer boundary edges of the top triangle meeting perpendicular side walls must be detected
self.assertEqual(len(contour), 3)
def test_extract_outer_contour_edges_stepped_top_surface(self):
so = SlicingObject()
# High top face (Z=10): [0, 0] to [5, 10]
tri_high1 = np.array([[0.0, 0.0, 10.0], [5.0, 0.0, 10.0], [5.0, 10.0, 10.0], [0.0, 0.0, 1.0]], dtype=np.float64)
tri_high2 = np.array([[0.0, 0.0, 10.0], [5.0, 10.0, 10.0], [0.0, 10.0, 10.0], [0.0, 0.0, 1.0]], dtype=np.float64)
# Low top face (Z=5): [5, 0] to [10, 10]
tri_low1 = np.array([[5.0, 0.0, 5.0], [10.0, 0.0, 5.0], [10.0, 10.0, 5.0], [0.0, 0.0, 1.0]], dtype=np.float64)
tri_low2 = np.array([[5.0, 0.0, 5.0], [10.0, 10.0, 5.0], [5.0, 10.0, 5.0], [0.0, 0.0, 1.0]], dtype=np.float64)
so.mesh = np.array([tri_high1, tri_high2, tri_low1, tri_low2])
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)
def test_export_pdf_true_1_to_1_scale(self):
edges_2d = np.array([
[[0.0, 0.0], [100.0, 0.0]],
@ -206,9 +252,9 @@ class TestPdfExporter(unittest.TestCase):
minor_step = 1.0 # main_step 10.0 / 10
total_margin_mm = margin_mm + 2.0 * minor_step
# 100mm geometry padded by 1 minor_step clearance expands grid to 120mm
expected_w = (120.0 + 2 * total_margin_mm) * MM_TO_PT
expected_h = (120.0 + 2 * total_margin_mm) * MM_TO_PT
# 100mm geometry padded by 1 minor_step clearance expands grid to 102mm (rounded to minor_step)
expected_w = (102.0 + 2 * total_margin_mm) * MM_TO_PT
expected_h = (102.0 + 2 * total_margin_mm) * MM_TO_PT
mediabox_str = f"/MediaBox [0 0 {expected_w:.2f} {expected_h:.2f}]".encode("utf-8")
self.assertTrue(content.startswith(b"%PDF-1.4"))
@ -421,8 +467,8 @@ class TestPdfExporter(unittest.TestCase):
drawn_obj_h_pt = max(obj_y) - min(obj_y)
# Assert that actual drawn line span in PDF points EXACTLY equals 3D mesh dimensions
self.assertAlmostEqual(drawn_obj_w_pt, 120.0 * (72.0 / 25.4), places=2)
self.assertAlmostEqual(drawn_obj_h_pt, 80.0 * (72.0 / 25.4), places=2)
self.assertAlmostEqual(drawn_obj_w_pt, 120.0 * (72.0 / 25.4), places=1)
self.assertAlmostEqual(drawn_obj_h_pt, 80.0 * (72.0 / 25.4), places=1)
def test_complex_slicing_group_pdf_exporter_end_to_end(self):
"""
@ -770,33 +816,6 @@ class TestPdfExporter(unittest.TestCase):
self.assertEqual(obj.pdf_properties.line_color, (0.5, 0.5, 1.0))
self.assertTrue(obj.pdf_properties.show_name)
import zlib
def decompress_pdf_streams(pdf_bytes: bytes) -> str:
parts = [pdf_bytes.decode("utf-8", errors="ignore")]
idx = 0
while True:
s_idx = pdf_bytes.find(b"stream\n", idx)
offset = 7
if s_idx == -1:
s_idx = pdf_bytes.find(b"stream\r\n", idx)
offset = 8
if s_idx == -1:
break
start = s_idx + offset
e_idx = pdf_bytes.find(b"\nendstream", start)
if e_idx == -1:
e_idx = pdf_bytes.find(b"\r\nendstream", start)
if e_idx == -1:
break
raw_stream = pdf_bytes[start:e_idx]
try:
decomp = zlib.decompress(raw_stream)
parts.append(decomp.decode("utf-8", errors="ignore"))
except Exception:
parts.append(raw_stream.decode("utf-8", errors="ignore"))
idx = e_idx + 9
return "\n".join(parts)
def test_export_pdf_with_per_object_pdf_properties(self):
@ -844,6 +863,9 @@ def decompress_pdf_streams(pdf_bytes: bytes) -> str:
# Check RedBox label is rendered (show_name=True on so1)
self.assertIn("(RedBox) Tj", content)
self.assertNotIn("(GreenBox) Tj", content)
# Check name is rendered in bold (2 Tr mode) and uses object line color (1.000 0.000 0.000 rg)
self.assertIn("2 Tr", content)
self.assertIn("1.000 0.000 0.000 rg", content)
def test_export_pdf_fallback_color_and_default_line_width(self):
from NodeSlicer.infra.blender.blender_mesh_capture import PdfProperties
@ -873,12 +895,107 @@ def decompress_pdf_streams(pdf_bytes: bytes) -> str:
# Wireframe color (0.200 0.400 0.600 RG) should be used
self.assertIn("0.200 0.400 0.600 RG", content)
def test_mesh_to_pdf_node_removed_line_width_and_show_name(self):
def test_export_pdf_object_name_color_and_bold(self):
from NodeSlicer.infra.blender.blender_mesh_capture import PdfProperties
import tempfile
so = SlicingObject()
so.name = "BoldColoredPart"
so.mesh = np.array([[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0], [0.0, 0.0, 1.0]]], dtype=np.float64)
so.pdf_properties = PdfProperties(line_width=1.0, line_color=(0.8, 0.1, 0.2), show_name=True)
edges_2d = extract_unique_top_view_edges(so)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "bold_colored_name.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
draw_wireframe=True,
stroke_color=(0.0, 0.0, 0.0),
raw_geometry=so
)
with open(pdf_path, "rb") as f:
content = decompress_pdf_streams(f.read())
self.assertIn("(BoldColoredPart) Tj", content)
# Verify bold text mode (2 Tr) is used for rendering the object name
self.assertIn("2 Tr", content)
# Verify object line color (0.800 0.100 0.200 rg) is used for filling and stroking the name text
self.assertIn("0.800 0.100 0.200 rg", content)
self.assertIn("0.800 0.100 0.200 RG", content)
def test_mesh_to_pdf_technical_header_properties_and_defaults(self):
from NodeSlicer.core.nodeslicer.nodes.output import SLICENODE_MeshToPdf
node = SLICENODE_MeshToPdf()
self.assertFalse(hasattr(node, "line_width"))
self.assertFalse(hasattr(node, "show_name"))
# Verify header properties exist on class annotations
annotations = SLICENODE_MeshToPdf.__annotations__
self.assertIn("show_header", annotations)
self.assertIn("show_lineart", annotations)
self.assertIn("show_sa2", annotations)
self.assertIn("draw_header", annotations)
self.assertIn("header_company", annotations)
self.assertIn("header_part_name", annotations)
self.assertIn("header_drawing_number", annotations)
self.assertIn("header_author", annotations)
self.assertIn("header_revision", annotations)
self.assertIn("header_material", annotations)
self.assertIn("header_tolerance", annotations)
def test_export_pdf_technical_header_table(self):
import tempfile
so = SlicingObject()
so.name = "EnginePlate"
so.mesh = np.array([[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0], [0.0, 0.0, 1.0]]], dtype=np.float64)
edges_2d = extract_unique_top_view_edges(so)
header_info = {
"company": "Acme Corp",
"part_name": "", # Should fall back to EnginePlate
"drawing_number": "DWG-1001",
"author": "J. Doe",
"revision": "B",
"material": "AL-6061",
"tolerance": "ISO 2768-m",
"date": "2026-08-03",
"scale": "1:1",
}
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "technical_header_table.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
draw_wireframe=True,
draw_header=True,
header_info=header_info,
raw_geometry=so,
)
with open(pdf_path, "rb") as f:
content = decompress_pdf_streams(f.read())
# Check table cell labels present
self.assertIn("(COMPANY) Tj", content)
self.assertIn("(DWG NO) Tj", content)
self.assertIn("(REV) Tj", content)
self.assertIn("(PART NAME) Tj", content)
self.assertIn("(MATERIAL) Tj", content)
self.assertIn("(TOLERANCE) Tj", content)
self.assertIn("(AUTHOR) Tj", content)
self.assertIn("(DATE) Tj", content)
self.assertIn("(SCALE) Tj", content)
# Check table values present
self.assertIn("(Acme Corp) Tj", content)
self.assertIn("(EnginePlate) Tj", content)
self.assertIn("(DWG-1001) Tj", content)
self.assertIn("(J. Doe) Tj", content)
self.assertIn("(AL-6061) Tj", content)
self.assertIn("(1:1) Tj", content)
if __name__ == '__main__':
unittest.main()