nodeslicer/tests/test_pdf_exporter.py

485 lines
18 KiB
Python

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
mock_types = MagicMock()
mock_types.NodeSocket = DummyNodeSocket
mock_types.Node = DummyNode
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()
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,
export_wireframe_to_pdf,
export_mesh_to_pdf,
MM_TO_PT
)
class TestPdfExporter(unittest.TestCase):
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_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
expected_w = (100.0 + 2 * total_margin_mm) * MM_TO_PT
expected_h = (100.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_show_bbox_size(self):
so = SlicingObject()
so.name = "GearPart"
tri = np.array([
[10.0, 20.0, 0.0],
[130.0, 20.0, 0.0],
[10.0, 250.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_bbox.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
show_name=True,
show_bbox_size=True,
raw_geometry=so
)
self.assertTrue(os.path.exists(pdf_path))
with open(pdf_path, "rb") as f:
content = f.read()
# Width = 120mm, Height = 230mm -> Line 1: (GearPart) Tj, Line 2: (\(X: 120mm Y: 230mm\)) Tj
self.assertIn(b"(GearPart) Tj", content)
self.assertIn(b"(\\(X: 120mm Y: 230mm\\)) 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=2)
self.assertAlmostEqual(drawn_obj_h_pt, 80.0 * (72.0 / 25.4), places=2)
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)
if __name__ == '__main__':
unittest.main()