ref
This commit is contained in:
parent
5bb30b08a0
commit
a4068addf9
11 changed files with 1896 additions and 1077 deletions
|
|
@ -363,10 +363,12 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
|
|||
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]
|
||||
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_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]
|
||||
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]
|
||||
|
||||
bg_color: NfFloatVectorProperty(
|
||||
|
|
@ -413,9 +415,11 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
|
|||
layout.prop(self, "draw_bbox")
|
||||
if self.draw_bbox:
|
||||
layout.prop(self, "bbox_color")
|
||||
layout.prop(self, "draw_sa2_measures")
|
||||
layout.prop(self, "show_name")
|
||||
layout.prop(self, "show_bbox_size")
|
||||
layout.prop(self, "line_width")
|
||||
layout.prop(self, "font_size")
|
||||
layout.prop(self, "margin")
|
||||
layout.prop(self, "bg_color")
|
||||
layout.prop(self, "wire_color")
|
||||
|
|
@ -473,11 +477,13 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
|
|||
draw_contour=self.draw_contour,
|
||||
draw_crease=self.draw_crease,
|
||||
draw_bbox=self.draw_bbox,
|
||||
draw_sa2_measures=self.draw_sa2_measures,
|
||||
show_name=self.show_name,
|
||||
show_bbox_size=self.show_bbox_size,
|
||||
crease_angle=self.crease_angle,
|
||||
margin_mm=self.margin,
|
||||
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])),
|
||||
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])),
|
||||
|
|
|
|||
1073
core/pdf_exporter.py
1073
core/pdf_exporter.py
File diff suppressed because it is too large
Load diff
58
core/pdf_exporter/__init__.py
Normal file
58
core/pdf_exporter/__init__.py
Normal 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",
|
||||
]
|
||||
BIN
core/pdf_exporter/fonts/OpenGorton-Regular.otf
Normal file
BIN
core/pdf_exporter/fonts/OpenGorton-Regular.otf
Normal file
Binary file not shown.
443
core/pdf_exporter/geometry.py
Normal file
443
core/pdf_exporter/geometry.py
Normal 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)
|
||||
142
core/pdf_exporter/metadata.py
Normal file
142
core/pdf_exporter/metadata.py
Normal 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
|
||||
518
core/pdf_exporter/sa2_measures.py
Normal file
518
core/pdf_exporter/sa2_measures.py
Normal 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
538
core/pdf_exporter/writer.py
Normal 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
|
||||
|
|
@ -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`.
|
||||
- **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.
|
||||
- **Font Size (pt)**: Font size in PostScript points for all text labels and measurement callouts.
|
||||
- **Margin (mm)**: Margin around the geometry grid in mm.
|
||||
- **Background**: Color picker for the canvas background.
|
||||
- **Wireframe**: Color picker for the wireframe lines and grid.
|
||||
|
|
|
|||
BIN
fonts/OpenGorton-Regular.otf
Normal file
BIN
fonts/OpenGorton-Regular.otf
Normal file
Binary file not shown.
|
|
@ -16,9 +16,15 @@ class DummyNode: pass
|
|||
class DummyObject: pass
|
||||
class DummyMesh: pass
|
||||
|
||||
class DummyBase: pass
|
||||
mock_types = MagicMock()
|
||||
mock_types.NodeSocket = DummyNodeSocket
|
||||
mock_types.Node = DummyNode
|
||||
mock_types.NodeSocket = type("NodeSocket", (DummyBase,), {})
|
||||
mock_types.Node = type("Node", (DummyBase,), {})
|
||||
mock_types.NodeCustomGroup = type("NodeCustomGroup", (DummyBase,), {})
|
||||
mock_types.NodeTree = type("NodeTree", (DummyBase,), {})
|
||||
mock_types.PropertyGroup = type("PropertyGroup", (DummyBase,), {})
|
||||
mock_types.Operator = type("Operator", (DummyBase,), {})
|
||||
mock_types.Panel = type("Panel", (DummyBase,), {})
|
||||
mock_types.Object = DummyObject
|
||||
mock_types.Mesh = DummyMesh
|
||||
|
||||
|
|
@ -32,6 +38,9 @@ sys.modules['bpy'] = mock_bpy
|
|||
sys.modules['bpy.types'] = mock_types
|
||||
sys.modules['bpy.props'] = MagicMock()
|
||||
sys.modules['bpy.utils'] = MagicMock()
|
||||
sys.modules['bpy.utils.previews'] = MagicMock()
|
||||
sys.modules['bpy.app'] = MagicMock()
|
||||
sys.modules['bpy.app.handlers'] = MagicMock()
|
||||
|
||||
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
|
||||
from NodeSlicer.core.pdf_exporter import (
|
||||
|
|
@ -146,8 +155,9 @@ class TestPdfExporter(unittest.TestCase):
|
|||
|
||||
minor_step = 1.0 # main_step 10.0 / 10
|
||||
total_margin_mm = margin_mm + 2.0 * minor_step
|
||||
expected_w = (100.0 + 2 * total_margin_mm) * MM_TO_PT
|
||||
expected_h = (100.0 + 2 * total_margin_mm) * MM_TO_PT
|
||||
# 100mm geometry padded by 1 minor_step clearance expands grid to 120mm
|
||||
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")
|
||||
|
||||
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("(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__':
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue