This commit is contained in:
Nicolas Predella 2026-07-30 12:29:31 +03:00
parent 5bb30b08a0
commit a4068addf9
11 changed files with 1896 additions and 1077 deletions

View file

@ -363,10 +363,12 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
draw_crease: NfBoolProperty(name="Crease Angle", default=False) # pyright: ignore[reportInvalidTypeForm] draw_crease: NfBoolProperty(name="Crease Angle", default=False) # pyright: ignore[reportInvalidTypeForm]
crease_angle: NfFloatProperty(name="Angle (°)", default=30.0, min=0.0, max=180.0) # pyright: ignore[reportInvalidTypeForm] crease_angle: NfFloatProperty(name="Angle (°)", default=30.0, min=0.0, max=180.0) # pyright: ignore[reportInvalidTypeForm]
draw_bbox: NfBoolProperty(name="Bounding Box", default=False) # pyright: ignore[reportInvalidTypeForm] draw_bbox: NfBoolProperty(name="Bounding Box", default=False) # pyright: ignore[reportInvalidTypeForm]
draw_sa2_measures: NfBoolProperty(name="SA2 measures", default=False) # pyright: ignore[reportInvalidTypeForm]
show_name: NfBoolProperty(name="Show Name", default=False) # pyright: ignore[reportInvalidTypeForm] show_name: NfBoolProperty(name="Show Name", default=False) # pyright: ignore[reportInvalidTypeForm]
show_bbox_size: NfBoolProperty(name="Bounding Box Sizes", default=False) # pyright: ignore[reportInvalidTypeForm] show_bbox_size: NfBoolProperty(name="Bounding Box Sizes", default=False) # pyright: ignore[reportInvalidTypeForm]
line_width: NfFloatProperty(name="Line Width (mm)", default=0.5, min=0.01, max=50.0) # pyright: ignore[reportInvalidTypeForm] line_width: NfFloatProperty(name="Line Width (mm)", default=0.5, min=0.01, max=50.0) # pyright: ignore[reportInvalidTypeForm]
font_size: NfFloatProperty(name="Font Size (pt)", default=8.0, min=1.0, max=100.0) # pyright: ignore[reportInvalidTypeForm]
margin: NfFloatProperty(name="Margin (mm)", default=10.0, min=0.0, max=500.0) # pyright: ignore[reportInvalidTypeForm] margin: NfFloatProperty(name="Margin (mm)", default=10.0, min=0.0, max=500.0) # pyright: ignore[reportInvalidTypeForm]
bg_color: NfFloatVectorProperty( bg_color: NfFloatVectorProperty(
@ -413,9 +415,11 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
layout.prop(self, "draw_bbox") layout.prop(self, "draw_bbox")
if self.draw_bbox: if self.draw_bbox:
layout.prop(self, "bbox_color") layout.prop(self, "bbox_color")
layout.prop(self, "draw_sa2_measures")
layout.prop(self, "show_name") layout.prop(self, "show_name")
layout.prop(self, "show_bbox_size") layout.prop(self, "show_bbox_size")
layout.prop(self, "line_width") layout.prop(self, "line_width")
layout.prop(self, "font_size")
layout.prop(self, "margin") layout.prop(self, "margin")
layout.prop(self, "bg_color") layout.prop(self, "bg_color")
layout.prop(self, "wire_color") layout.prop(self, "wire_color")
@ -473,11 +477,13 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
draw_contour=self.draw_contour, draw_contour=self.draw_contour,
draw_crease=self.draw_crease, draw_crease=self.draw_crease,
draw_bbox=self.draw_bbox, draw_bbox=self.draw_bbox,
draw_sa2_measures=self.draw_sa2_measures,
show_name=self.show_name, show_name=self.show_name,
show_bbox_size=self.show_bbox_size, show_bbox_size=self.show_bbox_size,
crease_angle=self.crease_angle, crease_angle=self.crease_angle,
margin_mm=self.margin, margin_mm=self.margin,
line_width_mm=self.line_width, line_width_mm=self.line_width,
font_size_pt=self.font_size,
stroke_color=(float(self.wire_color[0]), float(self.wire_color[1]), float(self.wire_color[2])), 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])), bg_color=(float(self.bg_color[0]), float(self.bg_color[1]), float(self.bg_color[2])),
bbox_color=(float(self.bbox_color[0]), float(self.bbox_color[1]), float(self.bbox_color[2])), bbox_color=(float(self.bbox_color[0]), float(self.bbox_color[1]), float(self.bbox_color[2])),

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,58 @@
"""
NodeSlicer PDF Exporter Package.
Modular structure:
- geometry.py: 2D edge extraction (wireframe, lineart, boundary, contour, crease).
- metadata.py: SlicingObject labels, attributes, and bounding box extraction.
- sa2_measures.py: SA2 automated measurements (bores Ø, arcs R, chamfers C, pitch, steps).
- writer.py: Scalable vector PDF stream assembly & file writing.
"""
from .geometry import (
deduplicate_2d_edges,
extract_unique_top_view_edges,
extract_lineart_edges,
extract_boundary_edges,
extract_outer_contour_edges,
extract_crease_edges,
)
from .metadata import (
get_first_attribute_value,
extract_settings_items,
extract_object_label_info,
extract_object_name_positions,
extract_object_bounding_boxes,
)
from .sa2_measures import (
circumcircle_3pts,
extract_slicing_objects_bounds,
extract_slicing_object_features,
render_sa2_measures_pdf,
)
from .writer import (
MM_TO_PT,
_format_pdf_lines,
export_mesh_to_pdf,
export_wireframe_to_pdf,
)
__all__ = [
"export_mesh_to_pdf",
"export_wireframe_to_pdf",
"deduplicate_2d_edges",
"extract_unique_top_view_edges",
"extract_lineart_edges",
"extract_boundary_edges",
"extract_outer_contour_edges",
"extract_crease_edges",
"get_first_attribute_value",
"extract_settings_items",
"extract_object_label_info",
"extract_object_name_positions",
"extract_object_bounding_boxes",
"circumcircle_3pts",
"extract_slicing_objects_bounds",
"extract_slicing_object_features",
"render_sa2_measures_pdf",
"MM_TO_PT",
"_format_pdf_lines",
]

Binary file not shown.

View file

@ -0,0 +1,443 @@
"""
2D Vector Edge Extraction Module for PDF Exporter.
Extracts top-view wireframe, lineart, boundary, contour, and crease edges from 3D SlicingObjects.
"""
import math
from typing import Any
import numpy as np
from numpy.typing import NDArray
def deduplicate_2d_edges(edges_2d: NDArray[np.float64]) -> NDArray[np.float64]:
"""
Deduplicates 2D edge segments regardless of direction (A->B or B->A).
"""
if len(edges_2d) == 0:
return edges_2d
p0 = edges_2d[:, 0, :]
p1 = edges_2d[:, 1, :]
swap_mask = (p0[:, 0] > p1[:, 0]) | ((p0[:, 0] == p1[:, 0]) & (p0[:, 1] > p1[:, 1]))
canonical_p0 = np.where(swap_mask[:, None], p1, p0)
canonical_p1 = np.where(swap_mask[:, None], p0, p1)
quantized_p0 = np.round(canonical_p0, decimals=4)
quantized_p1 = np.round(canonical_p1, decimals=4)
stacked = np.column_stack([quantized_p0, quantized_p1])
_, unique_indices = np.unique(stacked, axis=0, return_index=True)
return edges_2d[unique_indices]
def extract_unique_top_view_edges(mesh_data: Any) -> NDArray[np.float64]:
"""
Extracts 2D top-view unique edges (X, Y projection) from mesh_data.
"""
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]
all_edge_segments.append(edge_3d[:, :, :2])
elif isinstance(item, SlicingObject):
if item.mesh.size > 0:
verts_3d = item.mesh[:, :3, :3]
verts_2d = verts_3d[:, :, :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)
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)
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)
if item.mesh.shape[1] >= 4:
normals = item.mesh[:, 3, :] # (N, 3)
else:
v0_3d = verts_3d[:, 0, :]
v1_3d = verts_3d[:, 1, :]
v2_3d = verts_3d[:, 2, :]
normals = np.cross(v1_3d - v0_3d, v2_3d - v0_3d)
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 CW vs CCW triangle orientation 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)
verts_2d = tris_3d[:, :, :2] # (N, 3, 2)
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
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)
edge_cw = np.tile(is_cw, 3)
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)
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
)
num_unique = len(unique_keys)
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)

View file

@ -0,0 +1,142 @@
"""
Object Metadata and Bounding Box Extraction Module for PDF Exporter.
Extracts SlicingObject names, 2D bounding boxes, and custom settings attributes.
"""
from typing import Any
import numpy as np
from numpy.typing import NDArray
def get_first_attribute_value(so_item: Any, attr_name: str) -> Any:
"""
Extracts scalar attribute value for a SlicingObject.
"""
if not hasattr(so_item, 'attributes') or not isinstance(so_item.attributes, dict):
return None
if attr_name not in so_item.attributes:
return None
attr_info = so_item.attributes[attr_name]
if isinstance(attr_info, dict) and 'values' in attr_info:
vals = attr_info['values']
elif isinstance(attr_info, (list, tuple, np.ndarray)):
vals = attr_info
else:
return attr_info
if len(vals) == 0:
return None
val = vals[0]
if hasattr(val, 'item'):
return val.item()
return val
def extract_settings_items(settings: Any) -> list[tuple[str, str]]:
"""
Parses settings dict or object into key/suffix list.
"""
if settings is None:
return []
items: list[tuple[str, str]] = []
if isinstance(settings, dict):
for k, v in settings.items():
suffix = str(v) if v is not None else ""
items.append((str(k), suffix))
elif hasattr(settings, '__dict__'):
for k, v in settings.__dict__.items():
if not k.startswith('_'):
suffix = str(v) if v is not None else ""
items.append((str(k), suffix))
return items
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.
"""
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
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)
# Aliases for backward compatibility
extract_object_name_positions = extract_object_label_info

View file

@ -0,0 +1,518 @@
"""
SA2 Automated Vector Geometry Measurement & ISO 129-1 Technical Marking Engine.
Detects circular bores (Ø), arc cutouts & fillets (R), bevels & chamfers (C), hole pitch, and step features per SlicingObject.
"""
import math
from typing import Any, Callable
import numpy as np
from numpy.typing import NDArray
from .geometry import extract_lineart_edges, extract_unique_top_view_edges, deduplicate_2d_edges, extract_boundary_edges
MM_TO_PT = 2.834645669291339
def circumcircle_3pts(p1: NDArray, p2: NDArray, p3: NDArray) -> tuple[tuple[float, float] | None, float | None]:
"""Calculate center and radius of circumcircle passing through 3 2D points."""
ax, ay = float(p1[0]), float(p1[1])
bx, by = float(p2[0]), float(p2[1])
cx, cy = float(p3[0]), float(p3[1])
d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
if abs(d) < 1e-8:
return None, None
ux = ((ax**2 + ay**2) * (by - cy) + (bx**2 + by**2) * (cy - ay) + (cx**2 + cy**2) * (ay - by)) / d
uy = ((ax**2 + ay**2) * (cx - bx) + (bx**2 + by**2) * (ax - cx) + (cx**2 + cy**2) * (bx - ax)) / d
r = float(np.sqrt((ax - ux)**2 + (ay - uy)**2))
return (ux, uy), r
def extract_slicing_objects_bounds(mesh_data: Any) -> list[tuple[Any, str, float, float, float, float]]:
"""
Extracts (slicing_object, object_name, min_x_mm, max_x_mm, min_y_mm, max_y_mm) for each SlicingObject in mesh_data.
"""
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:
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())
results.append((item, name, min_x, max_x, min_y, max_y))
process_item(mesh_data)
return results
def extract_slicing_object_features(so_item: Any) -> tuple[
list[tuple[float, float, float]], # Holes (cx, cy, dia)
list[tuple[float, float, float, float, float]], # Arcs (cx, cy, r, mid_x, mid_y)
list[tuple[float, float, float, float, float]], # Chamfers (x1, y1, x2, y2, c_val)
list[tuple[float, float, float, float, float]] # Steps (x1, y1, x2, y2, length)
]:
"""
Extracts geometric features from a SlicingObject per ISO 129-1 standards:
1. Circular bores / holes: list of (center_x, center_y, diameter_mm)
2. Arc cutouts & fillets: list of (center_x, center_y, radius_mm, mid_x, mid_y)
3. Bevels / Chamfers: list of (x1, y1, x2, y2, c_val_mm)
4. Internal step / notch segments: list of (x1, y1, x2, y2, length_mm)
"""
b_edges = extract_boundary_edges(so_item)
if len(b_edges) == 0:
b_edges = extract_lineart_edges(so_item)
if len(b_edges) == 0:
b_edges = extract_unique_top_view_edges(so_item)
if len(b_edges) == 0:
return [], [], [], []
b_edges = deduplicate_2d_edges(b_edges)
b_edges = deduplicate_2d_edges(b_edges)
adj: dict[tuple[float, float], list[tuple[float, float]]] = {}
for p0, p1 in b_edges:
pt0 = (round(float(p0[0]), 2), round(float(p0[1]), 2))
pt1 = (round(float(p1[0]), 2), round(float(p1[1]), 2))
if pt0 == pt1:
continue
adj.setdefault(pt0, []).append(pt1)
adj.setdefault(pt1, []).append(pt0)
for pt, nbrs in adj.items():
nbrs.sort(key=lambda n: (n[0] - pt[0]) ** 2 + (n[1] - pt[1]) ** 2)
visited_edges: set[tuple[tuple[float, float], tuple[float, float]]] = set()
loops: list[NDArray[np.float64]] = []
for start_pt in list(adj.keys()):
for neighbor in adj[start_pt]:
edge_key = tuple(sorted([start_pt, neighbor]))
if edge_key in visited_edges:
continue
temp_visited = set()
loop_pts = [start_pt]
temp_visited.add(edge_key)
curr = neighbor
while curr != start_pt:
loop_pts.append(curr)
next_pt = None
for nxt in adj.get(curr, []):
ek = tuple(sorted([curr, nxt]))
if ek not in visited_edges and ek not in temp_visited:
temp_visited.add(ek)
next_pt = nxt
break
if next_pt is None:
break
curr = next_pt
if curr == start_pt:
visited_edges.update(temp_visited)
if len(loop_pts) >= 3:
loops.append(np.array(loop_pts, dtype=np.float64))
holes: list[tuple[float, float, float]] = []
arcs: list[tuple[float, float, float, float, float]] = []
chamfers: list[tuple[float, float, float, float, float]] = []
step_features: list[tuple[float, float, float, float, float]] = []
if so_item.mesh.size == 0:
return holes, arcs, chamfers, step_features
verts_2d = so_item.mesh[:, :3, :2].reshape(-1, 2)
min_x, max_x = float(verts_2d[:, 0].min()), float(verts_2d[:, 0].max())
min_y, max_y = float(verts_2d[:, 1].min()), float(verts_2d[:, 1].max())
overall_w = max_x - min_x
overall_h = max_y - min_y
for loop in loops:
N = len(loop)
if N < 3:
continue
cx_full = float(loop[:, 0].mean())
cy_full = float(loop[:, 1].mean())
dists_full = np.sqrt((loop[:, 0] - cx_full) ** 2 + (loop[:, 1] - cy_full) ** 2)
r_mean_full = float(dists_full.mean())
r_std_full = float(dists_full.std())
# 1. ISO Full Circular Bore (Ø)
if N >= 5 and r_mean_full > 0.5 and (r_std_full / r_mean_full) < 0.20:
if r_mean_full < max(overall_w, overall_h) * 0.48:
holes.append((cx_full, cy_full, r_mean_full * 2.0))
continue
# 2. Trace ISO Arc Cutouts & Fillets (R)
idx_i = 0
visited_indices: set[int] = set()
while idx_i < N:
if idx_i in visited_indices:
idx_i += 1
continue
p1 = loop[idx_i]
p2 = loop[(idx_i + 1) % N]
p3 = loop[(idx_i + 2) % N]
center, r_arc = circumcircle_3pts(p1, p2, p3)
if center is not None and 1.0 <= r_arc <= max(overall_w, overall_h) * 0.45:
arc_seq = [idx_i, (idx_i + 1) % N, (idx_i + 2) % N]
for j in range(idx_i + 3, idx_i + N):
idx_nxt = j % N
p_nxt = loop[idx_nxt]
dist_to_c = np.sqrt((p_nxt[0] - center[0]) ** 2 + (p_nxt[1] - center[1]) ** 2)
if abs(dist_to_c - r_arc) / r_arc < 0.12:
arc_seq.append(idx_nxt)
else:
break
if len(arc_seq) >= 4:
mid_idx = arc_seq[len(arc_seq) // 2]
mid_pt = loop[mid_idx]
arcs.append((float(center[0]), float(center[1]), float(r_arc), float(mid_pt[0]), float(mid_pt[1])))
visited_indices.update(arc_seq)
idx_i += len(arc_seq) - 1
continue
idx_i += 1
# 3. Trace ISO Chamfers / Bevels (C)
for k in range(N):
if k in visited_indices:
continue
p_prev = loop[(k - 1) % N]
p_curr = loop[k]
p_next = loop[(k + 1) % N]
p_next2 = loop[(k + 2) % N]
v1 = p_curr - p_prev
v2 = p_next - p_curr
v3 = p_next2 - p_next
l1, l2, l3 = float(np.linalg.norm(v1)), float(np.linalg.norm(v2)), float(np.linalg.norm(v3))
if l1 > 0 and l2 > 0 and l3 > 0:
cos12 = float(np.dot(v1, v2) / (l1 * l2))
cos23 = float(np.dot(v2, v3) / (l2 * l3))
if 0.45 <= abs(cos12) <= 0.88 and 0.45 <= abs(cos23) <= 0.88:
if 1.0 <= l2 <= min(overall_w, overall_h) * 0.4:
c_val = float(l2 * math.sin(math.pi / 4.0))
chamfers.append((float(p_curr[0]), float(p_curr[1]), float(p_next[0]), float(p_next[1]), c_val))
# 4. Interior Step Segments
for i in range(N):
p1 = loop[i]
p2 = loop[(i + 1) % N]
seg_len = float(np.sqrt((p2[0] - p1[0])**2 + (p2[1] - p1[1])**2))
is_horiz = abs(p2[1] - p1[1]) < 1e-2 and seg_len > 4.0 and abs(seg_len - overall_w) > 1e-2
is_vert = abs(p2[0] - p1[0]) < 1e-2 and seg_len > 4.0 and abs(seg_len - overall_h) > 1e-2
if is_horiz or is_vert:
on_outer_edge = (
abs(p1[0] - min_x) < 1e-2 and abs(p2[0] - min_x) < 1e-2 or
abs(p1[0] - max_x) < 1e-2 and abs(p2[0] - max_x) < 1e-2 or
abs(p1[1] - min_y) < 1e-2 and abs(p2[1] - min_y) < 1e-2 or
abs(p1[1] - max_y) < 1e-2 and abs(p2[1] - max_y) < 1e-2
)
if not on_outer_edge:
step_features.append((float(p1[0]), float(p1[1]), float(p2[0]), float(p2[1]), seg_len))
return holes, arcs, chamfers, step_features
def render_sa2_measures_pdf(
stream_cmds: list[str],
raw_geometry: Any,
draw_sa2_measures: bool,
line_width_pt: float,
main_step: float,
to_pdf_pt: Callable[[NDArray[np.float64]], NDArray[np.float64]],
font_size_pt: float | None = None
) -> None:
"""
Renders ISO 129-1 compliant SA2 automated technical measurements on PDF stream.
"""
if not draw_sa2_measures or raw_geometry is None:
return
sa2_objects = extract_slicing_objects_bounds(raw_geometry)
if not sa2_objects:
return
stream_cmds.append("/GS100 gs")
sa2_lw_pt = max(0.4 * MM_TO_PT, line_width_pt * 0.8)
stream_cmds.append(f"{sa2_lw_pt:.2f} w")
stream_cmds.append("1 j 1 J")
# Vivid red / crimson color for main linear SA2 measures
red_r, red_g, red_b = 0.85, 0.15, 0.15
stream_cmds.append(f"{red_r:.3f} {red_g:.3f} {red_b:.3f} RG")
if font_size_pt is None or font_size_pt <= 0:
font_size_pt = max(8.0, line_width_pt * 4.0)
arrow_len_pt = max(3.5 * MM_TO_PT, line_width_pt * 4.0)
arrow_half_w_pt = arrow_len_pt * 0.40
for so_item, obj_name, min_x, max_x, min_y, max_y in sa2_objects:
w_obj_mm = max_x - min_x
h_obj_mm = max_y - min_y
offset_mm = 8.0
ext_overhang_mm = 2.0
# --- Width Dimension (Horizontal below SlicingObject) ---
y_dim_mm = min_y - offset_mm
p_ext_l1 = to_pdf_pt(np.array([[min_x, min_y - 1.0]]))
p_ext_l2 = to_pdf_pt(np.array([[min_x, y_dim_mm - ext_overhang_mm]]))
p_ext_r1 = to_pdf_pt(np.array([[max_x, min_y - 1.0]]))
p_ext_r2 = to_pdf_pt(np.array([[max_x, y_dim_mm - ext_overhang_mm]]))
stream_cmds.append(f"{p_ext_l1[0,0]:.2f} {p_ext_l1[0,1]:.2f} m {p_ext_l2[0,0]:.2f} {p_ext_l2[0,1]:.2f} l S")
stream_cmds.append(f"{p_ext_r1[0,0]:.2f} {p_ext_r1[0,1]:.2f} m {p_ext_r2[0,0]:.2f} {p_ext_r2[0,1]:.2f} l S")
p_dim_w1 = to_pdf_pt(np.array([[min_x, y_dim_mm]]))
p_dim_w2 = to_pdf_pt(np.array([[max_x, y_dim_mm]]))
x_w1, y_w = p_dim_w1[0, 0], p_dim_w1[0, 1]
x_w2 = p_dim_w2[0, 0]
stream_cmds.append(f"{x_w1:.2f} {y_w:.2f} m {x_w2:.2f} {y_w:.2f} l S")
stream_cmds.append(
f"{x_w1 + arrow_len_pt:.2f} {y_w + arrow_half_w_pt:.2f} m "
f"{x_w1:.2f} {y_w:.2f} l "
f"{x_w1 + arrow_len_pt:.2f} {y_w - arrow_half_w_pt:.2f} l S"
)
stream_cmds.append(
f"{x_w2 - arrow_len_pt:.2f} {y_w + arrow_half_w_pt:.2f} m "
f"{x_w2:.2f} {y_w:.2f} l "
f"{x_w2 - arrow_len_pt:.2f} {y_w - arrow_half_w_pt:.2f} l S"
)
# --- Height Dimension (Vertical to the left of SlicingObject) ---
x_dim_mm = min_x - offset_mm
p_ext_b1 = to_pdf_pt(np.array([[min_x - 1.0, min_y]]))
p_ext_b2 = to_pdf_pt(np.array([[x_dim_mm - ext_overhang_mm, min_y]]))
p_ext_t1 = to_pdf_pt(np.array([[min_x - 1.0, max_y]]))
p_ext_t2 = to_pdf_pt(np.array([[x_dim_mm - ext_overhang_mm, max_y]]))
stream_cmds.append(f"{p_ext_b1[0,0]:.2f} {p_ext_b1[0,1]:.2f} m {p_ext_b2[0,0]:.2f} {p_ext_b2[0,1]:.2f} l S")
stream_cmds.append(f"{p_ext_t1[0,0]:.2f} {p_ext_t1[0,1]:.2f} m {p_ext_t2[0,0]:.2f} {p_ext_t2[0,1]:.2f} l S")
p_dim_h1 = to_pdf_pt(np.array([[x_dim_mm, min_y]]))
p_dim_h2 = to_pdf_pt(np.array([[x_dim_mm, max_y]]))
x_h, y_h1 = p_dim_h1[0, 0], p_dim_h1[0, 1]
y_h2 = p_dim_h2[0, 1]
stream_cmds.append(f"{x_h:.2f} {y_h1:.2f} m {x_h:.2f} {y_h2:.2f} l S")
stream_cmds.append(
f"{x_h - arrow_half_w_pt:.2f} {y_h1 + arrow_len_pt:.2f} m "
f"{x_h:.2f} {y_h1:.2f} l "
f"{x_h + arrow_half_w_pt:.2f} {y_h1 + arrow_len_pt:.2f} l S"
)
stream_cmds.append(
f"{x_h - arrow_half_w_pt:.2f} {y_h2 - arrow_len_pt:.2f} m "
f"{x_h:.2f} {y_h2:.2f} l "
f"{x_h + arrow_half_w_pt:.2f} {y_h2 - arrow_len_pt:.2f} l S"
)
# --- Text Labels for Width and Height ---
stream_cmds.append(f"{red_r:.3f} {red_g:.3f} {red_b:.3f} rg")
stream_cmds.append("BT")
stream_cmds.append(f"/F1 {font_size_pt:.1f} Tf")
w_str = f"{w_obj_mm/1000:g}m" if main_step >= 1000.0 else f"{w_obj_mm:g}mm"
x_w_text = (x_w1 + x_w2) / 2.0 - (len(w_str) * font_size_pt * 0.28)
y_w_text = y_w - font_size_pt * 1.1
stream_cmds.append(f"1 0 0 1 {x_w_text:.2f} {y_w_text:.2f} Tm")
stream_cmds.append(f"({w_str}) Tj")
h_str = f"{h_obj_mm/1000:g}m" if main_step >= 1000.0 else f"{h_obj_mm:g}mm"
x_h_text = x_h - (len(h_str) * font_size_pt * 0.55) - (2.0 * MM_TO_PT)
y_h_text = (y_h1 + y_h2) / 2.0 - (font_size_pt * 0.35)
stream_cmds.append(f"1 0 0 1 {x_h_text:.2f} {y_h_text:.2f} Tm")
stream_cmds.append(f"({h_str}) Tj")
stream_cmds.append("ET")
# --- Additional SA2 Feature Measures: Bores (Ø), Arcs (R), Chamfers (C) & Pitch ---
holes, arcs, chamfers, step_features = extract_slicing_object_features(so_item)
# 1. ISO 129-1 Circular Bores / Hole Diameter Callouts (Ø - Electric Blue)
if holes:
blue_r, blue_g, blue_b = 0.10, 0.45, 0.90
stream_cmds.append(f"{blue_r:.3f} {blue_g:.3f} {blue_b:.3f} RG")
stream_cmds.append(f"{blue_r:.3f} {blue_g:.3f} {blue_b:.3f} rg")
for cx_mm, cy_mm, dia_mm in holes:
r_mm = dia_mm / 2.0
px_edge = cx_mm + r_mm * 0.7071
py_edge = cy_mm + r_mm * 0.7071
px_shoulder = px_edge + 8.0
py_shoulder = py_edge + 8.0
p_edge = to_pdf_pt(np.array([[px_edge, py_edge]]))
p_sh = to_pdf_pt(np.array([[px_shoulder, py_shoulder]]))
p_sh2 = to_pdf_pt(np.array([[px_shoulder + 10.0, py_shoulder]]))
x_e, y_e = p_edge[0, 0], p_edge[0, 1]
x_s1, y_s1 = p_sh[0, 0], p_sh[0, 1]
x_s2 = p_sh2[0, 0]
stream_cmds.append(f"{x_e:.2f} {y_e:.2f} m {x_s1:.2f} {y_s1:.2f} l {x_s2:.2f} {y_s1:.2f} l S")
stream_cmds.append(
f"{x_e + arrow_len_pt * 0.7071:.2f} {y_e + arrow_len_pt * 0.7071:.2f} m "
f"{x_e:.2f} {y_e:.2f} l "
f"{x_e + arrow_len_pt * 0.3535:.2f} {y_e + arrow_len_pt * 0.95:.2f} l S"
)
dia_val = round(dia_mm, 1)
dia_str = f"DIA {dia_val:g}mm" if main_step < 1000.0 else f"DIA {dia_val/1000:g}m"
stream_cmds.append("BT")
stream_cmds.append(f"/F1 {font_size_pt * 0.9:.1f} Tf")
stream_cmds.append(f"1 0 0 1 {x_s1 + 2.0:.2f} {y_s1 + 2.0:.2f} Tm")
stream_cmds.append(f"({dia_str}) Tj")
stream_cmds.append("ET")
# 2. ISO 129-1 Arc Cutouts & Corner Fillets (R - Amber Orange)
if arcs:
amber_r, amber_g, amber_b = 0.90, 0.45, 0.10
stream_cmds.append(f"{amber_r:.3f} {amber_g:.3f} {amber_b:.3f} RG")
stream_cmds.append(f"{amber_r:.3f} {amber_g:.3f} {amber_b:.3f} rg")
for acx_mm, acy_mm, r_mm, mid_x_mm, mid_y_mm in arcs[:4]:
p_center = to_pdf_pt(np.array([[acx_mm, acy_mm]]))
p_mid = to_pdf_pt(np.array([[mid_x_mm, mid_y_mm]]))
p_sh1 = to_pdf_pt(np.array([[mid_x_mm + 6.0, mid_y_mm + 6.0]]))
p_sh2 = to_pdf_pt(np.array([[mid_x_mm + 14.0, mid_y_mm + 6.0]]))
xc, yc = p_center[0, 0], p_center[0, 1]
xm, ym = p_mid[0, 0], p_mid[0, 1]
xs1, ys1 = p_sh1[0, 0], p_sh1[0, 1]
xs2 = p_sh2[0, 0]
stream_cmds.append(f"{xm:.2f} {ym:.2f} m {xs1:.2f} {ys1:.2f} l {xs2:.2f} {ys1:.2f} l S")
ux, uy = (xm - xc), (ym - yc)
dist_cm = math.sqrt(ux*ux + uy*uy)
if dist_cm > 1e-3:
ux, uy = ux / dist_cm, uy / dist_cm
stream_cmds.append(
f"{xm - arrow_len_pt * ux + arrow_half_w_pt * uy:.2f} {ym - arrow_len_pt * uy - arrow_half_w_pt * ux:.2f} m "
f"{xm:.2f} {ym:.2f} l "
f"{xm - arrow_len_pt * ux - arrow_half_w_pt * uy:.2f} {ym - arrow_len_pt * uy + arrow_half_w_pt * ux:.2f} l S"
)
r_val = round(r_mm, 1)
r_str = f"R{r_val:g}mm"
stream_cmds.append("BT")
stream_cmds.append(f"/F1 {font_size_pt * 0.9:.1f} Tf")
stream_cmds.append(f"1 0 0 1 {xs1 + 2.0:.2f} {ys1 + 2.0:.2f} Tm")
stream_cmds.append(f"({r_str}) Tj")
stream_cmds.append("ET")
# 3. ISO 129-1 Bevels & Chamfers (C / 45° - Violet Purple)
if chamfers:
purple_r, purple_g, purple_b = 0.60, 0.20, 0.85
stream_cmds.append(f"{purple_r:.3f} {purple_g:.3f} {purple_b:.3f} RG")
stream_cmds.append(f"{purple_r:.3f} {purple_g:.3f} {purple_b:.3f} rg")
for ch_x1, ch_y1, ch_x2, ch_y2, c_val in chamfers[:4]:
ch_mid_x = (ch_x1 + ch_x2) / 2.0
ch_mid_y = (ch_y1 + ch_y2) / 2.0
sh_x = ch_mid_x + 6.0
sh_y = ch_mid_y + 6.0
p_ch_mid = to_pdf_pt(np.array([[ch_mid_x, ch_mid_y]]))
p_ch_sh1 = to_pdf_pt(np.array([[sh_x, sh_y]]))
p_ch_sh2 = to_pdf_pt(np.array([[sh_x + 10.0, sh_y]]))
x_cm, y_cm = p_ch_mid[0, 0], p_ch_mid[0, 1]
x_cs1, y_cs1 = p_ch_sh1[0, 0], p_ch_sh1[0, 1]
x_cs2 = p_ch_sh2[0, 0]
stream_cmds.append(f"{x_cm:.2f} {y_cm:.2f} m {x_cs1:.2f} {y_cs1:.2f} l {x_cs2:.2f} {y_cs1:.2f} l S")
stream_cmds.append(
f"{x_cm + arrow_len_pt * 0.7071:.2f} {y_cm + arrow_len_pt * 0.7071:.2f} m "
f"{x_cm:.2f} {y_cm:.2f} l "
f"{x_cm + arrow_len_pt * 0.3535:.2f} {y_cm + arrow_len_pt * 0.95:.2f} l S"
)
c_val_rounded = round(c_val, 1)
c_str = f"C{c_val_rounded:g}mm"
stream_cmds.append("BT")
stream_cmds.append(f"/F1 {font_size_pt * 0.85:.1f} Tf")
stream_cmds.append(f"1 0 0 1 {x_cs1 + 2.0:.2f} {y_cs1 + 2.0:.2f} Tm")
stream_cmds.append(f"({c_str}) Tj")
stream_cmds.append("ET")
# 4. Hole Center Pitch Distances (Teal Green)
if len(holes) >= 2:
teal_r, teal_g, teal_b = 0.05, 0.58, 0.53
stream_cmds.append(f"{teal_r:.3f} {teal_g:.3f} {teal_b:.3f} RG")
stream_cmds.append(f"{teal_r:.3f} {teal_g:.3f} {teal_b:.3f} rg")
sorted_holes = sorted(holes, key=lambda h: (h[0], h[1]))
for i in range(len(sorted_holes) - 1):
h1, h2 = sorted_holes[i], sorted_holes[i + 1]
dist_mm = math.sqrt((h2[0] - h1[0])**2 + (h2[1] - h1[1])**2)
if dist_mm > 1.0:
p_h1 = to_pdf_pt(np.array([[h1[0], h1[1]]]))
p_h2 = to_pdf_pt(np.array([[h2[0], h2[1]]]))
x1, y1 = p_h1[0, 0], p_h1[0, 1]
x2, y2 = p_h2[0, 0], p_h2[0, 1]
stream_cmds.append(f"{x1:.2f} {y1:.2f} m {x2:.2f} {y2:.2f} l S")
pitch_str = f"PITCH: {dist_mm:g}mm"
stream_cmds.append("BT")
stream_cmds.append(f"/F1 {font_size_pt * 0.85:.1f} Tf")
stream_cmds.append(f"1 0 0 1 {(x1+x2)/2.0:.2f} {(y1+y2)/2.0 + 3.0:.2f} Tm")
stream_cmds.append(f"({pitch_str}) Tj")
stream_cmds.append("ET")
# 5. Interior Step / Notch Feature Segment Lengths (Deep Purple)
if step_features:
purple_r, purple_g, purple_b = 0.48, 0.23, 0.92
stream_cmds.append(f"{purple_r:.3f} {purple_g:.3f} {purple_b:.3f} RG")
stream_cmds.append(f"{purple_r:.3f} {purple_g:.3f} {purple_b:.3f} rg")
for x1_m, y1_m, x2_m, y2_m, step_len in step_features[:4]:
p_s1 = to_pdf_pt(np.array([[x1_m, y1_m]]))
p_s2 = to_pdf_pt(np.array([[x2_m, y2_m]]))
xs1, ys1 = p_s1[0, 0], p_s1[0, 1]
xs2, ys2 = p_s2[0, 0], p_s2[0, 1]
stream_cmds.append(f"{xs1:.2f} {ys1:.2f} m {xs2:.2f} {ys2:.2f} l S")
step_str = f"{step_len:g}mm"
stream_cmds.append("BT")
stream_cmds.append(f"/F1 {font_size_pt * 0.8:.1f} Tf")
stream_cmds.append(f"1 0 0 1 {(xs1+xs2)/2.0:.2f} {(ys1+ys2)/2.0 + 2.0:.2f} Tm")
stream_cmds.append(f"({step_str}) Tj")
stream_cmds.append("ET")

538
core/pdf_exporter/writer.py Normal file
View file

@ -0,0 +1,538 @@
"""
PDF Export Stream Assembly & Writer Module.
Formats vector edge arrays and renders scalable PDF files with grid overlays, text labels, and SA2 technical markings.
"""
import math
import os
from pathlib import Path
from typing import Any
import numpy as np
from numpy.typing import NDArray
from .metadata import (
extract_object_label_info,
extract_object_bounding_boxes,
extract_settings_items,
get_first_attribute_value,
)
from .geometry import (
extract_boundary_edges,
extract_outer_contour_edges,
extract_crease_edges,
deduplicate_2d_edges,
)
from .sa2_measures import render_sa2_measures_pdf
MM_TO_PT = 2.834645669291339
def _format_pdf_lines(lines_arr: NDArray[np.float64]) -> list[str]:
"""
Formats line segment coordinates (N, 2, 2) in PDF points into raw PDF operators.
"""
p0 = lines_arr[:, 0, :]
p1 = lines_arr[:, 1, :]
lines_str = [
f"{x1:.2f} {y1:.2f} m {x2:.2f} {y2:.2f} l"
for (x1, y1), (x2, y2) in zip(p0, p1)
]
return lines_str
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,
draw_sa2_measures: 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,
font_size_pt: float | None = None,
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,
**kwargs: Any
) -> 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:
pad_x = minor_step
pad_y = minor_step
half_span_x = math.ceil(max(abs((max_x + pad_x) - center_x), abs((min_x - pad_x) - center_x)) / main_step) * main_step
half_span_y = math.ceil(max(abs((max_y + pad_y) - center_y), abs((min_y - pad_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 min_x - grid_min_x < minor_step:
grid_min_x -= main_step
if grid_max_x - max_x < minor_step:
grid_max_x += main_step
if min_y - grid_min_y < minor_step:
grid_min_y -= main_step
if grid_max_y - max_y < minor_step:
grid_max_y += 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: NDArray[np.float64]) -> NDArray[np.float64]:
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:
b_r, b_g, b_b = 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"{b_r:.3f} {b_g:.3f} {b_b:.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")
# 5.6. SA2 Automated Technical Measures Block
if draw_sa2_measures and raw_geometry is not None:
render_sa2_measures_pdf(stream_cmds, raw_geometry, draw_sa2_measures, line_width_pt, main_step, to_pdf_pt, font_size_pt=font_size_pt)
# 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 = font_size_pt if font_size_pt is not None and font_size_pt > 0 else 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")
# Embed Open Gorton Typeface
font_file_path = Path(__file__).parent / "fonts" / "OpenGorton-Regular.otf"
if not font_file_path.exists():
font_file_path = Path(__file__).parents[2] / "fonts" / "OpenGorton-Regular.otf"
font_bytes: bytes | None = None
if font_file_path.exists():
try:
with open(font_file_path, "rb") as f:
font_bytes = f.read()
except Exception:
font_bytes = None
if font_bytes:
widths_str = " ".join(["600"] * 224)
objects.append(
f"8 0 obj\n"
f"<< /Type /Font /Subtype /Type1 /BaseFont /OpenGorton-Regular "
f"/FirstChar 32 /LastChar 255 "
f"/Widths [ {widths_str} ] "
f"/FontDescriptor 9 0 R /Encoding /WinAnsiEncoding >>\nendobj\n".encode("utf-8")
)
objects.append(
b"9 0 obj\n"
b"<< /Type /FontDescriptor /FontName /OpenGorton-Regular /Flags 32 "
b"/FontBBox [-500 -300 1500 1000] /ItalicAngle 0 /Ascent 800 /Descent -200 "
b"/CapHeight 700 /StemV 80 /FontFile3 10 0 R >>\nendobj\n"
)
font_stream_header = f"10 0 obj\n<< /Length {len(font_bytes)} /Subtype /OpenType >>\nstream\n".encode("utf-8")
font_stream_footer = b"\nendstream\nendobj\n"
objects.append(font_stream_header + font_bytes + font_stream_footer)
else:
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

View file

@ -131,6 +131,7 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
- **Show Name**: Checkbox to render the object name to the right of the rightmost vertex of each `SlicingObject`. - **Show Name**: Checkbox to render the object name to the right of the rightmost vertex of each `SlicingObject`.
- **Bounding Box Sizes**: Checkbox to render 2D bounding box size dimensions (e.g. `X: 120mm Y: 230mm` or `X: 1.2m Y: 2.3m`) next to each object label. - **Bounding Box Sizes**: Checkbox to render 2D bounding box size dimensions (e.g. `X: 120mm Y: 230mm` or `X: 1.2m Y: 2.3m`) next to each object label.
- **Line Width (mm)**: Stroke width in millimeters for the exported lines. - **Line Width (mm)**: Stroke width in millimeters for the exported lines.
- **Font Size (pt)**: Font size in PostScript points for all text labels and measurement callouts.
- **Margin (mm)**: Margin around the geometry grid in mm. - **Margin (mm)**: Margin around the geometry grid in mm.
- **Background**: Color picker for the canvas background. - **Background**: Color picker for the canvas background.
- **Wireframe**: Color picker for the wireframe lines and grid. - **Wireframe**: Color picker for the wireframe lines and grid.

Binary file not shown.

View file

@ -16,9 +16,15 @@ class DummyNode: pass
class DummyObject: pass class DummyObject: pass
class DummyMesh: pass class DummyMesh: pass
class DummyBase: pass
mock_types = MagicMock() mock_types = MagicMock()
mock_types.NodeSocket = DummyNodeSocket mock_types.NodeSocket = type("NodeSocket", (DummyBase,), {})
mock_types.Node = DummyNode 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.Object = DummyObject
mock_types.Mesh = DummyMesh mock_types.Mesh = DummyMesh
@ -32,6 +38,9 @@ sys.modules['bpy'] = mock_bpy
sys.modules['bpy.types'] = mock_types sys.modules['bpy.types'] = mock_types
sys.modules['bpy.props'] = MagicMock() sys.modules['bpy.props'] = MagicMock()
sys.modules['bpy.utils'] = 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.infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
from NodeSlicer.core.pdf_exporter import ( from NodeSlicer.core.pdf_exporter import (
@ -146,8 +155,9 @@ class TestPdfExporter(unittest.TestCase):
minor_step = 1.0 # main_step 10.0 / 10 minor_step = 1.0 # main_step 10.0 / 10
total_margin_mm = margin_mm + 2.0 * minor_step total_margin_mm = margin_mm + 2.0 * minor_step
expected_w = (100.0 + 2 * total_margin_mm) * MM_TO_PT # 100mm geometry padded by 1 minor_step clearance expands grid to 120mm
expected_h = (100.0 + 2 * total_margin_mm) * MM_TO_PT expected_w = (120.0 + 2 * total_margin_mm) * MM_TO_PT
expected_h = (120.0 + 2 * total_margin_mm) * MM_TO_PT
mediabox_str = f"/MediaBox [0 0 {expected_w:.2f} {expected_h:.2f}]".encode("utf-8") mediabox_str = f"/MediaBox [0 0 {expected_w:.2f} {expected_h:.2f}]".encode("utf-8")
self.assertTrue(content.startswith(b"%PDF-1.4")) self.assertTrue(content.startswith(b"%PDF-1.4"))
@ -517,6 +527,182 @@ class TestPdfExporter(unittest.TestCase):
self.assertIn("(layer_height: 0.15 mm) Tj", content) self.assertIn("(layer_height: 0.15 mm) Tj", content)
self.assertIn("(material: PLA) 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_measures=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_measures=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")
# ISO circular callouts (Electric Blue 0.10,0.45,0.90 for Ø or Amber Orange 0.90,0.45,0.10 for R)
self.assertTrue("0.100 0.450 0.900 RG" in content or "0.900 0.450 0.100 RG" in content)
def test_export_pdf_iso_arc_and_chamfer_detection(self):
# Create mesh with a chamfer (10mm) and an arc cutout (R15mm)
t = np.linspace(0, np.pi/2, 8)
arc_pts = np.column_stack([15 - 15*np.cos(t), 15 - 15*np.sin(t)])
pts = [
[0, 15],
[0, 60],
[90, 60], # Chamfer start
[100, 50], # Chamfer end
[100, 0],
[15, 0]
]
polygon_pts = pts[:5] + list(arc_pts[::-1])
# Triangulate top face & extruded 3D mesh
tris = []
for i in range(len(polygon_pts)):
i_next = (i + 1) % len(polygon_pts)
p1_2d = polygon_pts[i]
p2_2d = polygon_pts[i_next]
p1_top = [p1_2d[0], p1_2d[1], 10.0]
p2_top = [p2_2d[0], p2_2d[1], 10.0]
p1_bot = [p1_2d[0], p1_2d[1], 0.0]
p2_bot = [p2_2d[0], p2_2d[1], 0.0]
# Side wall tris
tris.append([p1_bot, p1_top, p2_top, [0, 0, 1]])
tris.append([p1_bot, p2_top, p2_bot, [0, 0, 1]])
so = SlicingObject()
so.name = "ArcChamferPart"
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_arc_chamfer.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
draw_sa2_measures=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")
# Verify ISO R (radius amber 0.900 0.450 0.100) and ISO C (chamfer purple 0.600 0.200 0.850)
self.assertIn("0.900 0.450 0.100 RG", content)
self.assertIn("(R15", content)
self.assertIn("0.600 0.200 0.850 RG", content)
self.assertIn("(C10mm) Tj", 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_pt=14.5,
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_open_gorton_font_embedded(self):
so = SlicingObject()
so.name = "GortonTestPart"
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_open_gorton.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("OpenGorton-Regular", content)
self.assertIn("/Subtype /OpenType", content)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()