nodeslicer/core/pdf_exporter.py
2026-07-30 11:53:20 +03:00

1073 lines
41 KiB
Python

from __future__ import annotations
from typing import Any
import os
import math
import numpy as np
from numpy.typing import NDArray
import shapely
from shapely.geometry import Polygon, MultiPolygon
MM_TO_PT = 72.0 / 25.4 # 1 mm = 2.8346456692913386 PostScript points
def deduplicate_2d_edges(edges: NDArray[np.float64]) -> NDArray[np.float64]:
"""
Deduplicates 2D line segments regardless of endpoint orientation.
"""
if len(edges) == 0:
return edges
rounded = np.round(edges, decimals=5)
swap_mask = (rounded[:, 0, 0] > rounded[:, 1, 0]) | (
(rounded[:, 0, 0] == rounded[:, 1, 0]) & (rounded[:, 0, 1] > rounded[:, 1, 1])
)
ordered = 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 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)
return deduplicate_2d_edges(valid_edges)
def extract_lineart_edges(mesh_data: Any, max_angle_deg: float = 30.0) -> NDArray[np.float64]:
"""
Fully vectorized NumPy Lineart edge extraction:
1. Silhouette edge: shared between at least one CCW face and at least one CW face in 2D top view.
2. Boundary edge: owned by only 1 face.
3. Crease edge: owned by 2 or more faces where 3D dihedral angle between normals > max_angle_deg.
Returns 2D edge segments array of shape (N, 2, 2).
"""
from ..infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
all_tris_3d = []
all_normals = []
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):
for so in item.objects:
process_item(so)
elif isinstance(item, SlicingObject):
if item.mesh.size > 0:
verts_3d = item.mesh[:, :3, :3] # (N, 3, 3)
normals = item.mesh[:, 3, :] # (N, 3)
norms_len = np.linalg.norm(normals, axis=1, keepdims=True)
norms_len = np.where(norms_len == 0, 1.0, norms_len)
normals_unit = normals / norms_len
zero_norm_mask = (normals[:, 0] == 0) & (normals[:, 1] == 0) & (normals[:, 2] == 0)
if np.any(zero_norm_mask):
v0_3d = verts_3d[:, 0, :]
v1_3d = verts_3d[:, 1, :]
v2_3d = verts_3d[:, 2, :]
computed_normals = np.cross(v1_3d - v0_3d, v2_3d - v0_3d)
cn_len = np.linalg.norm(computed_normals, axis=1, keepdims=True)
cn_len = np.where(cn_len == 0, 1.0, cn_len)
normals_unit[zero_norm_mask] = (computed_normals / cn_len)[zero_norm_mask]
all_tris_3d.append(verts_3d)
all_normals.append(normals_unit)
process_item(mesh_data)
if not all_tris_3d:
return np.empty((0, 2, 2), dtype=np.float64)
tris_3d = np.concatenate(all_tris_3d, axis=0) # (N, 3, 3)
normals_unit = np.concatenate(all_normals, axis=0) # (N, 3)
num_tris = len(tris_3d)
if num_tris == 0:
return np.empty((0, 2, 2), dtype=np.float64)
# Top-view 2D vertices
verts_2d = tris_3d[:, :, :2] # (N, 3, 2)
# 2D orientation per triangle
v0_2d = verts_2d[:, 0, :]
v1_2d = verts_2d[:, 1, :]
v2_2d = verts_2d[:, 2, :]
cross_2d = (v1_2d[:, 0] - v0_2d[:, 0]) * (v2_2d[:, 1] - v0_2d[:, 1]) - (v1_2d[:, 1] - v0_2d[:, 1]) * (v2_2d[:, 0] - v0_2d[:, 0])
is_ccw = cross_2d > 0
is_cw = cross_2d < 0
# Extract all 3 edges per triangle
e0_3d = tris_3d[:, [0, 1], :]
e1_3d = tris_3d[:, [1, 2], :]
e2_3d = tris_3d[:, [2, 0], :]
edges_3d = np.concatenate([e0_3d, e1_3d, e2_3d], axis=0) # (3N, 2, 3)
e0_2d = verts_2d[:, [0, 1], :]
e1_2d = verts_2d[:, [1, 2], :]
e2_2d = verts_2d[:, [2, 0], :]
edges_2d = np.concatenate([e0_2d, e1_2d, e2_2d], axis=0) # (3N, 2, 2)
edge_ccw = np.tile(is_ccw, 3) # (3N,)
edge_cw = np.tile(is_cw, 3) # (3N,)
edge_normals = np.tile(normals_unit, (3, 1)) # (3N, 3)
# Filter out near zero-length 2D edges
dists_sq_2d = np.sum((edges_2d[:, 0, :] - edges_2d[:, 1, :]) ** 2, axis=1)
valid_len_mask = dists_sq_2d >= 1e-8
edges_3d = edges_3d[valid_len_mask]
edges_2d = edges_2d[valid_len_mask]
edge_ccw = edge_ccw[valid_len_mask]
edge_cw = edge_cw[valid_len_mask]
edge_normals = edge_normals[valid_len_mask]
if len(edges_3d) == 0:
return np.empty((0, 2, 2), dtype=np.float64)
# Canonical undirected edge sorting in 3D (rounded to 4 decimal places)
rounded_3d = np.round(edges_3d, decimals=4)
pA = rounded_3d[:, 0, :]
pB = rounded_3d[:, 1, :]
swap_mask = (pA[:, 0] > pB[:, 0]) | (
(pA[:, 0] == pB[:, 0]) & (pA[:, 1] > pB[:, 1])
) | (
(pA[:, 0] == pB[:, 0]) & (pA[:, 1] == pB[:, 1]) & (pA[:, 2] > pB[:, 2])
)
canonical_3d = rounded_3d.copy()
canonical_3d[swap_mask] = canonical_3d[swap_mask][:, ::-1, :]
canonical_2d = edges_2d.copy()
canonical_2d[swap_mask] = canonical_2d[swap_mask][:, ::-1, :]
# Unique 3D edge identification using NumPy structured view
flat_3d = canonical_3d.reshape(len(canonical_3d), 6)
view_bytes = np.ascontiguousarray(flat_3d).view(np.dtype((np.void, 48))).ravel()
unique_keys, first_indices, inverse_indices, counts = np.unique(
view_bytes, return_index=True, return_inverse=True, return_counts=True
)
num_unique = len(unique_keys)
# Rule 1: Silhouette edge (has CCW and CW face)
has_ccw = np.bincount(inverse_indices, weights=edge_ccw.astype(int), minlength=num_unique) > 0
has_cw = np.bincount(inverse_indices, weights=edge_cw.astype(int), minlength=num_unique) > 0
rule1_unique = has_ccw & has_cw
# Rule 2: Boundary edge (owned by 1 face)
rule2_unique = counts == 1
# Rule 3: Crease edge (owned by 2+ faces with normal angle > max_angle_deg)
cos_threshold = math.cos(math.radians(max_angle_deg))
rule3_unique = np.zeros(num_unique, dtype=bool)
multi_edge_mask = counts >= 2
if np.any(multi_edge_mask):
sort_by_edge = np.argsort(inverse_indices)
sorted_edge_ids = inverse_indices[sort_by_edge]
sorted_normals = edge_normals[sort_by_edge]
splits = np.where(sorted_edge_ids[:-1] != sorted_edge_ids[1:])[0] + 1
edge_groups = np.split(sorted_normals, splits)
unique_group_ids = np.split(sorted_edge_ids, splits)
for grp_normals, grp_ids in zip(edge_groups, unique_group_ids):
edge_id = grp_ids[0]
if len(grp_normals) >= 2:
dots = grp_normals @ grp_normals.T
if np.any(dots < cos_threshold - 1e-6):
rule3_unique[edge_id] = True
marked_unique_mask = rule1_unique | rule2_unique | rule3_unique
marked_first_indices = first_indices[marked_unique_mask]
return canonical_2d[marked_first_indices]
def extract_boundary_edges(mesh_data: Any) -> NDArray[np.float64]:
"""
Extracts 2D edge segments owned by only 1 face (single-face boundary edges).
"""
from ..infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
all_tris_3d = []
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):
for so in item.objects:
process_item(so)
elif isinstance(item, SlicingObject):
if item.mesh.size > 0:
all_tris_3d.append(item.mesh[:, :3, :3])
process_item(mesh_data)
if not all_tris_3d:
return np.empty((0, 2, 2), dtype=np.float64)
tris_3d = np.concatenate(all_tris_3d, axis=0)
if len(tris_3d) == 0:
return np.empty((0, 2, 2), dtype=np.float64)
verts_2d = tris_3d[:, :, :2]
e0_3d = tris_3d[:, [0, 1], :]
e1_3d = tris_3d[:, [1, 2], :]
e2_3d = tris_3d[:, [2, 0], :]
edges_3d = np.concatenate([e0_3d, e1_3d, e2_3d], axis=0)
e0_2d = verts_2d[:, [0, 1], :]
e1_2d = verts_2d[:, [1, 2], :]
e2_2d = verts_2d[:, [2, 0], :]
edges_2d = np.concatenate([e0_2d, e1_2d, e2_2d], axis=0)
dists_sq_2d = np.sum((edges_2d[:, 0, :] - edges_2d[:, 1, :]) ** 2, axis=1)
valid_mask = dists_sq_2d >= 1e-8
edges_3d = edges_3d[valid_mask]
edges_2d = edges_2d[valid_mask]
if len(edges_3d) == 0:
return np.empty((0, 2, 2), dtype=np.float64)
rounded_3d = np.round(edges_3d, decimals=4)
pA = rounded_3d[:, 0, :]
pB = rounded_3d[:, 1, :]
swap_mask = (pA[:, 0] > pB[:, 0]) | (
(pA[:, 0] == pB[:, 0]) & (pA[:, 1] > pB[:, 1])
) | (
(pA[:, 0] == pB[:, 0]) & (pA[:, 1] == pB[:, 1]) & (pA[:, 2] > pB[:, 2])
)
canonical_3d = rounded_3d.copy()
canonical_3d[swap_mask] = canonical_3d[swap_mask][:, ::-1, :]
canonical_2d = edges_2d.copy()
canonical_2d[swap_mask] = canonical_2d[swap_mask][:, ::-1, :]
flat_3d = canonical_3d.reshape(len(canonical_3d), 6)
view_bytes = np.ascontiguousarray(flat_3d).view(np.dtype((np.void, 48))).ravel()
unique_keys, first_indices, inverse_indices, counts = np.unique(
view_bytes, return_index=True, return_inverse=True, return_counts=True
)
boundary_mask = counts == 1
return canonical_2d[first_indices[boundary_mask]]
def extract_crease_edges(mesh_data: Any, max_angle_deg: float = 30.0) -> NDArray[np.float64]:
"""
Extracts 2D edge segments owned by 2 or more faces where the 3D dihedral angle between face normals > max_angle_deg.
"""
return extract_lineart_edges(mesh_data, max_angle_deg=max_angle_deg)
def extract_silhouette_contour_edges(mesh_data: Any) -> NDArray[np.float64]:
"""
Extracts top-view silhouette contour edges using the user's exact backfacing rule:
Checks 2D triangle orientation (CW vs CCW).
An edge is marked for drawing if it is shared by at least one Clockwise (back-facing) triangle
AND at least one Counter-Clockwise (front-facing) triangle in +Z top-view projection.
"""
from ..infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
all_tris_3d = []
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):
for so in item.objects:
process_item(so)
elif isinstance(item, SlicingObject):
if item.mesh.size > 0:
all_tris_3d.append(item.mesh[:, :3, :3])
process_item(mesh_data)
if not all_tris_3d:
return np.empty((0, 2, 2), dtype=np.float64)
tris_3d = np.concatenate(all_tris_3d, axis=0) # (N, 3, 3)
num_tris = len(tris_3d)
if num_tris == 0:
return np.empty((0, 2, 2), dtype=np.float64)
# Top-view 2D vertices
verts_2d = tris_3d[:, :, :2] # (N, 3, 2)
# 2D orientation per triangle (cross product of 2D edge vectors)
v0_2d = verts_2d[:, 0, :]
v1_2d = verts_2d[:, 1, :]
v2_2d = verts_2d[:, 2, :]
cross_2d = (v1_2d[:, 0] - v0_2d[:, 0]) * (v2_2d[:, 1] - v0_2d[:, 1]) - (v1_2d[:, 1] - v0_2d[:, 1]) * (v2_2d[:, 0] - v0_2d[:, 0])
is_ccw = cross_2d > 0
is_cw = cross_2d < 0
# Extract all 3 edges per triangle
e0_3d = tris_3d[:, [0, 1], :]
e1_3d = tris_3d[:, [1, 2], :]
e2_3d = tris_3d[:, [2, 0], :]
edges_3d = np.concatenate([e0_3d, e1_3d, e2_3d], axis=0) # (3N, 2, 3)
e0_2d = verts_2d[:, [0, 1], :]
e1_2d = verts_2d[:, [1, 2], :]
e2_2d = verts_2d[:, [2, 0], :]
edges_2d = np.concatenate([e0_2d, e1_2d, e2_2d], axis=0) # (3N, 2, 2)
edge_ccw = np.tile(is_ccw, 3) # (3N,)
edge_cw = np.tile(is_cw, 3) # (3N,)
# Filter out near zero-length 2D edges
dists_sq_2d = np.sum((edges_2d[:, 0, :] - edges_2d[:, 1, :]) ** 2, axis=1)
valid_len_mask = dists_sq_2d >= 1e-8
edges_3d = edges_3d[valid_len_mask]
edges_2d = edges_2d[valid_len_mask]
edge_ccw = edge_ccw[valid_len_mask]
edge_cw = edge_cw[valid_len_mask]
if len(edges_3d) == 0:
return np.empty((0, 2, 2), dtype=np.float64)
# Canonical undirected edge sorting in 3D
rounded_3d = np.round(edges_3d, decimals=4)
pA = rounded_3d[:, 0, :]
pB = rounded_3d[:, 1, :]
swap_mask = (pA[:, 0] > pB[:, 0]) | (
(pA[:, 0] == pB[:, 0]) & (pA[:, 1] > pB[:, 1])
) | (
(pA[:, 0] == pB[:, 0]) & (pA[:, 1] == pB[:, 1]) & (pA[:, 2] > pB[:, 2])
)
canonical_3d = rounded_3d.copy()
canonical_3d[swap_mask] = canonical_3d[swap_mask][:, ::-1, :]
canonical_2d = edges_2d.copy()
canonical_2d[swap_mask] = canonical_2d[swap_mask][:, ::-1, :]
# Unique 3D edge identification using NumPy structured view
flat_3d = canonical_3d.reshape(len(canonical_3d), 6)
view_bytes = np.ascontiguousarray(flat_3d).view(np.dtype((np.void, 48))).ravel()
unique_keys, first_indices, inverse_indices, counts = np.unique(
view_bytes, return_index=True, return_inverse=True, return_counts=True
)
num_unique = len(unique_keys)
# Silhouette contour edge: owned by at least 1 CCW triangle AND at least 1 CW triangle
has_ccw = np.bincount(inverse_indices, weights=edge_ccw.astype(int), minlength=num_unique) > 0
has_cw = np.bincount(inverse_indices, weights=edge_cw.astype(int), minlength=num_unique) > 0
silhouette_mask = has_ccw & has_cw
return canonical_2d[first_indices[silhouette_mask]]
def extract_outer_contour_edges(mesh_data: Any) -> NDArray[np.float64]:
"""
Extracts top-view contour edges using backfacing CW vs CCW triangle orientation.
"""
return extract_silhouette_contour_edges(mesh_data)
def _format_pdf_lines(lines_arr: NDArray[np.float64]) -> list[str]:
"""
Vectorized formatting of 2D line segments to PDF operator strings.
lines_arr shape: (N, 2, 2)
"""
if len(lines_arr) == 0:
return []
flat = lines_arr.reshape(-1, 4)
return [f"{row[0]:.2f} {row[1]:.2f} m {row[2]:.2f} {row[3]:.2f} l" for row in flat]
def extract_settings_items(settings: Any) -> list[tuple[str, str]]:
"""
Extracts a list of (setting_name, second_field_suffix) from settings input.
The setting_name is the left field, and second_field_suffix is the right field (e.g. unit).
"""
if not settings:
return []
items: list[tuple[str, str]] = []
if isinstance(settings, dict):
for k, v in settings.items():
key_str = str(k).strip()
if key_str:
suffix_str = str(v) if v is not None else ""
items.append((key_str, suffix_str))
elif isinstance(settings, (list, tuple)):
for item in settings:
if isinstance(item, str) and item.strip():
items.append((item.strip(), ""))
elif isinstance(item, dict):
if "field" in item and item["field"]:
key_str = str(item["field"]).strip()
val_str = str(item.get("value_str", item.get("value", "")))
items.append((key_str, val_str))
else:
for k, v in item.items():
key_str = str(k).strip()
if key_str:
suffix_str = str(v) if v is not None else ""
items.append((key_str, suffix_str))
elif hasattr(item, "field") and getattr(item, "field"):
key_str = str(getattr(item, "field")).strip()
val_str = str(getattr(item, "value_str", getattr(item, "value", "")))
items.append((key_str, val_str))
return items
def extract_settings_keys(settings: Any) -> list[str]:
return [item[0] for item in extract_settings_items(settings)]
def get_first_attribute_value(obj: Any, attr_name: str) -> Any:
"""
Accesses the first element of the attribute with attr_name on obj.
"""
val = None
if hasattr(obj, "attributes") and isinstance(obj.attributes, dict) and attr_name in obj.attributes:
attr = obj.attributes[attr_name]
if isinstance(attr, dict) and "values" in attr:
val = attr["values"]
else:
val = attr
elif hasattr(obj, attr_name):
val = getattr(obj, attr_name)
if val is None:
return None
if isinstance(val, np.ndarray):
if val.size > 0:
first_val = val.flat[0]
if hasattr(first_val, "item"):
return first_val.item()
return first_val
return None
elif isinstance(val, (list, tuple)):
if len(val) > 0:
first_val = val[0]
if hasattr(first_val, "item"):
return first_val.item()
return first_val
return None
else:
if hasattr(val, "item"):
return val.item()
return val
def extract_object_label_info(mesh_data: Any) -> list[tuple[Any, str, float, float, float, float]]:
"""
Extracts (slicing_object, object_name, max_x_mm, y_at_max_x_mm, width_mm, height_mm) for each SlicingObject in mesh_data.
max_x_mm is the X coordinate of the rightmost vertex of the object.
y_at_max_x_mm is the Y coordinate of that rightmost vertex.
"""
from ..infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
results: list[tuple[Any, str, float, float, float, float]] = []
def process_item(item: Any, fallback_name: str = ""):
if item is None:
return
elif isinstance(item, (list, tuple)):
for i, sub in enumerate(item):
process_item(sub, f"{fallback_name}_{i}" if fallback_name else f"object_{i}")
elif isinstance(item, SlicingGroup):
for key, coll in item.collections.items():
process_item(coll, key)
elif isinstance(item, SlicingCollection):
coll_name = item.name or fallback_name
for i, so in enumerate(item.objects):
process_item(so, so.name or f"{coll_name}_{i}")
elif isinstance(item, SlicingObject):
if item.mesh.size > 0:
name = item.name or fallback_name or "object"
verts_2d = item.mesh[:, :3, :2].reshape(-1, 2)
if len(verts_2d) > 0:
max_idx = np.argmax(verts_2d[:, 0])
max_x = float(verts_2d[max_idx, 0])
min_x = float(verts_2d[:, 0].min())
y_val = float(verts_2d[max_idx, 1])
min_y = float(verts_2d[:, 1].min())
max_y = float(verts_2d[:, 1].max())
w_mm = max_x - min_x
h_mm = max_y - min_y
results.append((item, name, max_x, y_val, w_mm, h_mm))
process_item(mesh_data)
return results
# Alias for backward compatibility
extract_object_name_positions = extract_object_label_info
def extract_object_bounding_boxes(mesh_data: Any) -> NDArray[np.float64]:
"""
Extracts 2D top-view bounding box rectangle edge segments (shape N x 2 x 2) for each SlicingObject in mesh_data.
"""
from ..infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
all_segments: list[np.ndarray] = []
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):
for so in item.objects:
process_item(so)
elif isinstance(item, SlicingObject):
if item.mesh.size > 0:
verts_2d = item.mesh[:, :3, :2].reshape(-1, 2)
if len(verts_2d) > 0:
min_x = float(verts_2d[:, 0].min())
max_x = float(verts_2d[:, 0].max())
min_y = float(verts_2d[:, 1].min())
max_y = float(verts_2d[:, 1].max())
rect = np.array([
[[min_x, min_y], [max_x, min_y]],
[[max_x, min_y], [max_x, max_y]],
[[max_x, max_y], [min_x, max_y]],
[[min_x, max_y], [min_x, min_y]],
], dtype=np.float64)
all_segments.append(rect)
process_item(mesh_data)
if not all_segments:
return np.empty((0, 2, 2), dtype=np.float64)
return np.concatenate(all_segments, axis=0)
def export_mesh_to_pdf(
edges_2d: NDArray[np.float64],
filepath: str | Path,
draw_wireframe: bool = True,
draw_boundary: bool = False,
draw_contour: bool = False,
draw_crease: bool = False,
draw_bbox: bool = False,
show_name: bool = False,
show_bbox_size: bool = False,
crease_angle: float = 30.0,
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),
bbox_color: tuple[float, float, float] = (0.0, 0.0, 1.0),
raw_geometry: Any = None,
settings: Any = None,
style: str | None = None # Backward compatibility
) -> None:
"""
Exports 2D vector geometry to a 1:1 true physical scale vector PDF file.
Supports toggleable line layers: Wireframe, Boundary (1 face), Contour, Crease Angle.
"""
# Handle legacy style argument if provided
if style == 'LINEART':
draw_wireframe = False
draw_boundary = True
draw_crease = True
elif style == 'OUTER_CONTOUR':
draw_wireframe = False
draw_contour = True
edge_list: list[NDArray[np.float64]] = []
if draw_wireframe and len(edges_2d) > 0:
edge_list.append(edges_2d)
if draw_boundary and raw_geometry is not None:
b_edges = extract_boundary_edges(raw_geometry)
if len(b_edges) > 0:
edge_list.append(b_edges)
if draw_contour and raw_geometry is not None:
c_edges = extract_outer_contour_edges(raw_geometry)
if len(c_edges) > 0:
edge_list.append(c_edges)
if draw_crease and raw_geometry is not None:
cr_edges = extract_crease_edges(raw_geometry, max_angle_deg=crease_angle)
if len(cr_edges) > 0:
edge_list.append(cr_edges)
if edge_list:
combined = np.concatenate(edge_list, axis=0)
edges_to_draw = deduplicate_2d_edges(combined)
else:
edges_to_draw = edges_2d
if len(edges_to_draw) > 0:
min_x = float(edges_to_draw[:, :, 0].min())
max_x = float(edges_to_draw[:, :, 0].max())
min_y = float(edges_to_draw[:, :, 1].min())
max_y = float(edges_to_draw[:, :, 1].max())
elif 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
center_x = (min_x + max_x) / 2.0
center_y = (min_y + max_y) / 2.0
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_to_draw) > 0 or len(edges_2d) > 0:
half_span_x = math.ceil(max(abs(max_x - center_x), abs(min_x - center_x)) / main_step) * main_step
half_span_y = math.ceil(max(abs(max_y - center_y), abs(min_y - center_y)) / main_step) * main_step
if half_span_x == 0:
half_span_x = main_step
if half_span_y == 0:
half_span_y = main_step
grid_min_x = math.floor((center_x - half_span_x) / main_step) * main_step
grid_max_x = math.ceil((center_x + half_span_x) / main_step) * main_step
grid_min_y = math.floor((center_y - half_span_y) / main_step) * main_step
grid_max_y = math.ceil((center_y + half_span_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
total_margin_mm = margin_mm + 2.0 * minor_step
w_total_mm = w_grid_mm + 2.0 * total_margin_mm
h_total_mm = h_grid_mm + 2.0 * total_margin_mm
page_w_pt = w_total_mm * MM_TO_PT
page_h_pt = h_total_mm * MM_TO_PT
line_width_pt = line_width_mm * MM_TO_PT
def to_pdf_pt(pt_mm):
x_pt = (pt_mm[:, 0] - grid_min_x + total_margin_mm) * MM_TO_PT
y_pt = (pt_mm[:, 1] - grid_min_y + total_margin_mm) * MM_TO_PT
return np.column_stack([x_pt, y_pt])
max_grid_steps = 2000
# Vectorized 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))
k_x = np.arange(k_x_start, k_x_end + 1)
if len(k_x) <= max_grid_steps:
gx = k_x * minor_step
pts0_v = np.column_stack([gx, np.full_like(gx, grid_min_y)])
pts1_v = np.column_stack([gx, np.full_like(gx, grid_max_y)])
p0_v = to_pdf_pt(pts0_v)
p1_v = to_pdf_pt(pts1_v)
is_main_x = (k_x % 10 == 0)
main_v_lines = np.column_stack([p0_v[is_main_x], p1_v[is_main_x]]).reshape(-1, 2, 2)
minor_v_lines = np.column_stack([p0_v[~is_main_x], p1_v[~is_main_x]]).reshape(-1, 2, 2)
else:
main_v_lines = np.empty((0, 2, 2))
minor_v_lines = np.empty((0, 2, 2))
# Vectorized 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))
k_y = np.arange(k_y_start, k_y_end + 1)
if len(k_y) <= max_grid_steps:
gy = k_y * minor_step
pts0_h = np.column_stack([np.full_like(gy, grid_min_x), gy])
pts1_h = np.column_stack([np.full_like(gy, grid_max_x), gy])
p0_h = to_pdf_pt(pts0_h)
p1_h = to_pdf_pt(pts1_h)
is_main_y = (k_y % 10 == 0)
main_h_lines = np.column_stack([p0_h[is_main_y], p1_h[is_main_y]]).reshape(-1, 2, 2)
minor_h_lines = np.column_stack([p0_h[~is_main_y], p1_h[~is_main_y]]).reshape(-1, 2, 2)
else:
main_h_lines = np.empty((0, 2, 2))
minor_h_lines = np.empty((0, 2, 2))
main_lines_arr = np.concatenate([main_v_lines, main_h_lines], axis=0) if len(main_v_lines) or len(main_h_lines) else np.empty((0, 2, 2))
minor_lines_arr = np.concatenate([minor_v_lines, minor_h_lines], axis=0) if len(minor_v_lines) or len(minor_h_lines) else np.empty((0, 2, 2))
stream_cmds: list[str] = []
# 1. Solid background fill
br, bg, bb = bg_color
sr, sg, sb = stroke_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
stream_cmds.append(f"{sr:.3f} {sg:.3f} {sb:.3f} RG")
# 3. Minor Grid Lines (25% Opacity)
if len(minor_lines_arr) > 0:
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 = _format_pdf_lines(minor_lines_arr)
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 len(main_lines_arr) > 0:
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 = _format_pdf_lines(main_lines_arr)
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 Lines (100% Opacity)
if len(edges_to_draw) > 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
p0_pt = to_pdf_pt(edges_to_draw[:, 0, :])
p1_pt = to_pdf_pt(edges_to_draw[:, 1, :])
obj_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2)
lines_str = _format_pdf_lines(obj_lines_arr)
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.5. Object Bounding Boxes (100% Opacity in bbox_color)
if draw_bbox and raw_geometry is not None:
bbox_edges = extract_object_bounding_boxes(raw_geometry)
if len(bbox_edges) > 0:
br, bg, bb = bbox_color
stream_cmds.append("/GS100 gs")
stream_cmds.append(f"{line_width_pt:.2f} w")
stream_cmds.append("1 j 1 J")
stream_cmds.append(f"{br:.3f} {bg:.3f} {bb:.3f} RG")
p0_pt = to_pdf_pt(bbox_edges[:, 0, :])
p1_pt = to_pdf_pt(bbox_edges[:, 1, :])
bbox_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2)
lines_str = _format_pdf_lines(bbox_lines_arr)
for start_idx in range(0, len(lines_str), 5000):
stream_cmds.extend(lines_str[start_idx:start_idx + 5000])
stream_cmds.append("S")
# 6. Size Scale Legend (Bottom Left, aligned directly on bottom-left main grid lines with arrows and X/Y labels)
stream_cmds.append("/GS100 gs")
x_grid_start_mm = grid_min_x
x_grid_end_mm = min(grid_max_x, grid_min_x + 10.0 * main_step)
num_main_steps = int(np.round((x_grid_end_mm - x_grid_start_mm) / main_step))
p_start_pt = to_pdf_pt(np.array([[x_grid_start_mm, grid_min_y]]))
p_end_pt = to_pdf_pt(np.array([[x_grid_end_mm, grid_min_y]]))
x_start_pt = p_start_pt[0, 0]
x_end_pt = p_end_pt[0, 0]
y_bar_pt = p_start_pt[0, 1]
# Vertical Y-axis extension: 5 minor grid lines up from origin
y_grid_top_mm = grid_min_y + 5.0 * minor_step
p_top_pt = to_pdf_pt(np.array([[grid_min_x, y_grid_top_mm]]))
y_top_pt = p_top_pt[0, 1]
# Smart arrowhead scaling based on line_width_pt and font size
font_size_pt = max(8.0, line_width_pt * 4.0)
arrow_len_pt = max(3.5 * MM_TO_PT, line_width_pt * 4.5)
arrow_half_w_pt = arrow_len_pt * 0.40
# Exact same stroke thickness as object wireframe lines
stream_cmds.append(f"{line_width_pt:.2f} w")
stream_cmds.append("1 j 1 J")
# 6a. Main horizontal X-axis line (spans 10 main grid steps, extends 5 minor grid lines past last tick + right arrow + X label)
x_arrow_base_mm = x_grid_end_mm + 5.0 * minor_step
p_arrow_base_pt = to_pdf_pt(np.array([[x_arrow_base_mm, grid_min_y]]))
x_arrow_base_pt = p_arrow_base_pt[0, 0]
x_arrow_tip_pt = x_arrow_base_pt + arrow_len_pt
# Horizontal main scale bar line
stream_cmds.append(f"{x_start_pt:.2f} {y_bar_pt:.2f} m {x_arrow_tip_pt:.2f} {y_bar_pt:.2f} l S")
# Pointy arrowhead (+X) extending past 5th minor grid line beyond x_end_pt
stream_cmds.append(
f"{x_arrow_base_pt:.2f} {y_bar_pt + arrow_half_w_pt:.2f} m "
f"{x_arrow_tip_pt:.2f} {y_bar_pt:.2f} l "
f"{x_arrow_base_pt:.2f} {y_bar_pt - arrow_half_w_pt:.2f} l S"
)
# Ticks directly overlapping vertical main grid lines on X-axis (full 10 main grid steps including k=0)
tick_cmds = []
for k in range(num_main_steps + 1):
xk_mm = x_grid_start_mm + k * main_step
pk_pt = to_pdf_pt(np.array([[xk_mm, grid_min_y]]))
xk_pt = pk_pt[0, 0]
tick_h_pt = (5.0 if k in (0, 5, num_main_steps) else 3.0) * MM_TO_PT
tick_cmds.append(f"{xk_pt:.2f} {y_bar_pt:.2f} m {xk_pt:.2f} {y_bar_pt + tick_h_pt:.2f} l")
stream_cmds.extend(tick_cmds)
stream_cmds.append("S")
# 6b. Main vertical Y-axis line (5 minor grid lines up with NO ticks) + top arrow (+Y)
y_arrow_base_pt = y_top_pt
y_arrow_tip_pt = y_arrow_base_pt + arrow_len_pt
stream_cmds.append(f"{x_start_pt:.2f} {y_bar_pt:.2f} m {x_start_pt:.2f} {y_arrow_tip_pt:.2f} l S")
# Pointy arrowhead (+Y) extending past 5th minor grid line (y_top_pt)
stream_cmds.append(
f"{x_start_pt - arrow_half_w_pt:.2f} {y_arrow_base_pt:.2f} m "
f"{x_start_pt:.2f} {y_arrow_tip_pt:.2f} l "
f"{x_start_pt + arrow_half_w_pt:.2f} {y_arrow_base_pt:.2f} l S"
)
# 6c. Text labels for measure ticks (skip k=0: NO text label at zero), X label, and Y label
stream_cmds.append(f"{sr:.3f} {sg:.3f} {sb:.3f} rg")
stream_cmds.append("BT")
stream_cmds.append(f"/F1 {font_size_pt:.1f} Tf")
for k in range(1, num_main_steps + 1):
xk_mm = x_grid_start_mm + k * main_step
pk_pt = to_pdf_pt(np.array([[xk_mm, grid_min_y]]))
xk_pt = pk_pt[0, 0]
tick_h_pt = (5.0 if k in (0, 5, num_main_steps) else 3.0) * MM_TO_PT
val_mm = k * main_step
if main_step >= 1000.0:
val_num = val_mm / 1000.0
unit = " m" if k == num_main_steps else ""
tick_str = f"{val_num:g}{unit}"
else:
unit = " mm" if k == num_main_steps else ""
tick_str = f"{val_mm:g}{unit}"
x_text_pt = xk_pt - (len(tick_str) * font_size_pt * 0.28)
y_text_pt = y_bar_pt + tick_h_pt + (2.5 * MM_TO_PT)
stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {y_text_pt:.2f} Tm")
stream_cmds.append(f"({tick_str}) Tj")
# X axis label next to right arrowhead
x_x_label_pt = x_arrow_tip_pt + (2.0 * MM_TO_PT)
y_x_label_pt = y_bar_pt - (font_size_pt * 0.35)
stream_cmds.append(f"1 0 0 1 {x_x_label_pt:.2f} {y_x_label_pt:.2f} Tm")
stream_cmds.append("(X) Tj")
# Y axis label next to top arrowhead
x_y_label_pt = x_start_pt - (font_size_pt * 0.35)
y_y_label_pt = y_arrow_tip_pt + (2.0 * MM_TO_PT)
stream_cmds.append(f"1 0 0 1 {x_y_label_pt:.2f} {y_y_label_pt:.2f} Tm")
stream_cmds.append("(Y) Tj")
stream_cmds.append("ET")
# 7. Object Names, Bounding Box Sizes, and Settings Attributes (to the right of the rightmost vertex)
settings_items = extract_settings_items(settings)
if (show_name or show_bbox_size or settings_items) and raw_geometry is not None:
label_infos = extract_object_label_info(raw_geometry)
if label_infos:
font_size_pt = max(8.0, line_width_pt * 4.0)
line_spacing_pt = font_size_pt * 1.2
stream_cmds.append("/GS100 gs")
stream_cmds.append(f"{sr:.3f} {sg:.3f} {sb:.3f} rg")
stream_cmds.append("BT")
stream_cmds.append(f"/F1 {font_size_pt:.1f} Tf")
for so_item, obj_name, max_x_mm, y_val_mm, w_obj_mm, h_obj_mm in label_infos:
p_pt = to_pdf_pt(np.array([[max_x_mm, y_val_mm]]))
x_text_pt = p_pt[0, 0] + (2.5 * MM_TO_PT)
y_text_pt = p_pt[0, 1] - (font_size_pt * 0.35)
lines_to_draw: list[str] = []
if show_name:
lines_to_draw.append(str(obj_name))
if show_bbox_size:
if main_step >= 1000.0:
w_val = w_obj_mm / 1000.0
h_val = h_obj_mm / 1000.0
size_str = f"(X: {w_val:g}m Y: {h_val:g}m)"
else:
size_str = f"(X: {w_obj_mm:g}mm Y: {h_obj_mm:g}mm)"
lines_to_draw.append(size_str)
for key_name, suffix in settings_items:
attr_val = get_first_attribute_value(so_item, key_name)
if attr_val is not None:
if isinstance(attr_val, float):
val_str = f"{attr_val:g}"
else:
val_str = str(attr_val)
if suffix:
val_str = f"{val_str}{suffix}"
lines_to_draw.append(f"{key_name}: {val_str}")
for line_idx, line_str in enumerate(lines_to_draw):
clean_str = line_str.replace("(", "\\(").replace(")", "\\)")
curr_y_pt = y_text_pt - (line_idx * line_spacing_pt)
stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {curr_y_pt:.2f} Tm")
stream_cmds.append(f"({clean_str}) Tj")
stream_cmds.append("ET")
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 >> /Font << /F1 8 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")
objects.append(b"8 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\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")
# Alias for backward compatibility
export_wireframe_to_pdf = export_mesh_to_pdf