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