up
This commit is contained in:
parent
dc0849a778
commit
5cf0caefd0
6 changed files with 398 additions and 131 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,20 +568,46 @@ 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()
|
||||
|
||||
# 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()
|
||||
|
||||
objects: list[bytes] = []
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue