nodeslicer/tests/test_pdf_exporter.py
2026-07-31 15:15:31 +03:00

734 lines
29 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
)
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_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 120mm
expected_w = (120.0 + 2 * total_margin_mm) * MM_TO_PT
expected_h = (120.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)
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,
show_bbox_size=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)
if __name__ == '__main__':
unittest.main()