naming template

This commit is contained in:
Nicolas Predella 2026-08-31 14:42:45 +03:00
parent 31d34577e7
commit 926da63dd8
6 changed files with 314 additions and 27 deletions

View file

@ -328,6 +328,19 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
bl_label = "Mesh to PDF"
button_label = "Generate PDF"
template: str = NfStringProperty(
name="Template",
default="{object_name}-{size_x_mm}x{size_y_mm}-{today_date_iso}.pdf",
)
@property
def filename_template(self) -> str:
return getattr(self, "template", "{object_name}-{size_x_mm}x{size_y_mm}-{today_date_iso}.pdf")
@filename_template.setter
def filename_template(self, value: str) -> None:
self.template = value
draw_wireframe: bool = NfBoolProperty(name="Wireframe", default=True)
draw_boundary: bool = NfBoolProperty(name="Boundary (1 Face)", default=False)
draw_contour: bool = NfBoolProperty(name="Contour", default=False)
@ -392,6 +405,8 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
def draw_buttons(self, context, layout):
super().draw_buttons(context, layout)
layout.prop(self, "template")
# 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:
@ -443,7 +458,12 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
def evaluate(self, operator=None):
import datetime
from ...pdf_exporter import extract_unique_top_view_edges, export_mesh_to_pdf, get_first_object_name
from ...pdf_exporter import (
extract_unique_top_view_edges,
export_mesh_to_pdf,
format_pdf_filename_template,
get_first_object_name,
)
geom = self.resolve("Geometry")
slice_paths = self.resolve("Paths")
@ -485,25 +505,6 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
is_single_pdf_file = len(primary_items) == 1 and folder_path.suffix.lower() == ".pdf"
if is_single_pdf_file:
os.makedirs(folder_path.parent, exist_ok=True)
target_list = [(primary_items[0], str(folder_path))]
else:
out_dir = folder_path if folder_path.suffix == "" else folder_path.parent
os.makedirs(out_dir, exist_ok=True)
target_list = []
for i, target_item in enumerate(primary_items):
if isinstance(target_item, SlicingGroup) and target_item.collections:
raw_name = "_".join(target_item.collections.keys())
elif hasattr(target_item, 'name') and getattr(target_item, 'name'):
raw_name = str(getattr(target_item, 'name'))
else:
raw_name = f"object_{i}"
name_part = raw_name.split('.')[0] if raw_name else f"object_{i}"
filename = f"{name_part}.pdf"
target_list.append((target_item, str(out_dir / filename)))
# Automatic scene scale determination
scale_str = "1:1"
try:
@ -530,7 +531,9 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
"scale": scale_str,
}
for target_item, filepath in target_list:
template = getattr(self, "template", None) or getattr(self, "filename_template", None) or "{object_name}-{size_x_mm}x{size_y_mm}-{today_date_iso}.pdf"
for i, target_item in enumerate(primary_items):
if has_raw_geom:
current_raw_geom = target_item
current_slice_paths = slice_paths
@ -558,13 +561,10 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
if len(sp_e) > 0:
edges_2d = np.concatenate([edges_2d, sp_e], axis=0) if len(edges_2d) > 0 else sp_e
if len(edges_2d) == 0:
if operator: operator.report({'WARNING'}, f"Geometry for {os.path.basename(filepath)} has no valid 2D top-view wireframe edges")
try:
export_mesh_to_pdf(
exporter = export_mesh_to_pdf(
edges_2d=edges_2d,
filepath=filepath,
filepath=None,
draw_wireframe=self.draw_wireframe,
draw_boundary=self.draw_boundary,
draw_contour=self.draw_contour,
@ -585,6 +585,36 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
draw_grid_xz=getattr(self, "draw_grid_xz", False),
draw_grid_yz=getattr(self, "draw_grid_yz", False),
)
if is_single_pdf_file:
os.makedirs(folder_path.parent, exist_ok=True)
filepath = str(folder_path)
else:
out_dir = folder_path if folder_path.suffix == "" else folder_path.parent
os.makedirs(out_dir, exist_ok=True)
if isinstance(target_item, SlicingGroup) and target_item.collections:
raw_name = "_".join(target_item.collections.keys())
elif hasattr(target_item, 'name') and getattr(target_item, 'name'):
raw_name = str(getattr(target_item, 'name'))
else:
raw_name = f"object_{i}"
object_name = raw_name.split('.')[0] if raw_name else f"object_{i}"
filename = format_pdf_filename_template(
template=template,
object_name=object_name,
size_x_mm=round(exporter.page_w_mm),
size_y_mm=round(exporter.page_h_mm),
today_date_iso=datetime.date.today().isoformat(),
target_item=target_item,
header_info=current_header_info,
)
filepath = str(out_dir / filename)
if len(edges_2d) == 0:
if operator: operator.report({'WARNING'}, f"Geometry for {os.path.basename(filepath)} has no valid 2D top-view wireframe edges")
exporter.export(filepath)
if operator: operator.report({'INFO'}, f"Mesh PDF saved to {filepath}")
except Exception as e:
if operator: operator.report({'ERROR'}, f"PDF Generation Error: {str(e)}")

View file

@ -47,6 +47,7 @@ from .sa2_measures import (
from .sizing import MM_TO_PT
from .writer import (
export_mesh_to_pdf,
format_pdf_filename_template,
PDFElement,
PDFBackgroundElement,
PDFGridElement,
@ -61,6 +62,7 @@ from .writer import (
__all__ = [
# Main exporters & element classes
"export_mesh_to_pdf",
"format_pdf_filename_template",
"PDFElement",
"PDFBackgroundElement",
"PDFGridElement",

View file

@ -5,6 +5,7 @@ Formats vector edge arrays and renders scalable PDF files with grid overlays, te
import datetime
import math
import os
import re
from pathlib import Path
from typing import Any
import numpy as np
@ -15,6 +16,7 @@ from .metadata import (
extract_object_bounding_boxes,
extract_settings_items,
get_first_attribute_value,
get_first_object_name,
resolve_object_properties,
)
from .geometry import (
@ -39,6 +41,94 @@ from .sizing import (
from .pdf_utils import PdfStream
def format_pdf_filename_template(
template: str | None = None,
object_name: str = "",
size_x_mm: float = 0.0,
size_y_mm: float = 0.0,
today_date_iso: str | None = None,
target_item: Any = None,
header_info: dict[str, Any] | None = None,
) -> str:
"""
Format a file naming template for PDF export.
Default template:
{object_name}-{size_x_mm}x{size_y_mm}-{today_date_iso}.pdf
Supported placeholders:
- {object_name}, {name}, {obj_name}, {part_name}: Name of the object
- {size_x_mm}, {size_x}, {width_mm}, {width}: Total width in mm of the whole PDF
- {size_y_mm}, {size_y}, {height_mm}, {height}: Total height in mm of the whole PDF
- {today_date_iso}, {date_iso}, {today_date}, {date}, {today}: Current date in ISO format (YYYY-MM-DD)
- Custom object attributes (via get_first_attribute_value) or header_info keys
Args:
template: The template format string.
object_name: The object name.
size_x_mm: Whole PDF width in mm.
size_y_mm: Whole PDF height in mm.
today_date_iso: ISO 8601 date string.
target_item: SlicingObject / SlicingGroup / SlicePaths item for attribute lookups.
header_info: Optional dictionary containing technical header drawing metadata.
Returns:
The formatted PDF filename ending in .pdf.
"""
tmpl = (template or "").strip()
if not tmpl:
tmpl = "{object_name}-{size_x_mm}x{size_y_mm}-{today_date_iso}.pdf"
if today_date_iso is None:
today_date_iso = datetime.date.today().isoformat()
sx_str = f"{round(size_x_mm, 2):g}"
sy_str = f"{round(size_y_mm, 2):g}"
context: dict[str, Any] = {
"object_name": object_name,
"name": object_name,
"obj_name": object_name,
"part_name": object_name,
"size_x_mm": sx_str,
"size_x": sx_str,
"width_mm": sx_str,
"width": sx_str,
"size_y_mm": sy_str,
"size_y": sy_str,
"height_mm": sy_str,
"height": sy_str,
"today_date_iso": today_date_iso,
"date_iso": today_date_iso,
"today_date": today_date_iso,
"date": today_date_iso,
"today": today_date_iso,
}
def replace_match(match: re.Match[str]) -> str:
key = match.group(1).strip()
if key in context:
return str(context[key])
if key.lower() in context:
return str(context[key.lower()])
if target_item is not None:
val = get_first_attribute_value(target_item, key)
if val is not None:
if isinstance(val, float):
return f"{round(val, 2):g}"
return str(val)
if header_info and key in header_info:
return str(header_info[key])
return ""
formatted = re.sub(r"\{([^}]+)\}", replace_match, tmpl).strip()
if not formatted:
formatted = f"{object_name or 'object'}.pdf"
if not formatted.lower().endswith(".pdf"):
formatted = f"{formatted}.pdf"
return formatted
def render_technical_header_table(
s: PdfStream,
page_w_pt: float,
@ -710,6 +800,7 @@ class export_mesh_to_pdf:
self,
edges_2d: NDArray[np.float64] | None = None,
filepath: str | Path | None = None,
template: str | None = None,
draw_wireframe: bool = True,
draw_boundary: bool = False,
draw_contour: bool = False,
@ -747,6 +838,7 @@ class export_mesh_to_pdf:
self.projection_plane = "XY" if draw_grid_xy else ("XZ" if draw_grid_xz else "YZ")
self.edges_2d = edges_2d if edges_2d is not None else np.empty((0, 2, 2), dtype=np.float64)
self.filepath = filepath
self.template = template
self.draw_wireframe = draw_wireframe
self.draw_boundary = draw_boundary
self.draw_contour = draw_contour
@ -784,7 +876,29 @@ class export_mesh_to_pdf:
]
if filepath is not None:
self.export(filepath)
target_p = Path(filepath)
if target_p.is_dir() or target_p.suffix.lower() != ".pdf":
out_dir = target_p if target_p.suffix == "" else target_p.parent
tmpl = self.template or "{object_name}-{size_x_mm}x{size_y_mm}-{today_date_iso}.pdf"
primary_obj_name = ""
if self.slicing_objs:
for so in self.slicing_objs:
if hasattr(so, "name") and getattr(so, "name"):
primary_obj_name = str(getattr(so, "name")).split('.')[0]
break
if not primary_obj_name:
primary_obj_name = (get_first_object_name(self.raw_geometry) or get_first_object_name(self.slice_paths) or "object").split('.')[0]
fname = format_pdf_filename_template(
template=tmpl,
object_name=primary_obj_name,
size_x_mm=self.page_w_mm,
size_y_mm=self.page_h_mm,
target_item=self.raw_geometry or self.slice_paths,
header_info=self.header_info,
)
self.export(out_dir / fname)
else:
self.export(filepath)
def _prepare_geometry_and_layout(self) -> None:
objs_raw = get_all_slicing_objects(self.raw_geometry)
@ -1026,6 +1140,10 @@ class export_mesh_to_pdf:
w_total_mm = w_grid_mm + w_table_mm + 2.0 * self.total_margin_mm
h_total_mm = max(h_grid_mm, h_table_col_mm) + 2.0 * self.total_margin_mm
self.w_total_mm = w_total_mm
self.h_total_mm = h_total_mm
self.page_w_mm = w_total_mm
self.page_h_mm = h_total_mm
self.page_w_pt = w_total_mm * MM_TO_PT
self.page_h_pt = h_total_mm * MM_TO_PT

View file

@ -47,6 +47,7 @@ This file provides a mapping of modules to their primary responsibilities and ke
- `extract_slicing_object_features()`: Feature detection for circular bores (Ø), arcs (R), chamfers (C), and steps.
- `writer.py`:
- `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`).
- `format_pdf_filename_template`: Formats file naming templates with placeholders for object names, whole PDF dimensions in mm (`{size_x_mm}`, `{size_y_mm}`), and ISO dates (`{today_date_iso}`).
## `/infra` (Blender Integration)

View file

@ -140,6 +140,7 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
- **Geometry**: Generic geometry input socket (accepts `SLICESOCK_Mesh`, `SLICESOCK_Paths`, or `SLICESOCK_Geometry`).
- **Settings**: Input socket accepting configuration settings or property items.
- **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.
- **Template**: File naming template string for exported PDF files when saving to a folder (default: `{object_name}-{size_x_mm}x{size_y_mm}-{today_date_iso}.pdf`). Supports placeholders `{object_name}`, `{size_x_mm}`, `{size_y_mm}` (the whole PDF page dimensions in mm), `{today_date_iso}` (current ISO date `YYYY-MM-DD`), and custom object attributes.
- **Technical Drawing Header**: Collapsible menu containing string inputs for technical drawing info (Company, Part Name, Drawing No., Author, Revision, Material, Tolerance). If Part Name is left empty, it automatically defaults to the name of the first object being rendered. 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 single geometry (`SlicingGroup`, `SlicingCollection`, or `SlicingObject`). Join multiple geometries using Join Geometry nodes.
- **SlicePaths**: Input socket accepting `SlicePaths` (reserved for path rendering).

View file

@ -1742,6 +1742,141 @@ class TestPdfExporter(unittest.TestCase):
self.assertFalse(getattr(node, "draw_grid_xz", False))
self.assertFalse(getattr(node, "draw_grid_yz", False))
def test_format_pdf_filename_template(self):
from NodeSlicer.core.pdf_exporter import format_pdf_filename_template
import datetime
today = datetime.date.today().isoformat()
# 1. Default template
fname = format_pdf_filename_template(
template="{object_name}-{size_x_mm}x{size_y_mm}-{today_date_iso}.pdf",
object_name="PartA",
size_x_mm=140.0,
size_y_mm=95.0,
today_date_iso="2026-08-31",
)
self.assertEqual(fname, "PartA-140x95-2026-08-31.pdf")
# 2. None or empty template -> defaults to {object_name}-{size_x_mm}x{size_y_mm}-{today_date_iso}.pdf
fname_default = format_pdf_filename_template(
template="",
object_name="PartB",
size_x_mm=200.5,
size_y_mm=100.25,
today_date_iso="2026-08-31",
)
self.assertEqual(fname_default, "PartB-200.5x100.25-2026-08-31.pdf")
# 3. Dynamic today_date_iso when not provided
fname_today = format_pdf_filename_template(
template="{object_name}_{today_date_iso}",
object_name="Plate",
size_x_mm=50.0,
size_y_mm=50.0,
)
self.assertEqual(fname_today, f"Plate_{today}.pdf")
# 4. Aliases: {name}, {width_mm}, {height_mm}, {date}
fname_aliases = format_pdf_filename_template(
template="{name}_W{width_mm}_H{height_mm}_{date}.pdf",
object_name="Bracket",
size_x_mm=120.0,
size_y_mm=80.0,
today_date_iso="2026-01-15",
)
self.assertEqual(fname_aliases, "Bracket_W120_H80_2026-01-15.pdf")
# 5. Missing .pdf extension should be appended
fname_no_ext = format_pdf_filename_template(
template="{object_name}_spec",
object_name="Gear",
size_x_mm=60.0,
size_y_mm=60.0,
)
self.assertEqual(fname_no_ext, "Gear_spec.pdf")
# 6. Target item attributes
so = SlicingObject()
so.attributes = {"material": {"values": np.array(["Aluminum"]), "domain": "OBJECT", "type": "STRING"}}
fname_attr = format_pdf_filename_template(
template="{object_name}_{material}.pdf",
object_name="Rod",
size_x_mm=30.0,
size_y_mm=10.0,
target_item=so,
)
self.assertEqual(fname_attr, "Rod_Aluminum.pdf")
def test_mesh_to_pdf_template_property_and_evaluation_to_directory(self):
from NodeSlicer.core.nodeslicer.nodes.output import SLICENODE_MeshToPdf
import datetime
# Check annotations
self.assertIn("template", SLICENODE_MeshToPdf.__annotations__)
node = SLICENODE_MeshToPdf()
self.assertEqual(getattr(node, "template", ""), "{object_name}-{size_x_mm}x{size_y_mm}-{today_date_iso}.pdf")
# Check alias filename_template
self.assertEqual(node.filename_template, "{object_name}-{size_x_mm}x{size_y_mm}-{today_date_iso}.pdf")
node.filename_template = "custom_{object_name}.pdf"
self.assertEqual(node.template, "custom_{object_name}.pdf")
# Test evaluation with folder destination (directory)
node_exec = SLICENODE_MeshToPdf.__new__(SLICENODE_MeshToPdf)
node_exec.template = "{object_name}-{size_x_mm}x{size_y_mm}-{today_date_iso}.pdf"
node_exec.draw_wireframe = True
node_exec.draw_boundary = False
node_exec.draw_contour = False
node_exec.draw_crease = False
node_exec.crease_angle = 30.0
node_exec.draw_bbox = False
node_exec.draw_sa2_holes = False
node_exec.draw_header = False
node_exec.header_company = ""
node_exec.header_part_name = ""
node_exec.header_drawing_number = ""
node_exec.header_author = ""
node_exec.header_revision = "A"
node_exec.header_material = ""
node_exec.header_tolerance = ""
node_exec.font_size = 0.0
node_exec.margin = 10.0
node_exec.wire_color = (0.0, 0.0, 0.0)
node_exec.bg_color = (1.0, 1.0, 1.0)
node_exec.bbox_color = (0.0, 0.0, 1.0)
class MockOperator:
def __init__(self):
self.reports = []
def report(self, type_set, message):
self.reports.append((type_set, message))
op = MockOperator()
so = SlicingObject()
so.name = "MyComponent"
so.mesh = np.array([[[0.0, 0.0, 0.0], [50.0, 0.0, 0.0], [0.0, 50.0, 0.0]]], dtype=np.float64)
with tempfile.TemporaryDirectory() as tmpdir:
resolved_inputs = {
"Geometry": so,
"Paths": None,
"Folder": tmpdir,
"Settings": None,
}
node_exec.resolve = lambda fieldname: resolved_inputs.get(fieldname)
res = node_exec.evaluate(operator=op)
self.assertEqual(res, "ok")
# Check exported file in directory
files = os.listdir(tmpdir)
self.assertEqual(len(files), 1)
generated_name = files[0]
today_str = datetime.date.today().isoformat()
self.assertTrue(generated_name.startswith("MyComponent-"))
self.assertTrue(generated_name.endswith(f"-{today_str}.pdf"))
self.assertIn("x", generated_name)
if __name__ == '__main__':
unittest.main()