wf viz
This commit is contained in:
parent
fda0042ba0
commit
9d2d948d89
5 changed files with 519 additions and 1 deletions
|
|
@ -7,7 +7,7 @@ import tempfile
|
|||
import re
|
||||
import json
|
||||
|
||||
from ..bpy_ns_types import NfStringProperty
|
||||
from ..bpy_ns_types import NfStringProperty, NfFloatProperty, NfEnumProperty, NfFloatVectorProperty
|
||||
from ....registry import register_class
|
||||
from .base import ExecSliceNode, SlicePaths, SliceNode, SlicingCollection, SlicingGroup
|
||||
|
||||
|
|
@ -350,3 +350,105 @@ class SLICENODE_AirtableSubmit(ExecSliceNode_DatabaseSubmit):
|
|||
|
||||
db.upload_attachment(self.base_id, record_id, 'GCODE', temp_path)
|
||||
return str(record_id)
|
||||
|
||||
|
||||
@register_class
|
||||
class SLICENODE_WireframeToPdf(ExecSliceNode):
|
||||
bl_label = "Wireframe to PDF"
|
||||
button_label = "Generate PDF"
|
||||
|
||||
line_width: NfFloatProperty(name="Line Width (mm)", default=0.5, min=.0) # pyright: ignore[reportInvalidTypeForm]
|
||||
margin: NfFloatProperty(name="Margin (mm)", default=10.0, min=.0) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
bg_color: NfFloatVectorProperty(
|
||||
name="Background",
|
||||
subtype='COLOR',
|
||||
default=(1.0, 1.0, 1.0),
|
||||
min=0.0,
|
||||
max=1.0,
|
||||
size=3
|
||||
) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
wire_color: NfFloatVectorProperty(
|
||||
name="Wireframe",
|
||||
subtype='COLOR',
|
||||
default=(0.0, 0.0, 0.0),
|
||||
min=0.0,
|
||||
max=1.0,
|
||||
size=3
|
||||
) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
def init(self, context):
|
||||
self._init_exec()
|
||||
self.add_input("SLICESOCK_DirPath", "Folder")
|
||||
self.add_multi_socket_group("SLICESOCK_Mesh", name="Geometry")
|
||||
|
||||
def draw_buttons(self, context, layout):
|
||||
super().draw_buttons(context, layout)
|
||||
layout.prop(self, "line_width")
|
||||
layout.prop(self, "margin")
|
||||
layout.prop(self, "bg_color")
|
||||
layout.prop(self, "wire_color")
|
||||
|
||||
def evaluate(self, operator=None):
|
||||
from ...pdf_exporter import extract_unique_top_view_edges, export_wireframe_to_pdf
|
||||
|
||||
geometries: list[Any] = [g for g in self.resolve_multi_input('Geometry') if g is not None]
|
||||
folder: str = self.resolve("Folder") or ""
|
||||
|
||||
if not geometries:
|
||||
if operator: operator.report({'ERROR'}, "No Geometry connected")
|
||||
return 'error'
|
||||
|
||||
if not folder:
|
||||
if operator: operator.report({'ERROR'}, "No Folder provided")
|
||||
return 'error'
|
||||
|
||||
abs_folder = bpy.path.abspath(f"//{folder}" if not folder.startswith(("/", "\\")) and ":" not in folder else folder)
|
||||
folder_path = Path(abs_folder)
|
||||
|
||||
is_single_pdf_file = len(geometries) == 1 and folder_path.suffix.lower() == ".pdf"
|
||||
|
||||
if is_single_pdf_file:
|
||||
os.makedirs(folder_path.parent, exist_ok=True)
|
||||
target_list = [(geometries[0], str(folder_path))]
|
||||
else:
|
||||
out_dir = folder_path if folder_path.suffix == "" else folder_path.parent
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
target_list = []
|
||||
for i, geom in enumerate(geometries):
|
||||
if isinstance(geom, SlicingGroup) and geom.collections:
|
||||
raw_name = "_".join(geom.collections.keys())
|
||||
elif hasattr(geom, 'name') and getattr(geom, 'name'):
|
||||
raw_name = str(getattr(geom, 'name'))
|
||||
else:
|
||||
raw_name = f"object_{i}"
|
||||
|
||||
name_part = raw_name.split('.')[0] if raw_name else f"object_{i}"
|
||||
filename = f"{name_part}.pdf"
|
||||
target_list.append((geom, str(out_dir / filename)))
|
||||
|
||||
for geom, filepath in target_list:
|
||||
edges_2d = extract_unique_top_view_edges(geom)
|
||||
if len(edges_2d) == 0:
|
||||
if operator: operator.report({'WARNING'}, f"Geometry for {os.path.basename(filepath)} has no valid 2D top-view wireframe edges")
|
||||
|
||||
try:
|
||||
export_wireframe_to_pdf(
|
||||
edges_2d=edges_2d,
|
||||
filepath=filepath,
|
||||
margin_mm=self.margin,
|
||||
line_width_mm=self.line_width,
|
||||
stroke_color=(float(self.wire_color[0]), float(self.wire_color[1]), float(self.wire_color[2])),
|
||||
bg_color=(float(self.bg_color[0]), float(self.bg_color[1]), float(self.bg_color[2]))
|
||||
)
|
||||
if operator: operator.report({'INFO'}, f"Wireframe PDF saved to {filepath}")
|
||||
except Exception as e:
|
||||
if operator: operator.report({'ERROR'}, f"PDF Generation Error: {str(e)}")
|
||||
return 'error'
|
||||
|
||||
return 'ok'
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -560,6 +560,7 @@ class SLICENODE_MT_ActionIO(Menu):
|
|||
layout = self.layout
|
||||
assert layout
|
||||
layout.operator("node.add_node", text="Gcode to File").type = "SLICENODE_GcodeToFile"
|
||||
layout.operator("node.add_node", text="Wireframe to PDF").type = "SLICENODE_WireframeToPdf"
|
||||
layout.operator("node.add_node", text="Gcode to Text Block").type = "SLICENODE_GcodeToTextBlock"
|
||||
layout.operator("node.add_node", text="Start Print").type = "SLICENODE_StartPrint"
|
||||
layout.separator()
|
||||
|
|
|
|||
274
core/pdf_exporter.py
Normal file
274
core/pdf_exporter.py
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
from __future__ import annotations
|
||||
from typing import Any
|
||||
import os
|
||||
import math
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
MM_TO_PT = 72.0 / 25.4 # 1 mm = 2.8346456692913386 PostScript points
|
||||
|
||||
|
||||
def extract_unique_top_view_edges(mesh_data: Any) -> NDArray[np.float64]:
|
||||
"""
|
||||
Extracts unique 2D line segments projected orthographically from top (+Z).
|
||||
Handles SlicingGroup, SlicingCollection, SlicingObject, or lists/dicts of them.
|
||||
Returns array of shape (N, 2, 2) where each element is [(x0, y0), (x1, y1)] in mm.
|
||||
"""
|
||||
from ..infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
|
||||
|
||||
all_edge_segments: list[NDArray[np.float64]] = []
|
||||
|
||||
def process_item(item: Any):
|
||||
if item is None:
|
||||
return
|
||||
elif isinstance(item, (list, tuple)):
|
||||
for sub in item:
|
||||
process_item(sub)
|
||||
elif isinstance(item, SlicingGroup):
|
||||
for coll in item.collections.values():
|
||||
process_item(coll)
|
||||
elif isinstance(item, SlicingCollection):
|
||||
all_verts, all_idxs = item.unique_verts
|
||||
if len(all_idxs) > 0:
|
||||
edges = np.vstack([
|
||||
all_idxs[:, [0, 1]],
|
||||
all_idxs[:, [1, 2]],
|
||||
all_idxs[:, [2, 0]]
|
||||
])
|
||||
sorted_edges = np.sort(edges, axis=1)
|
||||
unique_edge_indices = np.unique(sorted_edges, axis=0)
|
||||
edge_3d = all_verts[unique_edge_indices] # shape (E, 2, 3)
|
||||
all_edge_segments.append(edge_3d[:, :, :2]) # top view (X, Y)
|
||||
elif isinstance(item, SlicingObject):
|
||||
if item.mesh.size > 0:
|
||||
verts_3d = item.mesh[:, :3, :3] # (N, 3, 3)
|
||||
verts_2d = verts_3d[:, :, :2] # (N, 3, 2)
|
||||
e0 = verts_2d[:, [0, 1], :]
|
||||
e1 = verts_2d[:, [1, 2], :]
|
||||
e2 = verts_2d[:, [2, 0], :]
|
||||
edges_2d = np.concatenate([e0, e1, e2], axis=0) # (3N, 2, 2)
|
||||
all_edge_segments.append(edges_2d)
|
||||
|
||||
process_item(mesh_data)
|
||||
|
||||
if not all_edge_segments:
|
||||
return np.empty((0, 2, 2), dtype=np.float64)
|
||||
|
||||
combined_edges = np.concatenate(all_edge_segments, axis=0)
|
||||
if combined_edges.shape[0] == 0:
|
||||
return np.empty((0, 2, 2), dtype=np.float64)
|
||||
|
||||
# Filter out near-zero-length 2D edges (e.g. vertical edges seen from top)
|
||||
dists_sq = np.sum((combined_edges[:, 0, :] - combined_edges[:, 1, :]) ** 2, axis=1)
|
||||
valid_mask = dists_sq > 1e-8
|
||||
valid_edges = combined_edges[valid_mask]
|
||||
if valid_edges.shape[0] == 0:
|
||||
return np.empty((0, 2, 2), dtype=np.float64)
|
||||
|
||||
# Deduplicate 2D edges regardless of endpoint orientation
|
||||
rounded = np.round(valid_edges, decimals=5)
|
||||
swap_mask = (rounded[:, 0, 0] > rounded[:, 1, 0]) | (
|
||||
(rounded[:, 0, 0] == rounded[:, 1, 0]) & (rounded[:, 0, 1] > rounded[:, 1, 1])
|
||||
)
|
||||
ordered = valid_edges.copy()
|
||||
ordered[swap_mask] = ordered[swap_mask][:, ::-1, :]
|
||||
ordered_rounded = rounded.copy()
|
||||
ordered_rounded[swap_mask] = ordered_rounded[swap_mask][:, ::-1, :]
|
||||
|
||||
view = ordered_rounded.reshape(len(ordered_rounded), -1)
|
||||
_, unique_indices = np.unique(view, axis=0, return_index=True)
|
||||
return ordered[unique_indices]
|
||||
|
||||
|
||||
def export_wireframe_to_pdf(
|
||||
edges_2d: NDArray[np.float64],
|
||||
filepath: str,
|
||||
margin_mm: float = 10.0,
|
||||
line_width_mm: float = 0.5,
|
||||
stroke_color: tuple[float, float, float] = (0.0, 0.0, 0.0),
|
||||
bg_color: tuple[float, float, float] = (1.0, 1.0, 1.0)
|
||||
) -> None:
|
||||
"""
|
||||
Exports 2D wireframe line segments to a 1:1 true physical scale vector PDF file.
|
||||
- PDF page dimensions automatically match the physical size of the geometry + grid + margin.
|
||||
- line_width_mm is specified in physical millimeters and converted to PDF points (1 mm = 72/25.4 pt).
|
||||
"""
|
||||
if len(edges_2d) > 0:
|
||||
min_x = float(edges_2d[:, :, 0].min())
|
||||
max_x = float(edges_2d[:, :, 0].max())
|
||||
min_y = float(edges_2d[:, :, 1].min())
|
||||
max_y = float(edges_2d[:, :, 1].max())
|
||||
else:
|
||||
min_x = max_x = min_y = max_y = 0.0
|
||||
|
||||
w_geom = max_x - min_x
|
||||
h_geom = max_y - min_y
|
||||
|
||||
L = max(w_geom, h_geom)
|
||||
if L > 1e-6:
|
||||
step_raw = L / 10.0
|
||||
exponent = int(np.round(np.log10(step_raw)))
|
||||
main_step = 10.0 ** exponent
|
||||
else:
|
||||
main_step = 10.0
|
||||
|
||||
minor_step = main_step / 10.0
|
||||
|
||||
if len(edges_2d) > 0:
|
||||
grid_min_x = math.floor(min_x / main_step) * main_step
|
||||
grid_max_x = math.ceil(max_x / main_step) * main_step
|
||||
grid_min_y = math.floor(min_y / main_step) * main_step
|
||||
grid_max_y = math.ceil(max_y / main_step) * main_step
|
||||
|
||||
if grid_min_x == grid_max_x:
|
||||
grid_max_x += main_step
|
||||
if grid_min_y == grid_max_y:
|
||||
grid_max_y += main_step
|
||||
else:
|
||||
grid_min_x, grid_max_x = -50.0, 50.0
|
||||
grid_min_y, grid_max_y = -50.0, 50.0
|
||||
|
||||
w_grid_mm = grid_max_x - grid_min_x
|
||||
h_grid_mm = grid_max_y - grid_min_y
|
||||
|
||||
w_total_mm = w_grid_mm + 2.0 * margin_mm
|
||||
h_total_mm = h_grid_mm + 2.0 * margin_mm
|
||||
|
||||
page_w_pt = w_total_mm * MM_TO_PT
|
||||
page_h_pt = h_total_mm * MM_TO_PT
|
||||
|
||||
# Convert line width from millimeters to PostScript points
|
||||
line_width_pt = line_width_mm * MM_TO_PT
|
||||
|
||||
def to_pdf_pt(pt_mm):
|
||||
x_pt = (pt_mm[0] - grid_min_x + margin_mm) * MM_TO_PT
|
||||
y_pt = (pt_mm[1] - grid_min_y + margin_mm) * MM_TO_PT
|
||||
return x_pt, y_pt
|
||||
|
||||
minor_lines: list[tuple[tuple[float, float], tuple[float, float]]] = []
|
||||
main_lines: list[tuple[tuple[float, float], tuple[float, float]]] = []
|
||||
|
||||
max_grid_steps = 2000
|
||||
|
||||
# Vertical grid lines
|
||||
k_x_start = int(np.round(grid_min_x / minor_step))
|
||||
k_x_end = int(np.round(grid_max_x / minor_step))
|
||||
if abs(k_x_end - k_x_start) <= max_grid_steps:
|
||||
for k_x in range(k_x_start, k_x_end + 1):
|
||||
gx = k_x * minor_step
|
||||
p0 = to_pdf_pt((gx, grid_min_y))
|
||||
p1 = to_pdf_pt((gx, grid_max_y))
|
||||
if k_x % 10 == 0:
|
||||
main_lines.append((p0, p1))
|
||||
else:
|
||||
minor_lines.append((p0, p1))
|
||||
|
||||
# Horizontal grid lines
|
||||
k_y_start = int(np.round(grid_min_y / minor_step))
|
||||
k_y_end = int(np.round(grid_max_y / minor_step))
|
||||
if abs(k_y_end - k_y_start) <= max_grid_steps:
|
||||
for k_y in range(k_y_start, k_y_end + 1):
|
||||
gy = k_y * minor_step
|
||||
p0 = to_pdf_pt((grid_min_x, gy))
|
||||
p1 = to_pdf_pt((grid_max_x, gy))
|
||||
if k_y % 10 == 0:
|
||||
main_lines.append((p0, p1))
|
||||
else:
|
||||
minor_lines.append((p0, p1))
|
||||
|
||||
stream_cmds: list[str] = []
|
||||
|
||||
# 1. Solid background fill
|
||||
br, bg, bb = bg_color
|
||||
stream_cmds.append(f"{br:.3f} {bg:.3f} {bb:.3f} rg")
|
||||
stream_cmds.append(f"0 0 {page_w_pt:.2f} {page_h_pt:.2f} re f")
|
||||
|
||||
# 2. Wireframe stroke color
|
||||
sr, sg, sb = stroke_color
|
||||
stream_cmds.append(f"{sr:.3f} {sg:.3f} {sb:.3f} RG")
|
||||
|
||||
# 3. Minor Grid Lines (25% Opacity)
|
||||
if minor_lines:
|
||||
stream_cmds.append("/GS25 gs")
|
||||
minor_grid_pt = min(0.3 * MM_TO_PT, line_width_pt)
|
||||
stream_cmds.append(f"{minor_grid_pt:.2f} w")
|
||||
lines_str = [f"{p0[0]:.2f} {p0[1]:.2f} m {p1[0]:.2f} {p1[1]:.2f} l" for p0, p1 in minor_lines]
|
||||
for start_idx in range(0, len(lines_str), 5000):
|
||||
stream_cmds.extend(lines_str[start_idx:start_idx + 5000])
|
||||
stream_cmds.append("S")
|
||||
|
||||
# 4. Main Grid Lines (50% Opacity)
|
||||
if main_lines:
|
||||
stream_cmds.append("/GS50 gs")
|
||||
main_grid_pt = min(0.4 * MM_TO_PT, line_width_pt)
|
||||
stream_cmds.append(f"{main_grid_pt:.2f} w")
|
||||
lines_str = [f"{p0[0]:.2f} {p0[1]:.2f} m {p1[0]:.2f} {p1[1]:.2f} l" for p0, p1 in main_lines]
|
||||
for start_idx in range(0, len(lines_str), 5000):
|
||||
stream_cmds.extend(lines_str[start_idx:start_idx + 5000])
|
||||
stream_cmds.append("S")
|
||||
|
||||
# 5. Object Wireframe (100% Opacity, exact physical line_width_mm)
|
||||
if len(edges_2d) > 0:
|
||||
stream_cmds.append("/GS100 gs")
|
||||
stream_cmds.append(f"{line_width_pt:.2f} w")
|
||||
stream_cmds.append("1 j 1 J") # round join and cap
|
||||
|
||||
lines_str = []
|
||||
for i in range(len(edges_2d)):
|
||||
p0 = to_pdf_pt(edges_2d[i, 0])
|
||||
p1 = to_pdf_pt(edges_2d[i, 1])
|
||||
lines_str.append(f"{p0[0]:.2f} {p0[1]:.2f} m {p1[0]:.2f} {p1[1]:.2f} l")
|
||||
|
||||
for start_idx in range(0, len(lines_str), 5000):
|
||||
stream_cmds.extend(lines_str[start_idx:start_idx + 5000])
|
||||
stream_cmds.append("S")
|
||||
|
||||
stream_content = "\n".join(stream_cmds) + "\n"
|
||||
stream_bytes = stream_content.encode("utf-8")
|
||||
|
||||
objects: list[bytes] = []
|
||||
objects.append(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n")
|
||||
objects.append(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n")
|
||||
page_header = (
|
||||
f"3 0 obj\n"
|
||||
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {page_w_pt:.2f} {page_h_pt:.2f}] "
|
||||
f"/Resources << /ExtGState << /GS25 5 0 R /GS50 6 0 R /GS100 7 0 R >> >> "
|
||||
f"/Contents 4 0 R >>\n"
|
||||
f"endobj\n"
|
||||
)
|
||||
objects.append(page_header.encode("utf-8"))
|
||||
content_header = f"4 0 obj\n<< /Length {len(stream_bytes)} >>\nstream\n".encode("utf-8")
|
||||
content_footer = b"endstream\nendobj\n"
|
||||
objects.append(content_header + stream_bytes + content_footer)
|
||||
objects.append(b"5 0 obj\n<< /Type /ExtGState /CA 0.25 /ca 0.25 >>\nendobj\n")
|
||||
objects.append(b"6 0 obj\n<< /Type /ExtGState /CA 0.5 /ca 0.5 >>\nendobj\n")
|
||||
objects.append(b"7 0 obj\n<< /Type /ExtGState /CA 1.0 /ca 1.0 >>\nendobj\n")
|
||||
|
||||
header = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n"
|
||||
|
||||
parent_dir = os.path.dirname(filepath)
|
||||
if parent_dir:
|
||||
os.makedirs(parent_dir, exist_ok=True)
|
||||
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(header)
|
||||
offsets = [0]
|
||||
current_offset = len(header)
|
||||
|
||||
for obj_bytes in objects:
|
||||
offsets.append(current_offset)
|
||||
f.write(obj_bytes)
|
||||
current_offset += len(obj_bytes)
|
||||
|
||||
xref_offset = current_offset
|
||||
f.write(b"xref\n")
|
||||
f.write(f"0 {len(offsets)}\n".encode("utf-8"))
|
||||
f.write(b"0000000000 65535 f \n")
|
||||
for off in offsets[1:]:
|
||||
f.write(f"{off:010d} 00000 n \n".encode("utf-8"))
|
||||
|
||||
f.write(b"trailer\n")
|
||||
f.write(f"<< /Size {len(offsets)} /Root 1 0 R >>\n".encode("utf-8"))
|
||||
f.write(b"startxref\n")
|
||||
f.write(f"{xref_offset}\n".encode("utf-8"))
|
||||
f.write(b"%%EOF\n")
|
||||
|
|
@ -115,6 +115,13 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
|
|||
- **Load Gcode**: Reads a G-code file from disk.
|
||||
- **Gcode to File**: Writes G-code string to a file on disk.
|
||||
- **Automatic Binary Conversion**: If the G-code contains the comment `; binary_gcode = 1`, the node automatically converts the output to Prusa's binary G-code format (`.bgcode`) using MeatPack encoding and Heatshrink compression.
|
||||
- **Wireframe to PDF**: Generates a 1:1 true-scale 2D top-view (+Z orthographic) wireframe PDF from a `SlicingGroup` or `SlicingCollection` with a custom grid and background color.
|
||||
- **Geometry**: Input socket accepting a `SlicingGroup`, `SlicingCollection`, or `SlicingObject`.
|
||||
- **Folder**: Target folder or PDF file path on disk.
|
||||
- **Line Width (mm)**: Stroke width in millimeters for the wireframe lines.
|
||||
- **Margin (mm)**: Margin around the geometry grid in mm.
|
||||
- **Background**: Color picker for the canvas background.
|
||||
- **Wireframe**: Color picker for the wireframe lines and grid.
|
||||
- **Baserow Submit**: Sends G-code and metadata to a Baserow database.
|
||||
- **Host**: Baserow host URL.
|
||||
- **Token File**: Path to a file containing the Baserow API token.
|
||||
|
|
|
|||
134
tests/test_pdf_exporter.py
Normal file
134
tests/test_pdf_exporter.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
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, export_wireframe_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_export_pdf_true_1_to_1_scale(self):
|
||||
# Setup 100mm x 100mm 2D square
|
||||
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
|
||||
# Main grid step for 100mm is 10mm. Bounds: [0, 100], [0, 100].
|
||||
# Total width = 100 + 2*10 = 120mm. Expected pt = 120 * MM_TO_PT = 340.16 pt.
|
||||
|
||||
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()
|
||||
|
||||
expected_w = (100.0 + 2 * margin_mm) * MM_TO_PT
|
||||
expected_h = (100.0 + 2 * 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"))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Loading…
Add table
Reference in a new issue