nodeslicer/core/pdf_exporter.py
2026-07-29 16:53:31 +03:00

274 lines
10 KiB
Python

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")