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]
|
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_bbox: NfBoolProperty(name="Bounding Box", default=False) # pyright: ignore[reportInvalidTypeForm]
|
||||||
draw_sa2_holes: NfBoolProperty(name="Holes (Ø)", 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_lineart: NfBoolProperty(name="Lineart Visibility", default=False) # pyright: ignore[reportInvalidTypeForm]
|
||||||
show_sa2: NfBoolProperty(name="SA2 Settings", default=True) # 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]
|
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]
|
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]
|
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):
|
def draw_buttons(self, context, layout):
|
||||||
super().draw_buttons(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')
|
layout.prop(self, "show_lineart", text="Lineart Visibility", icon='TRIA_DOWN' if self.show_lineart else 'TRIA_RIGHT')
|
||||||
if self.show_lineart:
|
if self.show_lineart:
|
||||||
box = layout.box()
|
box = layout.box()
|
||||||
|
|
@ -418,7 +441,7 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
|
||||||
if self.draw_crease:
|
if self.draw_crease:
|
||||||
box.prop(self, "crease_angle")
|
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')
|
layout.prop(self, "show_sa2", text="SA2 Settings", icon='TRIA_DOWN' if self.show_sa2 else 'TRIA_RIGHT')
|
||||||
if self.show_sa2:
|
if self.show_sa2:
|
||||||
box = layout.box()
|
box = layout.box()
|
||||||
|
|
@ -437,6 +460,7 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
|
||||||
layout.prop(self, "wire_color")
|
layout.prop(self, "wire_color")
|
||||||
|
|
||||||
def evaluate(self, operator=None):
|
def evaluate(self, operator=None):
|
||||||
|
import datetime
|
||||||
from ...pdf_exporter import extract_unique_top_view_edges, export_mesh_to_pdf
|
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]
|
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"
|
filename = f"{name_part}.pdf"
|
||||||
target_list.append((geom, str(out_dir / filename)))
|
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:
|
for geom, filepath in target_list:
|
||||||
edges_2d = extract_unique_top_view_edges(geom)
|
edges_2d = extract_unique_top_view_edges(geom)
|
||||||
if len(edges_2d) == 0:
|
if len(edges_2d) == 0:
|
||||||
|
|
@ -490,6 +540,8 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
|
||||||
draw_crease=self.draw_crease,
|
draw_crease=self.draw_crease,
|
||||||
draw_bbox=self.draw_bbox,
|
draw_bbox=self.draw_bbox,
|
||||||
draw_sa2_holes=self.draw_sa2_holes,
|
draw_sa2_holes=self.draw_sa2_holes,
|
||||||
|
draw_header=self.draw_header,
|
||||||
|
header_info=header_info,
|
||||||
show_bbox_size=self.show_bbox_size,
|
show_bbox_size=self.show_bbox_size,
|
||||||
crease_angle=self.crease_angle,
|
crease_angle=self.crease_angle,
|
||||||
margin_mm=self.margin,
|
margin_mm=self.margin,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ from typing import Any
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
|
|
||||||
def deduplicate_2d_edges(edges_2d: NDArray[np.float64]) -> NDArray[np.float64]:
|
def deduplicate_2d_edges(edges_2d: NDArray[np.float64]) -> NDArray[np.float64]:
|
||||||
"""
|
"""
|
||||||
Deduplicates 2D edge segments regardless of direction (A->B or B->A).
|
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]:
|
def extract_silhouette_contour_edges(mesh_data: Any) -> NDArray[np.float64]:
|
||||||
"""
|
"""
|
||||||
Extracts top-view silhouette contour edges using 3D face normals.
|
Extracts top-view silhouette contour edges using 3D face normals.
|
||||||
A silhouette edge separates faces with different visibility from the top-down view.
|
Filters front-facing faces (normal_z > epsilon) and extracts edges owned by exactly 1 front-facing face.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
|
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:
|
if num_tris == 0:
|
||||||
return np.empty((0, 2, 2), dtype=np.float64)
|
return np.empty((0, 2, 2), dtype=np.float64)
|
||||||
|
|
||||||
# For top-down view (view direction = (0,0,-1)), compute sign of normal.z
|
# Filter for top-facing faces (normal_z > epsilon).
|
||||||
# The sign of -normal.z tells us visibility: >0 means front-facing (visible)
|
# Back-facing (normal_z < -epsilon) and vertical (|normal_z| <= epsilon) faces are masked out.
|
||||||
# We categorize by sign of normal.z directly for simplicity:
|
|
||||||
# negative z = facing viewer, positive z = facing away, zero = edge-on
|
|
||||||
normal_z = normals_unit[:, 2]
|
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
|
epsilon = 1e-3
|
||||||
face_category = np.zeros(num_tris, dtype=int)
|
front_mask = normal_z > epsilon
|
||||||
face_category[normal_z < -epsilon] = 1 # Front-facing
|
|
||||||
face_category[normal_z > epsilon] = 2 # Back-facing
|
|
||||||
# face_category == 0 means vertical (|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], :]
|
e0_3d = tris_3d[:, [0, 1], :]
|
||||||
e1_3d = tris_3d[:, [1, 2], :]
|
e1_3d = tris_3d[:, [1, 2], :]
|
||||||
e2_3d = tris_3d[:, [2, 0], :]
|
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], :]
|
e0_2d = verts_2d[:, [0, 1], :]
|
||||||
e1_2d = verts_2d[:, [1, 2], :]
|
e1_2d = verts_2d[:, [1, 2], :]
|
||||||
e2_2d = verts_2d[:, [2, 0], :]
|
e2_2d = verts_2d[:, [2, 0], :]
|
||||||
edges_2d = np.concatenate([e0_2d, e1_2d, e2_2d], axis=0) # (3N, 2, 2)
|
edges_2d = np.concatenate([e0_2d, e1_2d, e2_2d], axis=0) # (3M, 2, 2)
|
||||||
|
|
||||||
# Each edge inherits its face's category
|
|
||||||
edge_category = np.tile(face_category, 3)
|
|
||||||
|
|
||||||
# Filter out near zero-length 2D edges
|
# Filter out near zero-length 2D edges
|
||||||
dists_sq_2d = np.sum((edges_2d[:, 0, :] - edges_2d[:, 1, :]) ** 2, axis=1)
|
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_3d = edges_3d[valid_len_mask]
|
||||||
edges_2d = edges_2d[valid_len_mask]
|
edges_2d = edges_2d[valid_len_mask]
|
||||||
edge_category = edge_category[valid_len_mask]
|
|
||||||
|
|
||||||
if len(edges_3d) == 0:
|
if len(edges_3d) == 0:
|
||||||
return np.empty((0, 2, 2), dtype=np.float64)
|
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
|
# Unique 3D edge identification
|
||||||
flat_3d = canonical_3d.reshape(len(canonical_3d), 6)
|
flat_3d = canonical_3d.reshape(len(canonical_3d), 6)
|
||||||
view_bytes = np.ascontiguousarray(flat_3d).view(np.dtype((np.void, 48))).ravel()
|
view_bytes = np.ascontiguousarray(flat_3d).view(np.dtype((np.void, 48))).ravel()
|
||||||
unique_keys, first_indices, inverse_indices, counts = np.unique(
|
_, first_indices, counts = np.unique(
|
||||||
view_bytes, return_index=True, return_inverse=True, return_counts=True
|
view_bytes, return_index=True, return_counts=True
|
||||||
)
|
)
|
||||||
|
|
||||||
num_unique = len(unique_keys)
|
# Boundary edges of the front-facing surface have count == 1
|
||||||
|
contour_edges = canonical_2d[first_indices[counts == 1]]
|
||||||
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]]
|
|
||||||
return deduplicate_2d_edges(contour_edges)
|
return deduplicate_2d_edges(contour_edges)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -380,6 +380,11 @@ class PdfStream:
|
||||||
self._cmds.append(f"/{name} {size_pt:.1f} Tf")
|
self._cmds.append(f"/{name} {size_pt:.1f} Tf")
|
||||||
return self
|
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:
|
def text_position(self, x: float, y: float) -> PdfStream:
|
||||||
"""Set the text matrix to translate to (x, y)."""
|
"""Set the text matrix to translate to (x, y)."""
|
||||||
self._cmds.append(f"1 0 0 1 {x:.2f} {y:.2f} Tm")
|
self._cmds.append(f"1 0 0 1 {x:.2f} {y:.2f} Tm")
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
PDF Export Stream Assembly & Writer Module.
|
PDF Export Stream Assembly & Writer Module.
|
||||||
Formats vector edge arrays and renders scalable PDF files with grid overlays, text labels, and SA2 technical markings.
|
Formats vector edge arrays and renders scalable PDF files with grid overlays, text labels, and SA2 technical markings.
|
||||||
"""
|
"""
|
||||||
|
import datetime
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -32,6 +33,120 @@ from .sizing import (
|
||||||
from .pdf_utils import PdfStream, escape_pdf_text
|
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]:
|
def get_all_slicing_objects(mesh_data: Any) -> list[Any]:
|
||||||
from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
|
from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
|
||||||
objects = []
|
objects = []
|
||||||
|
|
@ -62,6 +177,8 @@ def export_mesh_to_pdf(
|
||||||
draw_crease: bool = False,
|
draw_crease: bool = False,
|
||||||
draw_bbox: bool = False,
|
draw_bbox: bool = False,
|
||||||
draw_sa2_holes: bool = False,
|
draw_sa2_holes: bool = False,
|
||||||
|
draw_header: bool = False,
|
||||||
|
header_info: dict[str, Any] | None = None,
|
||||||
show_name: bool = False,
|
show_name: bool = False,
|
||||||
show_bbox_size: bool = False,
|
show_bbox_size: bool = False,
|
||||||
crease_angle: float = 30.0,
|
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),
|
bbox_color: tuple[float, float, float] = (0.0, 0.0, 1.0),
|
||||||
raw_geometry: Any = None,
|
raw_geometry: Any = None,
|
||||||
settings: Any = None,
|
settings: Any = None,
|
||||||
style: str | None = None,
|
|
||||||
**kwargs: Any
|
**kwargs: Any
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Exports 2D vector geometry to a 1:1 true physical scale vector PDF file.
|
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.
|
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]] = []
|
edge_list: list[NDArray[np.float64]] = []
|
||||||
|
|
||||||
if draw_wireframe and len(edges_2d) > 0:
|
if draw_wireframe and len(edges_2d) > 0:
|
||||||
|
|
@ -128,43 +235,23 @@ def export_mesh_to_pdf(
|
||||||
|
|
||||||
w_geom = max_x - min_x
|
w_geom = max_x - min_x
|
||||||
h_geom = max_y - min_y
|
h_geom = max_y - min_y
|
||||||
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)
|
main_step, minor_step = compute_grid_steps(w_geom, h_geom)
|
||||||
|
|
||||||
if len(edges_to_draw) > 0 or len(edges_2d) > 0:
|
if len(edges_to_draw) > 0 or len(edges_2d) > 0:
|
||||||
pad_x = minor_step
|
pad_x = minor_step
|
||||||
pad_y = 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
|
# Ensure grid is symmetric around center
|
||||||
grid_min_x = center_x - half_span_x
|
grid_min_x = min_x - pad_x
|
||||||
grid_max_x = center_x + half_span_x
|
grid_max_x = max_x + pad_x
|
||||||
grid_min_y = center_y - half_span_y
|
grid_min_y = min_y - pad_y
|
||||||
grid_max_y = center_y + half_span_y
|
grid_max_y = max_y + pad_y
|
||||||
|
|
||||||
# Ensure grid aligns with main_step grid lines
|
# Ensure grid aligns with minor_step grid lines
|
||||||
grid_min_x = math.floor(grid_min_x / main_step) * main_step
|
grid_min_x = math.floor(grid_min_x / minor_step) * minor_step
|
||||||
grid_max_x = math.ceil(grid_max_x / main_step) * main_step
|
grid_max_x = math.ceil(grid_max_x / minor_step) * minor_step
|
||||||
grid_min_y = math.floor(grid_min_y / main_step) * main_step
|
grid_min_y = math.floor(grid_min_y / minor_step) * minor_step
|
||||||
grid_max_y = math.ceil(grid_max_y / main_step) * main_step
|
grid_max_y = math.ceil(grid_max_y / minor_step) * minor_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
|
|
||||||
else:
|
else:
|
||||||
grid_min_x, grid_max_x = -50.0, 50.0
|
grid_min_x, grid_max_x = -50.0, 50.0
|
||||||
grid_min_y, grid_max_y = -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
|
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)
|
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_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:
|
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 show_bbox_size:
|
||||||
if main_step >= 1000.0:
|
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)"
|
size_str = f"(X: {w_val:g}m Y: {h_val:g}m)"
|
||||||
else:
|
else:
|
||||||
size_str = f"(X: {w_obj_mm:g}mm Y: {h_obj_mm:g}mm)"
|
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:
|
for key_name, suffix in settings_items:
|
||||||
attr_val = get_first_attribute_value(so_item, key_name)
|
attr_val = get_first_attribute_value(so_item, key_name)
|
||||||
|
|
@ -476,20 +568,46 @@ def export_mesh_to_pdf(
|
||||||
val_str = str(attr_val)
|
val_str = str(attr_val)
|
||||||
if suffix:
|
if suffix:
|
||||||
val_str = f"{val_str}{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:
|
if lines_to_draw:
|
||||||
s.begin_text().font("F1", eff_font_size_pt)
|
for line_idx, (line_str, col, is_bold) in enumerate(lines_to_draw):
|
||||||
|
|
||||||
for line_idx, (line_str, col) in enumerate(lines_to_draw):
|
|
||||||
cr, cg, cb = col
|
cr, cg, cb = col
|
||||||
s.fill_color(cr, cg, cb)
|
|
||||||
curr_y_pt = y_text_pt - (line_idx * line_spacing_pt)
|
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.text_position(x_text_pt, curr_y_pt)
|
||||||
s.show(line_str)
|
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()
|
stream_bytes = s.encode()
|
||||||
|
|
||||||
objects: list[bytes] = []
|
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.
|
- **Load Gcode**: Reads a G-code file from disk.
|
||||||
- **Gcode to File**: Writes G-code string to a file on 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.
|
- **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`.
|
- **Geometry**: Input socket accepting a `SlicingGroup`, `SlicingCollection`, or `SlicingObject`.
|
||||||
- **Folder**: Target folder or PDF file path on disk.
|
- **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.
|
- **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,
|
escape_pdf_text,
|
||||||
MM_TO_PT
|
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):
|
class TestPdfExporter(unittest.TestCase):
|
||||||
|
|
||||||
|
|
||||||
def test_escape_pdf_text_special_characters(self):
|
def test_escape_pdf_text_special_characters(self):
|
||||||
escaped = escape_pdf_text("Ø8mm (test) 45° × 2 ± 0.1 µm")
|
escaped = escape_pdf_text("Ø8mm (test) 45° × 2 ± 0.1 µm")
|
||||||
# With cp1252 encoding, special chars pass through directly
|
# 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
|
# All 3 outer boundary edges of the top triangle meeting perpendicular side walls must be detected
|
||||||
self.assertEqual(len(contour), 3)
|
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):
|
def test_export_pdf_true_1_to_1_scale(self):
|
||||||
edges_2d = np.array([
|
edges_2d = np.array([
|
||||||
[[0.0, 0.0], [100.0, 0.0]],
|
[[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
|
minor_step = 1.0 # main_step 10.0 / 10
|
||||||
total_margin_mm = margin_mm + 2.0 * minor_step
|
total_margin_mm = margin_mm + 2.0 * minor_step
|
||||||
# 100mm geometry padded by 1 minor_step clearance expands grid to 120mm
|
# 100mm geometry padded by 1 minor_step clearance expands grid to 102mm (rounded to minor_step)
|
||||||
expected_w = (120.0 + 2 * total_margin_mm) * MM_TO_PT
|
expected_w = (102.0 + 2 * total_margin_mm) * MM_TO_PT
|
||||||
expected_h = (120.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")
|
mediabox_str = f"/MediaBox [0 0 {expected_w:.2f} {expected_h:.2f}]".encode("utf-8")
|
||||||
|
|
||||||
self.assertTrue(content.startswith(b"%PDF-1.4"))
|
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)
|
drawn_obj_h_pt = max(obj_y) - min(obj_y)
|
||||||
|
|
||||||
# Assert that actual drawn line span in PDF points EXACTLY equals 3D mesh dimensions
|
# 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_w_pt, 120.0 * (72.0 / 25.4), places=1)
|
||||||
self.assertAlmostEqual(drawn_obj_h_pt, 80.0 * (72.0 / 25.4), places=2)
|
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):
|
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.assertEqual(obj.pdf_properties.line_color, (0.5, 0.5, 1.0))
|
||||||
self.assertTrue(obj.pdf_properties.show_name)
|
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):
|
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)
|
# Check RedBox label is rendered (show_name=True on so1)
|
||||||
self.assertIn("(RedBox) Tj", content)
|
self.assertIn("(RedBox) Tj", content)
|
||||||
self.assertNotIn("(GreenBox) Tj", content)
|
self.assertNotIn("(GreenBox) Tj", content)
|
||||||
|
# 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):
|
def test_export_pdf_fallback_color_and_default_line_width(self):
|
||||||
from NodeSlicer.infra.blender.blender_mesh_capture import PdfProperties
|
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
|
# Wireframe color (0.200 0.400 0.600 RG) should be used
|
||||||
self.assertIn("0.200 0.400 0.600 RG", content)
|
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
|
from NodeSlicer.core.nodeslicer.nodes.output import SLICENODE_MeshToPdf
|
||||||
node = SLICENODE_MeshToPdf()
|
# Verify header properties exist on class annotations
|
||||||
self.assertFalse(hasattr(node, "line_width"))
|
annotations = SLICENODE_MeshToPdf.__annotations__
|
||||||
self.assertFalse(hasattr(node, "show_name"))
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue