1235 lines
50 KiB
Python
1235 lines
50 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
|
||
|
||
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_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=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,
|
||
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)
|
||
|
||
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_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)
|
||
|
||
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)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
unittest.main()
|
||
|
||
|
||
|
||
|