nodeslicer/tests/test_pdf_exporter.py
2026-08-06 10:50:50 +03:00

1512 lines
61 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
import tempfile
import numpy as np
import unittest
from unittest.mock import MagicMock
import sys
from pathlib import Path
# Add workspace directory to sys.path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.append(str(PROJECT_ROOT.parent))
# Mock bpy and dependencies for unit testing
class DummyNodeSocket: pass
class DummyNode: pass
class DummyObject: pass
class DummyMesh: pass
class DummyBase: pass
mock_types = MagicMock()
mock_types.NodeSocket = type("NodeSocket", (DummyBase,), {})
mock_types.Node = type("Node", (DummyBase,), {})
mock_types.NodeCustomGroup = type("NodeCustomGroup", (DummyBase,), {})
mock_types.NodeTree = type("NodeTree", (DummyBase,), {})
mock_types.PropertyGroup = type("PropertyGroup", (DummyBase,), {})
mock_types.Operator = type("Operator", (DummyBase,), {})
mock_types.Panel = type("Panel", (DummyBase,), {})
mock_types.Object = DummyObject
mock_types.Mesh = DummyMesh
mock_bpy = MagicMock()
mock_bpy.context.scene = MagicMock()
mock_bpy.context.scene.unit_settings.scale_length = 0.001
mock_bpy.types = mock_types
mock_bpy.path.abspath = lambda path: path.replace("//", "/abs/")
sys.modules['bpy'] = mock_bpy
sys.modules['bpy.types'] = mock_types
sys.modules['bpy.props'] = MagicMock()
sys.modules['bpy.utils'] = MagicMock()
sys.modules['bpy.utils.previews'] = MagicMock()
sys.modules['bpy.app'] = MagicMock()
sys.modules['bpy.app.handlers'] = MagicMock()
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
from NodeSlicer.core.pdf_exporter import (
extract_unique_top_view_edges,
extract_lineart_edges,
extract_outer_contour_edges,
extract_slicing_object_features,
export_wireframe_to_pdf,
export_mesh_to_pdf,
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
self.assertIn("Ø8mm", escaped)
self.assertIn("\\(test\\)", escaped)
self.assertIn("°", escaped)
self.assertIn("×", escaped)
self.assertIn("±", escaped)
self.assertIn("µ", escaped)
def test_extract_edges_from_slicing_object(self):
so = SlicingObject()
# Create a simple triangle in 3D: (0,0,0), (10,0,0), (0,10,0) with normal (0,0,1)
tri = 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] # Normal
], dtype=np.float64)
so.mesh = np.array([tri])
edges = extract_unique_top_view_edges(so)
self.assertEqual(len(edges), 3)
def test_extract_edges_deduplication(self):
so = SlicingObject()
# Two triangles sharing an edge (10,0,0) - (0,10,0)
tri1 = 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)
tri2 = np.array([
[10.0, 0.0, 0.0],
[10.0, 10.0, 0.0],
[0.0, 10.0, 0.0],
[0.0, 0.0, 1.0]
], dtype=np.float64)
so.mesh = np.array([tri1, tri2])
edges = extract_unique_top_view_edges(so)
# Total edges: 3 + 3 - 1 shared = 5 unique edges
self.assertEqual(len(edges), 5)
def test_extract_edges_filters_vertical_lines(self):
so = SlicingObject()
# Vertical wall in top view (x, y remain constant, z changes) -> degenerate 2D line segment
tri = np.array([
[0.0, 0.0, 0.0],
[0.0, 0.0, 10.0],
[10.0, 0.0, 0.0],
[0.0, 1.0, 0.0]
], dtype=np.float64)
so.mesh = np.array([tri])
edges = extract_unique_top_view_edges(so)
# Vertical edge (0,0,0)-(0,0,10) becomes 2D length 0 and should be filtered out
self.assertTrue(len(edges) < 3)
def test_extract_lineart_edges(self):
so = SlicingObject()
# Create 2 triangles sharing an edge at 90 degree angle (crease)
tri1 = 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] # Normal up
], dtype=np.float64)
tri2 = np.array([
[10.0, 0.0, 0.0],
[10.0, 0.0, 10.0],
[0.0, 10.0, 0.0],
[1.0, 0.0, 0.0] # Normal right (90 deg to tri1)
], dtype=np.float64)
so.mesh = np.array([tri1, tri2])
# At max_angle_deg=30, the 90 deg crease edge should be marked
lineart_30 = extract_lineart_edges(so, max_angle_deg=30.0)
self.assertTrue(len(lineart_30) > 0)
# At max_angle_deg=100, crease edge (90 deg) is below threshold, but boundary edges remain
lineart_100 = extract_lineart_edges(so, max_angle_deg=100.0)
self.assertTrue(len(lineart_100) > 0)
def test_extract_outer_contour_edges(self):
so = SlicingObject()
# Top face made of 2 triangles (0,0)-(10,0)-(10,10) and (0,0)-(10,10)-(0,10)
# Shared interior diagonal edge: (0,0)-(10,10)
# Side wall triangles around the box (vertical wall, normal_z = 0)
tri_top1 = np.array([[0.0, 0.0, 10.0], [10.0, 0.0, 10.0], [10.0, 10.0, 10.0], [0.0, 0.0, 1.0]], dtype=np.float64)
tri_top2 = np.array([[0.0, 0.0, 10.0], [10.0, 10.0, 10.0], [0.0, 10.0, 10.0], [0.0, 0.0, 1.0]], dtype=np.float64)
# Side walls
tri_side1 = np.array([[0.0, 0.0, 10.0], [10.0, 0.0, 10.0], [10.0, 0.0, 0.0], [0.0, -1.0, 0.0]], dtype=np.float64)
tri_side2 = np.array([[10.0, 0.0, 10.0], [10.0, 10.0, 10.0], [10.0, 10.0, 0.0], [1.0, 0.0, 0.0]], dtype=np.float64)
tri_side3 = np.array([[10.0, 10.0, 10.0], [0.0, 10.0, 10.0], [0.0, 10.0, 0.0], [0.0, 1.0, 0.0]], dtype=np.float64)
tri_side4 = np.array([[0.0, 10.0, 10.0], [0.0, 0.0, 10.0], [0.0, 0.0, 0.0], [-1.0, 0.0, 0.0]], dtype=np.float64)
so.mesh = np.array([tri_top1, tri_top2, tri_side1, tri_side2, tri_side3, tri_side4])
wireframe = extract_unique_top_view_edges(so)
# Wireframe contains 4 outer edges + 1 interior diagonal = 5
self.assertEqual(len(wireframe), 5)
contour = extract_outer_contour_edges(so)
# Contour should exclude the internal diagonal edge and return only 4 outer boundary edges
self.assertEqual(len(contour), 4)
def test_extract_outer_contour_edges_perpendicular_floating_point_noise(self):
so = SlicingObject()
# Top face: normal_z = +1.0
tri_top = np.array([[0.0, 0.0, 10.0], [10.0, 0.0, 10.0], [0.0, 10.0, 10.0], [0.0, 0.0, 1.0]], dtype=np.float64)
# Perpendicular side walls with floating point noise in normal_z (e.g. 1.5e-5, -2.0e-5)
tri_side1 = np.array([[0.0, 0.0, 10.0], [10.0, 0.0, 10.0], [10.0, 0.0, 0.0], [0.0, -1.0, 1.5e-5]], dtype=np.float64)
tri_side2 = np.array([[10.0, 0.0, 10.0], [0.0, 10.0, 10.0], [0.0, 10.0, 0.0], [0.707, 0.707, -2.0e-5]], dtype=np.float64)
tri_side3 = np.array([[0.0, 10.0, 10.0], [0.0, 0.0, 10.0], [0.0, 0.0, 0.0], [-1.0, 0.0, 4.0e-5]], dtype=np.float64)
so.mesh = np.array([tri_top, tri_side1, tri_side2, tri_side3])
contour = extract_outer_contour_edges(so)
# 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), 7)
def test_export_pdf_true_1_to_1_scale(self):
edges_2d = np.array([
[[0.0, 0.0], [100.0, 0.0]],
[[100.0, 0.0], [100.0, 100.0]],
[[100.0, 100.0], [0.0, 100.0]],
[[0.0, 100.0], [0.0, 0.0]]
], dtype=np.float64)
margin_mm = 10.0
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "test_true_scale.pdf")
export_wireframe_to_pdf(
edges_2d,
pdf_path,
margin_mm=margin_mm,
stroke_color=(0.1, 0.2, 0.3),
bg_color=(0.9, 0.95, 1.0)
)
self.assertTrue(os.path.exists(pdf_path))
with open(pdf_path, "rb") as f:
content = f.read()
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 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"))
self.assertIn(mediabox_str, content)
self.assertIn(b"0.900 0.950 1.000 rg", content) # Background color
self.assertIn(b"0.100 0.200 0.300 RG", content) # Wireframe color
self.assertIn(b"/GS25 gs", content)
self.assertIn(b"/GS50 gs", content)
self.assertIn(b"/GS100 gs", content)
self.assertTrue(content.endswith(b"%%EOF\n"))
def test_export_pdf_line_layers(self):
so = SlicingObject()
tri = np.array([
[0.0, 0.0, 0.0],
[100.0, 0.0, 0.0],
[0.0, 100.0, 0.0],
[0.0, 0.0, 1.0]
], dtype=np.float64)
so.mesh = np.array([tri])
edges_2d = extract_unique_top_view_edges(so)
configs = [
{"draw_wireframe": True, "draw_contour": False, "draw_crease": False},
{"draw_wireframe": False, "draw_contour": True, "draw_crease": False},
{"draw_wireframe": False, "draw_contour": False, "draw_crease": True},
{"draw_wireframe": True, "draw_contour": True, "draw_crease": True},
]
for i, cfg in enumerate(configs):
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, f"test_layers_{i}.pdf")
export_wireframe_to_pdf(
edges_2d,
pdf_path,
crease_angle=30.0,
raw_geometry=so,
**cfg
)
self.assertTrue(os.path.exists(pdf_path))
with open(pdf_path, "rb") as f:
content = f.read()
self.assertTrue(content.startswith(b"%PDF-1.4"))
self.assertTrue(content.endswith(b"%%EOF\n"))
self.assertIn(b"(X) Tj", content)
self.assertIn(b"(Y) Tj", content)
def test_export_pdf_show_name(self):
so = SlicingObject()
so.name = "MyTestPart"
tri = np.array([
[0.0, 0.0, 0.0],
[50.0, 0.0, 0.0],
[0.0, 50.0, 0.0],
[0.0, 0.0, 1.0]
], dtype=np.float64)
so.mesh = np.array([tri])
edges_2d = extract_unique_top_view_edges(so)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "test_show_name.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
show_name=True,
raw_geometry=so
)
self.assertTrue(os.path.exists(pdf_path))
with open(pdf_path, "rb") as f:
content = f.read()
self.assertIn(b"(MyTestPart) Tj", content)
def test_export_pdf_draw_bbox(self):
so = SlicingObject()
so.name = "BBoxPart"
tri = np.array([
[0.0, 0.0, 0.0],
[100.0, 0.0, 0.0],
[0.0, 50.0, 0.0],
[0.0, 0.0, 1.0]
], dtype=np.float64)
so.mesh = np.array([tri])
edges_2d = extract_unique_top_view_edges(so)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "test_draw_bbox.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
draw_bbox=True,
bbox_color=(0.0, 0.0, 1.0),
raw_geometry=so
)
self.assertTrue(os.path.exists(pdf_path))
with open(pdf_path, "rb") as f:
content = f.read()
# BBox color blue: 0.000 0.000 1.000 RG
self.assertIn(b"0.000 0.000 1.000 RG", content)
def test_pdf_object_physical_size_matches_3d_mesh(self):
"""
Verifies that the physical size of an object exported to PDF (in PostScript points pt)
matches its 3D mesh dimensions (in mm) * (72.0 / 25.4) with 100% mathematical precision.
"""
import re
mesh_w_mm = 120.0
mesh_h_mm = 80.0
min_x_3d, min_y_3d = 25.0, -15.0
max_x_3d, max_y_3d = min_x_3d + mesh_w_mm, min_y_3d + mesh_h_mm
so = SlicingObject()
so.name = "ScaledPart"
tri1 = np.array([
[min_x_3d, min_y_3d, 0.0],
[max_x_3d, min_y_3d, 0.0],
[min_x_3d, max_y_3d, 0.0],
[0.0, 0.0, 1.0]
], dtype=np.float64)
tri2 = np.array([
[max_x_3d, min_y_3d, 0.0],
[max_x_3d, max_y_3d, 0.0],
[min_x_3d, max_y_3d, 0.0],
[0.0, 0.0, 1.0]
], dtype=np.float64)
so.mesh = np.array([tri1, tri2])
edges_2d = extract_unique_top_view_edges(so)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "test_physical_scale.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
draw_wireframe=True,
margin_mm=15.0,
line_width_mm=0.5,
raw_geometry=so
)
with open(pdf_path, "rb") as f:
pdf_text = f.read().decode("utf-8", errors="ignore")
mediabox_match = re.search(r"/MediaBox\s+\[0\s+0\s+(\d+\.\d+)\s+(\d+\.\d+)\]", pdf_text)
self.assertIsNotNone(mediabox_match)
page_w_pt = float(mediabox_match.group(1))
page_h_pt = float(mediabox_match.group(2))
# Verify MediaBox exact conversion: 1 pt = 1/72 inch = 25.4/72 mm
page_w_mm = page_w_pt / MM_TO_PT
page_h_mm = page_h_pt / MM_TO_PT
self.assertAlmostEqual(page_w_mm * MM_TO_PT, page_w_pt, places=4)
self.assertAlmostEqual(page_h_mm * MM_TO_PT, page_h_pt, places=4)
# Object physical size in PDF points:
object_w_pt = mesh_w_mm * MM_TO_PT
object_h_pt = mesh_h_mm * MM_TO_PT
# 120 mm -> 340.15748 pt, 80 mm -> 226.77165 pt
self.assertAlmostEqual(object_w_pt, 120.0 * (72.0 / 25.4), places=5)
self.assertAlmostEqual(object_h_pt, 80.0 * (72.0 / 25.4), places=5)
# Extract drawn object line coordinates under /GS100 gs from the PDF stream
gs100_idx = pdf_text.find("/GS100 gs")
self.assertNotEqual(gs100_idx, -1)
gs100_section = pdf_text[gs100_idx:]
line_matches = re.findall(r"(\d+\.\d+)\s+(\d+\.\d+)\s+m\s+(\d+\.\d+)\s+(\d+\.\d+)\s+l", gs100_section)
self.assertTrue(len(line_matches) >= 4)
obj_x, obj_y = [], []
for x0, y0, x1, y1 in line_matches[:4]:
obj_x.extend([float(x0), float(x1)])
obj_y.extend([float(y0), float(y1)])
drawn_obj_w_pt = max(obj_x) - min(obj_x)
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=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):
"""
End-to-end test on a complex multi-object SlicingGroup containing 3D mechanical
components (chamfered block + hollow prism), testing all PDF layers simultaneously.
"""
import re
# Object 1: 3D Chamfered Block
so1 = SlicingObject()
so1.name = "BaseBracket"
# Triangles forming a 3D block with a chamfered face
tris1 = [
# Bottom face
[[10.0, 20.0, 0.0], [110.0, 20.0, 0.0], [10.0, 70.0, 0.0], [0.0, 0.0, -1.0]],
[[110.0, 20.0, 0.0], [110.0, 70.0, 0.0], [10.0, 70.0, 0.0], [0.0, 0.0, -1.0]],
# Top face
[[10.0, 20.0, 30.0], [10.0, 70.0, 30.0], [90.0, 20.0, 30.0], [0.0, 0.0, 1.0]],
[[90.0, 20.0, 30.0], [10.0, 70.0, 30.0], [90.0, 70.0, 30.0], [0.0, 0.0, 1.0]],
# Chamfer face (45 deg angle)
[[90.0, 20.0, 30.0], [90.0, 70.0, 30.0], [110.0, 20.0, 0.0], [0.707, 0.0, 0.707]],
[[110.0, 20.0, 0.0], [90.0, 70.0, 30.0], [110.0, 70.0, 0.0], [0.707, 0.0, 0.707]],
]
so1.mesh = np.array(tris1, dtype=np.float64)
# Object 2: 3D Hollow Octagonal Prism
so2 = SlicingObject()
so2.name = "HollowPipe"
# Construct an 8-sided hollow tube in 3D
angles = np.linspace(0, 2 * np.pi, 8, endpoint=False)
r_out = 30.0
r_in = 15.0
cx, cy = 180.0, 45.0
tube_tris = []
for i in range(8):
a1, a2 = angles[i], angles[(i + 1) % 8]
x1_o, y1_o = cx + r_out * np.cos(a1), cy + r_out * np.sin(a1)
x2_o, y2_o = cx + r_out * np.cos(a2), cy + r_out * np.sin(a2)
x1_i, y1_i = cx + r_in * np.cos(a1), cy + r_in * np.sin(a1)
x2_i, y2_i = cx + r_in * np.cos(a2), cy + r_in * np.sin(a2)
# Top ring face
tube_tris.append([[x1_o, y1_o, 50.0], [x2_o, y2_o, 50.0], [x1_i, y1_i, 50.0], [0.0, 0.0, 1.0]])
tube_tris.append([[x2_o, y2_o, 50.0], [x2_i, y2_i, 50.0], [x1_i, y1_i, 50.0], [0.0, 0.0, 1.0]])
# Outer wall face
nx, ny = np.cos((a1 + a2) / 2), np.sin((a1 + a2) / 2)
tube_tris.append([[x1_o, y1_o, 0.0], [x2_o, y2_o, 0.0], [x1_o, y1_o, 50.0], [nx, ny, 0.0]])
tube_tris.append([[x2_o, y2_o, 0.0], [x2_o, y2_o, 50.0], [x1_o, y1_o, 50.0], [nx, ny, 0.0]])
so2.mesh = np.array(tube_tris, dtype=np.float64)
# Assemble into SlicingGroup with 2 collections
sc1 = SlicingCollection([], "BracketColl")
sc1.objects = [so1]
sc2 = SlicingCollection([], "PipeColl")
sc2.objects = [so2]
sg = SlicingGroup([])
sg.collections = {"BracketColl": sc1, "PipeColl": sc2}
# Extract 2D wireframe edges from SlicingGroup
edges_2d = extract_unique_top_view_edges(sg)
self.assertTrue(len(edges_2d) > 0)
# Export to PDF with ALL checkboxes enabled
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "complex_slicing_group.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
draw_wireframe=True,
draw_boundary=True,
draw_contour=True,
draw_crease=True,
show_name=True,
crease_angle=30.0,
margin_mm=12.5,
line_width_mm=0.8,
stroke_color=(0.1, 0.2, 0.4),
bg_color=(0.95, 0.98, 1.0),
raw_geometry=sg
)
self.assertTrue(os.path.exists(pdf_path))
with open(pdf_path, "rb") as f:
pdf_bytes = f.read()
pdf_text = pdf_bytes.decode("utf-8", errors="ignore")
# 1. Check PDF header & footer
self.assertTrue(pdf_bytes.startswith(b"%PDF-1.4"))
self.assertIn(b"%%EOF\n", pdf_bytes)
# 2. Check MediaBox
mediabox_match = re.search(r"/MediaBox\s+\[0\s+0\s+(\d+\.\d+)\s+(\d+\.\d+)\]", pdf_text)
self.assertIsNotNone(mediabox_match)
w_pt, h_pt = float(mediabox_match.group(1)), float(mediabox_match.group(2))
self.assertTrue(w_pt > 0 and h_pt > 0)
# 3. Check ExtGState opacities (/GS25, /GS50, /GS100)
self.assertIn("/GS25 5 0 R", pdf_text)
self.assertIn("/GS50 6 0 R", pdf_text)
self.assertIn("/GS100 7 0 R", pdf_text)
# 4. Check colors
self.assertIn("0.950 0.980 1.000 rg", pdf_text) # BG color
self.assertIn("0.100 0.200 0.400 RG", pdf_text) # Stroke color
# 5. Check object name rendering for both objects
self.assertIn("(BaseBracket) Tj", pdf_text)
self.assertIn("(HollowPipe) Tj", pdf_text)
# 6. Check size legend tick labels
self.assertIn("(10) Tj", pdf_text)
self.assertIn("(100 mm) Tj", pdf_text)
def test_export_pdf_with_settings_attributes(self):
so = SlicingObject()
so.name = "AttributedPart"
tri = 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.mesh = np.array([tri])
so.attributes = {
"infill_density": {"domain": "face", "type": "float", "values": np.array([20.0, 20.0])},
"layer_height": {"domain": "face", "type": "float", "values": np.array([0.15, 0.15])},
"material": ["PLA", "PLA"]
}
edges_2d = extract_unique_top_view_edges(so)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "test_settings_attr.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
show_name=True,
raw_geometry=so,
settings={"infill_density": "%", "layer_height": " mm", "material": ""}
)
self.assertTrue(os.path.exists(pdf_path))
with open(pdf_path, "rb") as f:
content = f.read().decode("utf-8", errors="ignore")
self.assertIn("(AttributedPart) Tj", content)
self.assertIn("(infill_density: 20%) Tj", content)
self.assertIn("(layer_height: 0.15 mm) Tj", content)
self.assertIn("(material: PLA) Tj", content)
def test_export_pdf_draw_sa2_measures(self):
so = SlicingObject()
so.name = "SA2Part"
tri = np.array([
[0.0, 0.0, 0.0],
[100.0, 0.0, 0.0],
[0.0, 50.0, 0.0],
[0.0, 0.0, 1.0]
], dtype=np.float64)
so.mesh = np.array([tri])
edges_2d = extract_unique_top_view_edges(so)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "test_sa2_measures.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
draw_sa2_holes=True,
raw_geometry=so
)
self.assertTrue(os.path.exists(pdf_path))
with open(pdf_path, "rb") as f:
content = f.read().decode("utf-8", errors="ignore")
# Check that SA2 crimson red color stroke and text measurements (100mm, 50mm) are generated
self.assertIn("0.850 0.150 0.150 RG", content)
self.assertIn("(100mm) Tj", content)
self.assertIn("(50mm) Tj", content)
def test_export_pdf_3d_solid_bore_detection(self):
num_sides = 16
angles = np.linspace(0, 2 * np.pi, num_sides, endpoint=False)
outer_top = np.column_stack([50 * np.cos(angles), 50 * np.sin(angles), np.full(num_sides, 20.0)])
outer_bot = np.column_stack([50 * np.cos(angles), 50 * np.sin(angles), np.full(num_sides, 0.0)])
inner_top = np.column_stack([15 * np.cos(angles), 15 * np.sin(angles), np.full(num_sides, 20.0)])
inner_bot = np.column_stack([15 * np.cos(angles), 15 * np.sin(angles), np.full(num_sides, 0.0)])
tris = []
for i in range(num_sides):
i_next = (i + 1) % num_sides
tris.append([outer_top[i], outer_top[i_next], inner_top[i], [0, 0, 1]])
tris.append([outer_top[i_next], inner_top[i_next], inner_top[i], [0, 0, 1]])
tris.append([outer_bot[i], inner_bot[i], outer_bot[i_next], [0, 0, -1]])
tris.append([outer_bot[i_next], inner_bot[i], inner_bot[i_next], [0, 0, -1]])
n_out = [np.cos(angles[i]), np.sin(angles[i]), 0]
tris.append([outer_bot[i], outer_top[i], outer_top[i_next], n_out])
tris.append([outer_bot[i], outer_top[i_next], outer_bot[i_next], n_out])
n_in = [-np.cos(angles[i]), -np.sin(angles[i]), 0]
tris.append([inner_bot[i], inner_top[i_next], inner_top[i], n_in])
tris.append([inner_bot[i], inner_bot[i_next], inner_top[i_next], n_in])
so = SlicingObject()
so.name = "SolidBoreCylinder"
so.mesh = np.array(tris, dtype=np.float64)
edges_2d = extract_unique_top_view_edges(so)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "test_solid_bore.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
draw_sa2_holes=True,
raw_geometry=so
)
self.assertTrue(os.path.exists(pdf_path))
with open(pdf_path, "rb") as f:
raw_bytes = f.read()
content = raw_bytes.decode("utf-8", errors="ignore")
# ISO circular bore callouts (Electric Blue 0.100 0.450 0.900 RG)
self.assertIn("0.100 0.450 0.900 RG", content)
self.assertIn("THRU", content)
def test_export_pdf_font_size(self):
so = SlicingObject()
so.name = "CustomFontPart"
so.mesh = np.array([
[[0, 0, 0], [50, 0, 0], [50, 50, 0]],
[[0, 0, 0], [50, 50, 0], [0, 50, 0]]
], dtype=np.float64)
edges_2d = extract_unique_top_view_edges(so)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "test_custom_font.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
font_size_mm=5.12,
show_name=True,
raw_geometry=so
)
self.assertTrue(os.path.exists(pdf_path))
with open(pdf_path, "rb") as f:
content = f.read().decode("utf-8", errors="ignore")
self.assertIn("/F1 14.5 Tf", content)
def test_export_pdf_league_mono_font_embedded(self):
so = SlicingObject()
so.name = "LeagueMonoTestPart"
so.mesh = np.array([
[[0, 0, 0], [10, 0, 0], [10, 10, 0]],
[[0, 0, 0], [10, 10, 0], [0, 10, 0]]
], dtype=np.float64)
edges_2d = extract_unique_top_view_edges(so)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "test_league_mono.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
show_name=True,
raw_geometry=so
)
self.assertTrue(os.path.exists(pdf_path))
with open(pdf_path, "rb") as f:
content = f.read().decode("utf-8", errors="ignore")
self.assertIn("LeagueMono-Regular", content)
self.assertIn("/Subtype /OpenType", content)
def test_sa2_source_geometry_matches_visible_selected(self):
so = SlicingObject()
so.name = "VisibilityTestPart"
tris = [
[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0], [0.0, 0.0, 1.0]],
[[10.0, 0.0, 0.0], [10.0, 10.0, 0.0], [0.0, 10.0, 0.0], [0.0, 0.0, 1.0]]
]
so.mesh = np.array(tris, dtype=np.float64)
# 1. When no geometry is selected as visible (all False), extract_slicing_object_features returns empty
h_none = extract_slicing_object_features(
so, draw_wireframe=False, draw_boundary=False, draw_contour=False, draw_crease=False
)
self.assertEqual(h_none, [])
# 2. When draw_boundary=True, features are extracted from boundary edges
h_b = extract_slicing_object_features(
so, draw_wireframe=False, draw_boundary=True, draw_contour=False, draw_crease=False
)
# 3. When draw_wireframe=True, features are extracted from top-view wireframe edges
h_w = extract_slicing_object_features(
so, draw_wireframe=True, draw_boundary=False, draw_contour=False, draw_crease=False
)
self.assertIsNotNone(h_w)
def test_pdf_properties_dataclass_on_slicing_object(self):
from NodeSlicer.infra.blender.blender_mesh_capture import PdfProperties
so = SlicingObject()
self.assertIsInstance(so.pdf_properties, PdfProperties)
self.assertEqual(so.pdf_properties.line_width, 0.0)
self.assertIsNone(so.pdf_properties.line_color)
self.assertFalse(so.pdf_properties.show_name)
so.pdf_properties = PdfProperties(line_width=1.5, line_color=(1.0, 0.0, 0.0), show_name=True)
self.assertEqual(so.pdf_properties.line_width, 1.5)
self.assertEqual(so.pdf_properties.line_color, (1.0, 0.0, 0.0))
self.assertTrue(so.pdf_properties.show_name)
def test_assign_pdf_properties_node_and_helper(self):
from NodeSlicer.infra.blender.blender_mesh_capture import PdfProperties
from NodeSlicer.core.nodeslicer.nodes.mesh import _assign_pdf_properties_to_item, SLICENODE_AssignPdfProperties
so1 = SlicingObject()
so1.name = "Obj1"
so1.mesh = np.array([[[0, 0, 0], [10, 0, 0], [0, 10, 0], [0, 0, 1]]], dtype=np.float64)
so2 = SlicingObject()
so2.name = "Obj2"
so2.mesh = np.array([[[20, 20, 0], [30, 20, 0], [20, 30, 0], [0, 0, 1]]], dtype=np.float64)
sc1 = SlicingCollection([], "Coll1")
sc1.objects = [so1]
sc2 = SlicingCollection([], "Coll2")
sc2.objects = [so2]
sg = SlicingGroup([])
sg.collections = {"Coll1": sc1, "Coll2": sc2}
# Apply properties with show_name=True
updated_sg = _assign_pdf_properties_to_item(sg, line_width=2.5, line_color=(0.5, 0.5, 1.0), show_name=True)
for coll in updated_sg.collections.values():
for obj in coll.objects:
self.assertEqual(obj.pdf_properties.line_width, 2.5)
self.assertEqual(obj.pdf_properties.line_color, (0.5, 0.5, 1.0))
self.assertTrue(obj.pdf_properties.show_name)
def test_export_pdf_with_per_object_pdf_properties(self):
from NodeSlicer.infra.blender.blender_mesh_capture import PdfProperties
import tempfile
so1 = SlicingObject()
so1.name = "RedBox"
so1.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)
so1.pdf_properties = PdfProperties(line_width=2.0, line_color=(1.0, 0.0, 0.0), show_name=True)
so2 = SlicingObject()
so2.name = "GreenBox"
so2.mesh = np.array([[[20.0, 20.0, 0.0], [30.0, 20.0, 0.0], [20.0, 30.0, 0.0], [0.0, 0.0, 1.0]]], dtype=np.float64)
so2.pdf_properties = PdfProperties(line_width=0.5, line_color=(0.0, 1.0, 0.0), show_name=False)
sc1 = SlicingCollection([], "Coll1")
sc1.objects = [so1]
sc2 = SlicingCollection([], "Coll2")
sc2.objects = [so2]
sg = SlicingGroup([])
sg.collections = {"Coll1": sc1, "Coll2": sc2}
edges_2d = extract_unique_top_view_edges(sg)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "custom_pdf_props.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
draw_wireframe=True,
stroke_color=(0.0, 0.0, 0.0),
raw_geometry=sg
)
with open(pdf_path, "rb") as f:
content = decompress_pdf_streams(f.read())
# Check red color (1.000 0.000 0.000 RG) and green color (0.000 1.000 0.000 RG) present
self.assertIn("1.000 0.000 0.000 RG", content)
self.assertIn("0.000 1.000 0.000 RG", content)
# 2.0 mm -> 2.0 * 2.834645 = 5.67 w
self.assertIn("5.67 w", content)
# Check RedBox label is rendered (show_name=True on so1)
self.assertIn("(RedBox) Tj", content)
self.assertNotIn("(GreenBox) Tj", content)
# 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
import tempfile
so = SlicingObject()
so.name = "DefaultPropsBox"
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)
# Default pdf_properties (line_width=0.0, line_color=None, show_name=False)
so.pdf_properties = PdfProperties()
edges_2d = extract_unique_top_view_edges(so)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "fallback_pdf_props.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
draw_wireframe=True,
stroke_color=(0.2, 0.4, 0.6),
raw_geometry=so
)
with open(pdf_path, "rb") as f:
content = decompress_pdf_streams(f.read())
# Wireframe color (0.200 0.400 0.600 RG) should be used
self.assertIn("0.200 0.400 0.600 RG", content)
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
# 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_mesh_to_pdf_single_geometry_and_slicepaths_inputs(self):
from NodeSlicer.core.nodeslicer.nodes.output import SLICENODE_MeshToPdf
node = SLICENODE_MeshToPdf.__new__(SLICENODE_MeshToPdf)
node.draw_wireframe = True
node.draw_boundary = False
node.draw_contour = False
node.draw_crease = False
node.crease_angle = 30.0
node.draw_bbox = False
node.draw_sa2_holes = False
node.draw_header = True
node.header_company = ""
node.header_part_name = ""
node.header_drawing_number = ""
node.header_author = ""
node.header_revision = "A"
node.header_material = ""
node.header_tolerance = ""
node.font_size = 0.0
node.margin = 10.0
node.wire_color = (0.0, 0.0, 0.0)
node.bg_color = (1.0, 1.0, 1.0)
node.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 = "TestObj"
so.mesh = np.array([[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0]]], dtype=np.float64)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_file = os.path.join(tmpdir, "output.pdf")
resolved_inputs = {
"Geometry": so,
"Paths": None,
"Folder": pdf_file,
"Settings": None,
}
node.resolve = lambda fieldname: resolved_inputs.get(fieldname)
res = node.evaluate(operator=op)
self.assertEqual(res, "ok", msg=f"Reports: {op.reports}")
self.assertTrue(os.path.exists(pdf_file))
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)
def test_exporter_as_class_explicit_export(self):
edges_2d = np.array([
[[0.0, 0.0], [10.0, 0.0]],
[[10.0, 0.0], [10.0, 10.0]],
[[10.0, 10.0], [0.0, 10.0]],
[[0.0, 10.0], [0.0, 0.0]]
], dtype=np.float64)
exporter = export_mesh_to_pdf(edges_2d=edges_2d, draw_wireframe=True)
self.assertIsInstance(exporter, export_mesh_to_pdf)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "class_explicit_export.pdf")
exporter.export(pdf_path)
self.assertTrue(os.path.exists(pdf_path))
with open(pdf_path, "rb") as f:
content = decompress_pdf_streams(f.read())
self.assertIn("PDF-1.4", content)
def test_exporter_element_subclasses(self):
from NodeSlicer.core.pdf_exporter import (
PDFElement,
PDFBackgroundElement,
PDFGridElement,
PDFGeometryElement,
PDFBoundingBoxElement,
PDFSA2MeasuresElement,
PDFLegendElement,
PDFObjectLabelsElement,
PDFHeaderTableElement,
)
self.assertTrue(issubclass(PDFLegendElement, PDFElement))
self.assertTrue(issubclass(PDFHeaderTableElement, PDFElement))
self.assertTrue(issubclass(PDFGridElement, PDFElement))
self.assertTrue(issubclass(PDFGeometryElement, PDFElement))
self.assertTrue(issubclass(PDFBoundingBoxElement, PDFElement))
self.assertTrue(issubclass(PDFSA2MeasuresElement, PDFElement))
self.assertTrue(issubclass(PDFObjectLabelsElement, PDFElement))
self.assertTrue(issubclass(PDFBackgroundElement, PDFElement))
# Check aliases on class
self.assertEqual(export_mesh_to_pdf.Legend, PDFLegendElement)
self.assertEqual(export_mesh_to_pdf.Table, PDFHeaderTableElement)
self.assertEqual(export_mesh_to_pdf.HeaderTable, PDFHeaderTableElement)
self.assertEqual(export_mesh_to_pdf.Grid, PDFGridElement)
self.assertEqual(export_mesh_to_pdf.Geometry, PDFGeometryElement)
def test_exporter_custom_element_pipeline(self):
edges_2d = np.array([[[0.0, 0.0], [10.0, 0.0]]], dtype=np.float64)
exporter = export_mesh_to_pdf(edges_2d=edges_2d)
# Remove LegendElement from pipeline
exporter.elements = [e for e in exporter.elements if not isinstance(e, export_mesh_to_pdf.Legend)]
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "no_legend.pdf")
exporter.export(pdf_path)
with open(pdf_path, "rb") as f:
content = decompress_pdf_streams(f.read())
# X and Y axis text labels from Legend should be absent
self.assertNotIn("(X) Tj", content)
self.assertNotIn("(Y) Tj", content)
def test_header_table_positioned_to_right_of_drawing(self):
edges_2d = np.array([[[0.0, 0.0], [10.0, 0.0]]], dtype=np.float64)
exp_no_header = export_mesh_to_pdf(edges_2d=edges_2d, draw_wireframe=True, draw_header=False)
exp_with_header = export_mesh_to_pdf(edges_2d=edges_2d, draw_wireframe=True, draw_header=True, header_info={})
w_table_pt = exp_with_header.font_size_pt * 25.0
# Page width with header is wider by the exact width of the table
self.assertAlmostEqual(exp_with_header.page_w_pt, exp_no_header.page_w_pt + w_table_pt, places=4)
# Drawing grid right edge in PDF points
w_grid_mm = exp_with_header.grid_max_x - exp_with_header.grid_min_x
grid_right_pt = (exp_with_header.total_margin_mm + w_grid_mm) * MM_TO_PT
# Table left edge in PDF points
table_left_pt = exp_with_header.page_w_pt - (exp_with_header.total_margin_mm * MM_TO_PT) - w_table_pt
# Table left edge aligns precisely with drawing grid right edge
self.assertAlmostEqual(table_left_pt, grid_right_pt, places=4)
# Drawing grid top edge & table top edge in PDF points
grid_top_pt = exp_with_header.page_h_pt - (exp_with_header.total_margin_mm * MM_TO_PT)
table_top_pt = exp_with_header.page_h_pt - (exp_with_header.total_margin_mm * MM_TO_PT)
self.assertAlmostEqual(table_top_pt, grid_top_pt, places=4)
def test_assign_property_to_object_node_and_helper(self):
from NodeSlicer.core.nodeslicer.nodes.mesh import _assign_properties_to_item, SLICENODE_AssignPropertyToObject
so = SlicingObject()
so.name = "TestBracket"
so.mesh = np.array([[[0, 0, 0], [10, 0, 0], [0, 10, 0], [0, 0, 1]]], dtype=np.float64)
sc = SlicingCollection([], "Coll1")
sc.objects = [so]
sg = SlicingGroup([])
sg.collections = {"Coll1": sc}
props = [("", "Text Only Property"), ("Infill", "40%"), ("Layer", "0.2mm")]
updated_sg = _assign_properties_to_item(sg, props)
for coll in updated_sg.collections.values():
for obj in coll.objects:
self.assertEqual(obj.object_properties, props)
# Also verify SlicePaths support
from NodeSlicer.core.slice import SlicePaths
sp = SlicePaths([np.array([[0, 0, 0], [10, 10, 0]], dtype=np.float64)])
updated_sp = _assign_properties_to_item(sp, props)
self.assertEqual(updated_sp.object_properties, props)
# Verify dynamic socket color logic
from NodeSlicer.core.nodeslicer.sockets import SLICESOCK_Geometry, SLICESOCK_Paths, SLICESOCK_Mesh
class DummyLink:
def __init__(self, from_sock):
self.from_socket = from_sock
class DummyNode:
def __init__(self):
self.inputs = {}
dummy_node = DummyNode()
in_geom = SLICESOCK_Geometry()
in_geom.is_output = False
in_geom.is_linked = True
in_geom.name = "Geometry"
paths_out = SLICESOCK_Paths()
in_geom.links = [DummyLink(paths_out)]
dummy_node.inputs["Geometry"] = in_geom
out_geom = SLICESOCK_Geometry()
out_geom.is_output = True
# Input socket draws Paths color (pink)
self.assertEqual(in_geom.draw_color(None, dummy_node), paths_out.bl_color)
# Output socket on same node inherits Paths color (pink) from input socket
self.assertEqual(out_geom.draw_color(None, dummy_node), paths_out.bl_color)
# Switch upstream to Mesh (cyan)
mesh_out = SLICESOCK_Mesh()
in_geom.links = [DummyLink(mesh_out)]
self.assertEqual(in_geom.draw_color(None, dummy_node), mesh_out.bl_color)
self.assertEqual(out_geom.draw_color(None, dummy_node), mesh_out.bl_color)
def test_export_pdf_object_properties_table(self):
so1 = SlicingObject()
so1.name = "BracketA"
so1.mesh = np.array([[[0, 0, 0], [10, 0, 0], [0, 10, 0], [0, 0, 1]]], dtype=np.float64)
so2 = SlicingObject()
so2.name = "BracketB"
so2.mesh = np.array([[[20, 20, 0], [30, 20, 0], [20, 30, 0], [0, 0, 1]]], dtype=np.float64)
sc1 = SlicingCollection([], "Coll1")
sc1.objects = [so1]
sc2 = SlicingCollection([], "Coll2")
sc2.objects = [so2]
sg = SlicingGroup([])
sg.collections = {"Coll1": sc1, "Coll2": sc2}
# Assign properties to so1 only
so1.object_properties = [("", "Material: PETG"), ("Tolerance", "ISO 2768-f")]
edges_2d = extract_unique_top_view_edges(sg)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "obj_props_table.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
draw_wireframe=True,
draw_header=True,
header_info={"company": "Acme Inc"},
raw_geometry=sg,
)
self.assertTrue(os.path.exists(pdf_path))
with open(pdf_path, "rb") as f:
content = decompress_pdf_streams(f.read())
# Check bold object name in table
self.assertIn("(BracketA) Tj", content)
self.assertIn("2 Tr", content)
# Check single line text property
self.assertIn("(Material: PETG) Tj", content)
# Check pair ID-value property
self.assertIn("(Tolerance) Tj", content)
self.assertIn("(ISO 2768-f) Tj", content)
# Object B has no properties, so no box for BracketB in object properties table
# Note: BracketB name is also not rendered in PDF since show_name=False
self.assertNotIn("(BracketB) Tj", content)
def test_assign_property_resolves_attribute_at_index_zero(self):
from NodeSlicer.core.nodeslicer.nodes.mesh import _assign_properties_to_item
so = SlicingObject()
so.name = "AttrObject"
so.mesh = np.array([[[0, 0, 0], [10, 0, 0], [0, 10, 0], [0, 0, 1]]], dtype=np.float64)
so.attributes = {
"infill_density": {"domain": "face", "type": "float", "values": np.array([15.5, 15.5])},
"material": ["ABS", "ABS"],
}
sc = SlicingCollection([], "Coll")
sc.objects = [so]
sg = SlicingGroup([])
sg.collections = {"Coll": sc}
# Tuple where 1st position exists as attribute and 2nd position is empty
props = [("infill_density", ""), ("material", ""), ("custom_setting", "fixed_value")]
updated_sg = _assign_properties_to_item(sg, props)
res_obj = updated_sg.collections["Coll"].objects[0]
self.assertEqual(
res_obj.object_properties,
[("infill_density", "15.5"), ("material", "ABS"), ("custom_setting", "fixed_value")]
)
edges_2d = extract_unique_top_view_edges(sg)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "attr_index_zero.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
draw_wireframe=True,
raw_geometry=updated_sg,
)
with open(pdf_path, "rb") as f:
content = decompress_pdf_streams(f.read())
self.assertIn("(AttrObject) Tj", content)
self.assertIn("(infill_density) Tj", content)
self.assertIn("(15.5) Tj", content)
self.assertIn("(material) Tj", content)
self.assertIn("(ABS) Tj", content)
def test_object_name_drawing_white_contour(self):
so = SlicingObject()
so.name = "ContourObj"
so.mesh = np.array([[[0, 0, 0], [10, 0, 0], [0, 10, 0], [0, 0, 1]]], dtype=np.float64)
sc = SlicingCollection([], "Coll")
sc.objects = [so]
sg = SlicingGroup([])
sg.collections = {"Coll": sc}
edges_2d = extract_unique_top_view_edges(sg)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "white_contour.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
show_name=True,
draw_wireframe=True,
raw_geometry=sg,
)
with open(pdf_path, "rb") as f:
content = decompress_pdf_streams(f.read())
self.assertIn("(ContourObj) Tj", content)
# Verify white fill/stroke color pass exists for contour
self.assertIn("1.000 1.000 1.000 rg", content)
self.assertIn("1.000 1.000 1.000 RG", content)
def test_slicepaths_pdf_export_default_style_and_properties(self):
from NodeSlicer.core.slice import SlicePaths
from NodeSlicer.core.nodeslicer.nodes.mesh import _assign_pdf_properties_to_item, _assign_properties_to_item
sp = SlicePaths([np.array([[0.0, 0.0], [50.0, 0.0], [50.0, 50.0], [0.0, 50.0]], dtype=np.float64)])
sp.name = "TestSlicePath"
# 1. Export with default style
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "slicepath_default.pdf")
export_mesh_to_pdf(
slice_paths=sp,
filepath=pdf_path,
draw_wireframe=True,
)
self.assertTrue(os.path.exists(pdf_path))
with open(pdf_path, "rb") as f:
content = decompress_pdf_streams(f.read())
self.assertTrue(len(content) > 0)
# 2. Assign PDF properties (custom width, color, show_name)
sp_pdf_props = _assign_pdf_properties_to_item(sp, line_width=3.0, line_color=(1.0, 0.0, 0.0), show_name=True)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "slicepath_pdf_props.pdf")
export_mesh_to_pdf(
slice_paths=sp_pdf_props,
filepath=pdf_path,
draw_wireframe=True,
)
with open(pdf_path, "rb") as f:
content = decompress_pdf_streams(f.read())
self.assertIn("1.000 0.000 0.000 RG", content)
self.assertIn("(TestSlicePath) Tj", content)
# 3. Assign Property to Object (table properties)
props = [("Layer Height", "0.2mm"), ("Speed", "60mm/s")]
sp_props = _assign_properties_to_item(sp, props)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "slicepath_object_props.pdf")
export_mesh_to_pdf(
slice_paths=sp_props,
filepath=pdf_path,
draw_wireframe=True,
)
with open(pdf_path, "rb") as f:
content = decompress_pdf_streams(f.read())
self.assertIn("(Layer Height) Tj", content)
self.assertIn("(0.2mm) Tj", content)
self.assertIn("(Speed) Tj", content)
self.assertIn("(60mm/s) Tj", content)
def test_mesh_to_pdf_paths_input_only_and_combined(self):
from NodeSlicer.core.slice import SlicePaths
from NodeSlicer.core.nodeslicer.nodes.output import SLICENODE_MeshToPdf
node = SLICENODE_MeshToPdf.__new__(SLICENODE_MeshToPdf)
node.draw_wireframe = True
node.draw_boundary = False
node.draw_contour = False
node.draw_crease = False
node.crease_angle = 30.0
node.draw_bbox = False
node.draw_sa2_holes = False
node.draw_header = False
node.header_company = ""
node.header_part_name = ""
node.header_drawing_number = ""
node.header_author = ""
node.header_revision = ""
node.header_material = ""
node.header_tolerance = ""
node.font_size = 0.0
node.margin = 10.0
node.wire_color = (0.0, 0.0, 0.0)
node.bg_color = (1.0, 1.0, 1.0)
node.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))
sp = SlicePaths([np.array([[5.0, 5.0], [25.0, 25.0]], dtype=np.float64)])
sp.name = "PathOnly"
# Case A: Only Paths connected (Geometry is None)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_file = os.path.join(tmpdir, "paths_only.pdf")
op = MockOperator()
resolved = {
"Geometry": None,
"Paths": sp,
"Folder": pdf_file,
}
node.resolve = lambda k: resolved.get(k)
res = node.evaluate(operator=op)
self.assertEqual(res, "ok", msg=f"Reports: {op.reports}")
self.assertTrue(os.path.exists(pdf_file))
with open(pdf_file, "rb") as f:
content = decompress_pdf_streams(f.read())
self.assertIn(" m ", content)
# Case B: Both Geometry and Paths connected
so = SlicingObject()
so.name = "MeshObj"
so.mesh = np.array([[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0]]], dtype=np.float64)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_file = os.path.join(tmpdir, "combined.pdf")
op = MockOperator()
resolved = {
"Geometry": so,
"Paths": sp,
"Folder": pdf_file,
}
node.resolve = lambda k: resolved.get(k)
res = node.evaluate(operator=op)
self.assertEqual(res, "ok", msg=f"Reports: {op.reports}")
self.assertTrue(os.path.exists(pdf_file))
with open(pdf_file, "rb") as f:
content = decompress_pdf_streams(f.read())
self.assertIn(" m ", content)
# Case C: Geometry is an empty list [] and Paths is connected
with tempfile.TemporaryDirectory() as tmpdir:
pdf_file = os.path.join(tmpdir, "empty_geom_list.pdf")
op = MockOperator()
resolved = {
"Geometry": [],
"Paths": sp,
"Folder": pdf_file,
}
node.resolve = lambda k: resolved.get(k)
res = node.evaluate(operator=op)
self.assertEqual(res, "ok", msg=f"Reports: {op.reports}")
self.assertTrue(os.path.exists(pdf_file))
with open(pdf_file, "rb") as f:
content = decompress_pdf_streams(f.read())
self.assertIn(" m ", content)
def test_get_first_object_name(self):
from NodeSlicer.core.pdf_exporter import get_first_object_name
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject, SlicingGroup, SlicingCollection
from NodeSlicer.core.slice import SlicePaths
so = SlicingObject()
so.name = "FirstObj"
self.assertEqual(get_first_object_name(so), "FirstObj")
sp = SlicePaths()
sp.name = "PathObj"
self.assertEqual(get_first_object_name(sp), "PathObj")
self.assertEqual(get_first_object_name([None, so]), "FirstObj")
coll = SlicingCollection.__new__(SlicingCollection)
coll.objects = [so]
coll.name = "CollName"
group = SlicingGroup.__new__(SlicingGroup)
group.collections = {"GroupKey": coll}
self.assertEqual(get_first_object_name(group), "FirstObj")
def test_mesh_to_pdf_default_part_name_first_object(self):
from NodeSlicer.core.nodeslicer.nodes.output import SLICENODE_MeshToPdf
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject
node = SLICENODE_MeshToPdf.__new__(SLICENODE_MeshToPdf)
node.draw_wireframe = True
node.draw_boundary = False
node.draw_contour = False
node.draw_crease = False
node.crease_angle = 30.0
node.draw_bbox = False
node.draw_sa2_holes = False
node.draw_header = True
node.header_company = ""
node.header_part_name = "" # Empty field -> should default to object name
node.header_drawing_number = ""
node.header_author = ""
node.header_revision = "A"
node.header_material = ""
node.header_tolerance = ""
node.font_size = 0.0
node.margin = 10.0
node.wire_color = (0.0, 0.0, 0.0)
node.bg_color = (1.0, 1.0, 1.0)
node.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 = "GearWheel"
so.mesh = np.array([[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0]]], dtype=np.float64)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_file = os.path.join(tmpdir, "output.pdf")
resolved_inputs = {
"Geometry": so,
"Paths": None,
"Folder": pdf_file,
"Settings": None,
}
node.resolve = lambda fieldname: resolved_inputs.get(fieldname)
res = node.evaluate(operator=op)
self.assertEqual(res, "ok")
self.assertTrue(os.path.exists(pdf_file))
with open(pdf_file, "rb") as f:
content = decompress_pdf_streams(f.read())
self.assertIn("(GearWheel) Tj", content)
if __name__ == '__main__':
unittest.main()