diff --git a/core/pdf_exporter/__init__.py b/core/pdf_exporter/__init__.py index e7e7495..bf2744d 100644 --- a/core/pdf_exporter/__init__.py +++ b/core/pdf_exporter/__init__.py @@ -4,8 +4,10 @@ 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. +- pdf_utils.py: Reusable PDF utilities (text escaping, drawing operators). +- sa2_measures.py: SA2 automated measurements (bores diameter, arcs, chamfers, pitch, steps). +- sizing.py: Automatic sizing calculations for PDF elements. +- writer.py: Scalable vector PDF stream assembly and file writing. """ from .geometry import ( deduplicate_2d_edges, @@ -22,39 +24,63 @@ from .metadata import ( extract_object_name_positions, extract_object_bounding_boxes, ) +from .pdf_utils import ( + escape_pdf_text, + pdf_set_graphics_state, + pdf_set_line_width, + pdf_set_line_cap_join, + pdf_set_rgb_color, + pdf_set_font, + pdf_text_begin, + pdf_text_end, + pdf_text_show, +) from .sa2_measures import ( circumcircle_3pts, extract_slicing_objects_bounds, extract_slicing_object_features, render_sa2_measures_pdf, ) +from .sizing import MM_TO_PT from .writer import ( - MM_TO_PT, _format_pdf_lines, - escape_pdf_text, export_mesh_to_pdf, export_wireframe_to_pdf, ) __all__ = [ + # Main exporters "export_mesh_to_pdf", "export_wireframe_to_pdf", + # PDF utilities "escape_pdf_text", + "pdf_set_graphics_state", + "pdf_set_line_width", + "pdf_set_line_cap_join", + "pdf_set_rgb_color", + "pdf_set_font", + "pdf_text_begin", + "pdf_text_end", + "pdf_text_show", + # Geometry "deduplicate_2d_edges", "extract_unique_top_view_edges", "extract_lineart_edges", "extract_boundary_edges", "extract_outer_contour_edges", "extract_crease_edges", + # Metadata "get_first_attribute_value", "extract_settings_items", "extract_object_label_info", "extract_object_name_positions", "extract_object_bounding_boxes", + # SA2 measures "circumcircle_3pts", "extract_slicing_objects_bounds", "extract_slicing_object_features", "render_sa2_measures_pdf", + # Constants "MM_TO_PT", "_format_pdf_lines", ] diff --git a/core/pdf_exporter/pdf_utils.py b/core/pdf_exporter/pdf_utils.py new file mode 100644 index 0000000..91eb087 --- /dev/null +++ b/core/pdf_exporter/pdf_utils.py @@ -0,0 +1,156 @@ +""" +PDF Utilities Module. +Reusable utilities for PDF stream generation, text escaping, and drawing operations. +""" +from typing import Callable + + +def escape_pdf_text(text: str) -> str: + """ + Escape special characters for PDF text strings. + + In PDF text strings delimited by parentheses, the following characters + must be escaped with a backslash: backslash, left-paren, right-paren. + + Non-ASCII characters (like diameter symbol, degree, etc.) are left as-is and will be + encoded as cp1252 bytes in the PDF stream, matching the WinAnsiEncoding + used by the embedded font. + + Args: + text: The text string to escape. + + Returns: + The escaped text string safe for PDF text operators. + """ + return text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") + + +def pdf_move_to(x_pt: float, y_pt: float) -> str: + """Generate PDF move-to operator.""" + return f"{x_pt:.2f} {y_pt:.2f} m" + + +def pdf_line_to(x_pt: float, y_pt: float) -> str: + """Generate PDF line-to operator.""" + return f"{x_pt:.2f} {y_pt:.2f} l" + + +def pdf_move_line(x1_pt: float, y1_pt: float, x2_pt: float, y2_pt: float) -> str: + """Generate PDF move-to and line-to sequence.""" + return f"{x1_pt:.2f} {y1_pt:.2f} m {x2_pt:.2f} {y2_pt:.2f} l" + + +def pdf_stroke() -> str: + """Generate PDF stroke operator.""" + return "S" + + +def pdf_text_show(text: str) -> str: + """Generate PDF text show operator with escaped text.""" + return f"({escape_pdf_text(text)}) Tj" + + +def pdf_text_begin() -> str: + """Generate PDF begin text operator.""" + return "BT" + + +def pdf_text_end() -> str: + """Generate PDF end text operator.""" + return "ET" + + +def pdf_set_font(font_name: str, size_pt: float) -> str: + """Generate PDF font selection operator.""" + return f"/{font_name} {size_pt:.1f} Tf" + + +def pdf_set_rgb_color(r: float, g: float, b: float, fill: bool = True) -> str: + """ + Generate PDF color setting operator. + + Args: + r: Red component (0.0-1.0) + g: Green component (0.0-1.0) + b: Blue component (0.0-1.0) + fill: True for fill color (rg), False for stroke color (RG) + + Returns: + PDF color operator string. + """ + color_op = "rg" if fill else "RG" + return f"{r:.3f} {g:.3f} {b:.3f} {color_op}" + + +def pdf_set_line_width(width_pt: float) -> str: + """Generate PDF line width operator.""" + return f"{width_pt:.2f} w" + + +def pdf_set_line_cap_join(cap: int = 1, join: int = 1) -> str: + """ + Generate PDF line cap and join style operators. + + Args: + cap: 0=butt, 1=round, 2=projecting square + join: 0=miter, 1=round, 2=bevel + + Returns: + PDF line style operator string. + """ + return f"{cap} j {join} J" + + +def pdf_set_graphics_state(name: str) -> str: + """Generate PDF graphics state selection operator.""" + return f"/{name} gs" + + +def pdf_transform_matrix(a: float, b: float, c: float, d: float, e: float, f: float) -> str: + """Generate PDF transformation matrix operator.""" + return f"{a:.2f} {b:.2f} {c:.2f} {d:.2f} {e:.2f} {f:.2f} Tm" + + +def pdf_arrowhead( + tip_x_pt: float, + tip_y_pt: float, + direction_x: float, + direction_y: float, + arrow_len_pt: float, + arrow_half_w_pt: float, +) -> str: + """ + Generate PDF commands for an arrowhead. + + Args: + tip_x_pt: X coordinate of arrow tip + tip_y_pt: Y coordinate of arrow tip + direction_x: X direction vector (normalized) + direction_y: Y direction vector (normalized) + arrow_len_pt: Length of arrow in points + arrow_half_w_pt: Half-width of arrow in points + + Returns: + PDF commands to draw the arrowhead. + """ + # Arrow base (opposite of tip direction) + base_x = tip_x_pt - direction_x * arrow_len_pt + base_y = tip_y_pt - direction_y * arrow_len_pt + + # Perpendicular vectors for arrow wings + perp_x = direction_y * arrow_half_w_pt + perp_y = -direction_x * arrow_half_w_pt + + # Left wing + left_x = base_x + perp_x + left_y = base_y + perp_y + + # Right wing + right_x = base_x - perp_x + right_y = base_y - perp_y + + return ( + f"{tip_x_pt:.2f} {tip_y_pt:.2f} m " + f"{left_x:.2f} {left_y:.2f} l " + f"{right_x:.2f} {right_y:.2f} l S" + ) diff --git a/core/pdf_exporter/sa2_measures.py b/core/pdf_exporter/sa2_measures.py index 6a8102a..726f214 100644 --- a/core/pdf_exporter/sa2_measures.py +++ b/core/pdf_exporter/sa2_measures.py @@ -16,15 +16,22 @@ from .geometry import ( extract_crease_edges, ) from .sizing import SA2Sizing, compute_effective_stroke_and_font, MM_TO_PT +from .pdf_utils import ( + escape_pdf_text, + pdf_set_graphics_state, + pdf_set_line_width, + pdf_set_line_cap_join, + pdf_set_rgb_color, + pdf_set_font, + pdf_text_begin, + pdf_text_end, + pdf_text_show, +) -def escape_pdf_text(text: str) -> str: - # For PDF text strings, escape only the PDF special characters: \, (, ) - # Non-ASCII characters will be encoded as cp1252 bytes in the stream - # which matches the WinAnsiEncoding of the font - return text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") - - +# ============================================================================= +# Geometry Utilities +# ============================================================================= 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.""" @@ -42,9 +49,16 @@ def circumcircle_3pts(p1: NDArray, p2: NDArray, p3: NDArray) -> tuple[tuple[floa return (ux, uy), r -def extract_slicing_objects_bounds(mesh_data: Any) -> list[tuple[Any, str, float, float, float, float]]: +# ============================================================================= +# Slicing Object Processing +# ============================================================================= + +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. + 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 @@ -78,6 +92,60 @@ def extract_slicing_objects_bounds(mesh_data: Any) -> list[tuple[Any, str, float return results +def _collect_edges( + so_item: Any, + draw_wireframe: bool = True, + draw_boundary: bool = False, + draw_contour: bool = False, + draw_crease: bool = False, + crease_angle: float = 30.0, +) -> NDArray[np.float64]: + """Collect and deduplicate edges from a SlicingObject based on draw settings.""" + edge_list: list[NDArray[np.float64]] = [] + + if draw_wireframe: + w_edges = extract_unique_top_view_edges(so_item) + if len(w_edges) > 0: + edge_list.append(w_edges) + + if draw_boundary and not draw_crease: + b_edges = extract_boundary_edges(so_item) + if len(b_edges) > 0: + edge_list.append(b_edges) + + if draw_contour: + c_edges = extract_outer_contour_edges(so_item) + if len(c_edges) > 0: + edge_list.append(c_edges) + + if draw_crease: + cr_edges = extract_crease_edges(so_item, 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) + return deduplicate_2d_edges(combined) + return np.empty((0, 2, 2), dtype=np.float64) + + +def _get_so_bounds( + so_item: Any, + bounds: tuple[float, float, float, float] | None = None, +) -> tuple[float, float, float, float]: + """Get bounding box for a SlicingObject.""" + if bounds is not None: + return bounds + elif hasattr(so_item, 'mesh') and so_item.mesh.size > 0: + verts_2d = so_item.mesh[:, :3, :2].reshape(-1, 2) + 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()) + return (min_x, max_x, min_y, max_y) + return (0.0, 0.0, 0.0, 0.0) + + def extract_slicing_object_features( so_item: Any, draw_wireframe: bool = True, @@ -91,33 +159,14 @@ def extract_slicing_object_features( Extracts circular bore / hole features (center_x, center_y, diameter_mm) from a SlicingObject per ISO 129-1 standards based on visible edge layers. """ - edge_list: list[NDArray[np.float64]] = [] - - if draw_wireframe: - w_edges = extract_unique_top_view_edges(so_item) - if len(w_edges) > 0: - edge_list.append(w_edges) - - if draw_boundary and not draw_crease: - b_edges_layer = extract_boundary_edges(so_item) - if len(b_edges_layer) > 0: - edge_list.append(b_edges_layer) - - if draw_contour: - c_edges_layer = extract_outer_contour_edges(so_item) - if len(c_edges_layer) > 0: - edge_list.append(c_edges_layer) - - if draw_crease: - cr_edges_layer = extract_crease_edges(so_item, max_angle_deg=crease_angle) - if len(cr_edges_layer) > 0: - edge_list.append(cr_edges_layer) - - if edge_list: - combined = np.concatenate(edge_list, axis=0) - b_edges = deduplicate_2d_edges(combined) - else: - b_edges = np.empty((0, 2, 2), dtype=np.float64) + b_edges = _collect_edges( + so_item, + draw_wireframe=draw_wireframe, + draw_boundary=draw_boundary, + draw_contour=draw_contour, + draw_crease=draw_crease, + crease_angle=crease_angle, + ) if len(b_edges) == 0: return [] @@ -129,39 +178,353 @@ def extract_slicing_object_features( polygons = list(polygonize(lines)) loops: list[NDArray[np.float64]] = [np.array(poly.exterior.coords, dtype=np.float64) for poly in polygons] - holes: list[tuple[float, float, float]] = [] - - if bounds is not None: - min_x, max_x, min_y, max_y = bounds - elif hasattr(so_item, 'mesh') and so_item.mesh.size > 0: - 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()) - else: - return holes - + min_x, max_x, min_y, max_y = _get_so_bounds(so_item, bounds) overall_w = max_x - min_x overall_h = max_y - min_y + holes: list[tuple[float, float, float]] = [] + 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()) + cx = float(loop[:, 0].mean()) + cy = float(loop[:, 1].mean()) + dists = np.sqrt((loop[:, 0] - cx) ** 2 + (loop[:, 1] - cy) ** 2) + r_mean = float(dists.mean()) + r_std = float(dists.std()) # ISO Full Circular Bore (Ø) Detection - if N >= 5 and r_mean_full > 0.5 and (r_std_full / r_mean_full) < 0.20: - if r_mean_full < min(overall_w, overall_h) * 0.45: - holes.append((cx_full, cy_full, r_mean_full * 2.0)) + if N >= 5 and r_mean > 0.5 and (r_std / r_mean) < 0.20: + if r_mean < min(overall_w, overall_h) * 0.45: + holes.append((cx, cy, r_mean * 2.0)) return holes +# ============================================================================= +# Drawing Helpers +# ============================================================================= + +def _append_pdf_cmd(stream_cmds: list[str], cmd: str) -> None: + """Append a PDF command if not empty.""" + if cmd: + stream_cmds.append(cmd) + + +def _pt_to_str(pt: NDArray[np.float64]) -> tuple[float, float]: + """Convert PDF point array to tuple of floats.""" + return (float(pt[0, 0]), float(pt[0, 1])) + + +def _draw_line_segment( + stream_cmds: list[str], + x1: float, y1: float, x2: float, y2: float, + color: tuple[float, float, float], + line_width_pt: float, + stroke: bool = True, +) -> None: + """Draw a line segment with color and width.""" + stream_cmds.append(pdf_set_rgb_color(*color, fill=False)) + stream_cmds.append(pdf_set_line_width(line_width_pt)) + stream_cmds.append(f"{x1:.2f} {y1:.2f} m {x2:.2f} {y2:.2f} l {"S" if stroke else ""}") + + +def _draw_text( + stream_cmds: list[str], + text: str, + x_pt: float, y_pt: float, + font_size_pt: float, + color: tuple[float, float, float], + char_spacing: float = 0.35, + text_len: int | None = None, + x_offset: float = 0.0, + y_offset: float = 0.0, +) -> None: + """Draw text at a position with automatic centering based on text length.""" + if text_len is None: + text_len = len(text) + text_x = x_pt - (text_len * font_size_pt * char_spacing) + x_offset + text_y = y_pt + y_offset + stream_cmds.append(pdf_set_rgb_color(*color, fill=True)) + stream_cmds.append(pdf_text_begin()) + stream_cmds.append(pdf_set_font("F1", font_size_pt)) + stream_cmds.append(f"1 0 0 1 {text_x:.2f} {text_y:.2f} Tm") + stream_cmds.append(f"({escape_pdf_text(text)}) Tj") + stream_cmds.append(pdf_text_end()) + + +def _draw_horizontal_dimension( + stream_cmds: list[str], + x1_mm: float, y_mm: float, x2_mm: float, + ext_gap_mm: float, ext_overhang_mm: float, + to_pdf_pt: Callable[[NDArray[np.float64]], NDArray[np.float64]], + color: tuple[float, float, float], + line_width_pt: float, + arrow_len_pt: float, + arrow_half_w_pt: float, + text: str, + font_size_pt: float, +) -> None: + """Draw a horizontal dimension with extension lines and arrowheads.""" + # Extension lines + y_ext_gap = y_mm - ext_gap_mm + y_ext_over = y_mm - ext_overhang_mm + + p_l1 = _pt_to_str(to_pdf_pt(np.array([[x1_mm, y_ext_gap]]))) + p_l2 = _pt_to_str(to_pdf_pt(np.array([[x1_mm, y_ext_over]]))) + p_r1 = _pt_to_str(to_pdf_pt(np.array([[x2_mm, y_ext_gap]]))) + p_r2 = _pt_to_str(to_pdf_pt(np.array([[x2_mm, y_ext_over]]))) + + stream_cmds.append(pdf_set_rgb_color(*color, fill=False)) + stream_cmds.append(pdf_set_line_width(line_width_pt)) + stream_cmds.append(f"{p_l1[0]:.2f} {p_l1[1]:.2f} m {p_l2[0]:.2f} {p_l2[1]:.2f} l S") + stream_cmds.append(f"{p_r1[0]:.2f} {p_r1[1]:.2f} m {p_r2[0]:.2f} {p_r2[1]:.2f} l S") + + # Dimension line + p_d1 = _pt_to_str(to_pdf_pt(np.array([[x1_mm, y_mm]]))) + p_d2 = _pt_to_str(to_pdf_pt(np.array([[x2_mm, y_mm]]))) + stream_cmds.append(f"{p_d1[0]:.2f} {p_d1[1]:.2f} m {p_d2[0]:.2f} {p_d2[1]:.2f} l S") + + # Arrowheads + stream_cmds.append( + f"{p_d1[0] + arrow_len_pt:.2f} {p_d1[1] + arrow_half_w_pt:.2f} m " + f"{p_d1[0]:.2f} {p_d1[1]:.2f} l " + f"{p_d1[0] + arrow_len_pt:.2f} {p_d1[1] - arrow_half_w_pt:.2f} l S" + ) + stream_cmds.append( + f"{p_d2[0] - arrow_len_pt:.2f} {p_d2[1] + arrow_half_w_pt:.2f} m " + f"{p_d2[0]:.2f} {p_d2[1]:.2f} l " + f"{p_d2[0] - arrow_len_pt:.2f} {p_d2[1] - arrow_half_w_pt:.2f} l S" + ) + + # Text label + _draw_text( + stream_cmds, text, (p_d1[0] + p_d2[0]) / 2.0, p_d1[1], + font_size_pt, color, char_spacing=0.28, y_offset=-font_size_pt * 1.1 + ) + + +def _draw_vertical_dimension( + stream_cmds: list[str], + x_mm: float, y1_mm: float, y2_mm: float, + ext_gap_mm: float, ext_overhang_mm: float, + to_pdf_pt: Callable[[NDArray[np.float64]], NDArray[np.float64]], + color: tuple[float, float, float], + line_width_pt: float, + arrow_len_pt: float, + arrow_half_w_pt: float, + text: str, + font_size_pt: float, +) -> None: + """Draw a vertical dimension with extension lines and arrowheads.""" + # Extension lines + x_ext_gap = x_mm - ext_gap_mm + x_ext_over = x_mm - ext_overhang_mm + + p_b1 = _pt_to_str(to_pdf_pt(np.array([[x_ext_gap, y1_mm]]))) + p_b2 = _pt_to_str(to_pdf_pt(np.array([[x_ext_over, y1_mm]]))) + p_t1 = _pt_to_str(to_pdf_pt(np.array([[x_ext_gap, y2_mm]]))) + p_t2 = _pt_to_str(to_pdf_pt(np.array([[x_ext_over, y2_mm]]))) + + stream_cmds.append(pdf_set_rgb_color(*color, fill=False)) + stream_cmds.append(pdf_set_line_width(line_width_pt)) + stream_cmds.append(f"{p_b1[0]:.2f} {p_b1[1]:.2f} m {p_b2[0]:.2f} {p_b2[1]:.2f} l S") + stream_cmds.append(f"{p_t1[0]:.2f} {p_t1[1]:.2f} m {p_t2[0]:.2f} {p_t2[1]:.2f} l S") + + # Dimension line + p_d1 = _pt_to_str(to_pdf_pt(np.array([[x_mm, y1_mm]]))) + p_d2 = _pt_to_str(to_pdf_pt(np.array([[x_mm, y2_mm]]))) + stream_cmds.append(f"{p_d1[0]:.2f} {p_d1[1]:.2f} m {p_d2[0]:.2f} {p_d2[1]:.2f} l S") + + # Arrowheads + stream_cmds.append( + f"{p_d1[0] - arrow_half_w_pt:.2f} {p_d1[1] + arrow_len_pt:.2f} m " + f"{p_d1[0]:.2f} {p_d1[1]:.2f} l " + f"{p_d1[0] + arrow_half_w_pt:.2f} {p_d1[1] + arrow_len_pt:.2f} l S" + ) + stream_cmds.append( + f"{p_d2[0] - arrow_half_w_pt:.2f} {p_d2[1] - arrow_len_pt:.2f} m " + f"{p_d2[0]:.2f} {p_d2[1]:.2f} l " + f"{p_d2[0] + arrow_half_w_pt:.2f} {p_d2[1] - arrow_len_pt:.2f} l S" + ) + + # Text label + _draw_text( + stream_cmds, text, p_d1[0], (p_d1[1] + p_d2[1]) / 2.0, + font_size_pt, color, char_spacing=0.55, + x_offset=-2.0 * MM_TO_PT, y_offset=-font_size_pt * 0.35 + ) + + +# ============================================================================= +# Hole Feature Drawing +# ============================================================================= + +def _group_concentric_holes( + holes: list[tuple[float, float, float]], + tolerance_mm: float = 0.5, +) -> list[list[tuple[float, float, float]]]: + """Group holes by center location to identify concentric bores.""" + groups: list[list[tuple[float, float, float]]] = [] + for cx, cy, dia in holes: + added = False + for grp in groups: + gcx, gcy, _ = grp[0] + if math.hypot(cx - gcx, cy - gcy) < tolerance_mm: + grp.append((cx, cy, dia)) + added = True + break + if not added: + groups.append([(cx, cy, dia)]) + return groups + + +def _get_hole_depth_str( + mesh_verts_3d: NDArray[np.float64] | None, + total_z_span: float, + cx_mm: float, cy_mm: float, + dia_mm: float, +) -> str: + """Get depth string for a hole feature.""" + if mesh_verts_3d is None or total_z_span <= 0: + return "" + r_h = dia_mm / 2.0 + d_mask = np.hypot(mesh_verts_3d[:, 0] - cx_mm, mesh_verts_3d[:, 1] - cy_mm) <= (r_h + 0.3) + if not np.any(d_mask): + return "" + z_sub = mesh_verts_3d[d_mask, 2] + h_z_depth = float(z_sub.max() - z_sub.min()) + if abs(h_z_depth - total_z_span) < 0.2: + return " THRU" + elif h_z_depth > 0.1: + return f" x {h_z_depth:.1f} DP" + return "" + + +def _draw_hole_leader_and_arrow( + stream_cmds: list[str], + cx_mm: float, cy_mm: float, dia_mm: float, + to_pdf_pt: Callable[[NDArray[np.float64]], NDArray[np.float64]], + sizing: SA2Sizing, + color: tuple[float, float, float], + line_width_pt: float, + arrow_len_pt: float, +) -> tuple[float, float, float, float]: + """ + Draw hole leader line and arrowhead. + Returns (xe, ye, xs1, ys1) for text positioning. + """ + slant_mm, leader_mm = sizing.get_hole_callout_lengths(dia_mm) + + # Edge point at 45 degrees + offset = dia_mm / 2.0 * 0.7071 + px_edge = cx_mm + offset + py_edge = cy_mm + offset + px_sh1 = px_edge + slant_mm + py_sh1 = py_edge + slant_mm + px_sh2 = px_sh1 + leader_mm + + p_edge = _pt_to_str(to_pdf_pt(np.array([[px_edge, py_edge]]))) + p_sh1 = _pt_to_str(to_pdf_pt(np.array([[px_sh1, py_sh1]]))) + p_sh2 = _pt_to_str(to_pdf_pt(np.array([[px_sh2, py_sh1]]))) + + xe, ye = p_edge + xs1, ys1 = p_sh1 + + stream_cmds.append(pdf_set_rgb_color(*color, fill=False)) + stream_cmds.append(pdf_set_line_width(line_width_pt)) + stream_cmds.append(f"{xe:.2f} {ye:.2f} m {xs1:.2f} {ys1:.2f} l {p_sh2[0]:.2f} {ys1:.2f} l S") + + # Arrowhead at edge (45 degree orientation) + stream_cmds.append( + f"{xe + arrow_len_pt * 0.7071:.2f} {ye + arrow_len_pt * 0.7071:.2f} m " + f"{xe:.2f} {ye:.2f} l " + f"{xe + arrow_len_pt * 0.3535:.2f} {ye + arrow_len_pt * 0.95:.2f} l S" + ) + + return xe, ye, xs1, ys1 + + +def _draw_hole_callout_text_only( + stream_cmds: list[str], + dia_mm: float, + depth_str: str, + xs1: float, ys1: float, + font_size_pt: float, + color: tuple[float, float, float], + line_idx: int, +) -> None: + """Draw the text for a hole callout.""" + dia_val = round(dia_mm, 1) + dia_str = f"Ø {dia_val:g}{depth_str}" + y_offset = ys1 + 2.0 - (line_idx * font_size_pt * 1.1) + + stream_cmds.append(pdf_text_begin()) + stream_cmds.append(pdf_set_font("F1", font_size_pt * 0.85)) + stream_cmds.append(f"1 0 0 1 {xs1 + 2.0:.2f} {y_offset:.2f} Tm") + stream_cmds.append(f"({escape_pdf_text(dia_str)}) Tj") + stream_cmds.append(pdf_text_end()) + + +# ============================================================================= +# Pitch Drawing +# ============================================================================= + +def _draw_pitch_marks( + stream_cmds: list[str], + cx1: float, cy1: float, cx2: float, cy2: float, + pitch_mm: float, pitch_y_mm: float, + to_pdf_pt: Callable[[NDArray[np.float64]], NDArray[np.float64]], + sizing: SA2Sizing, + color: tuple[float, float, float], + line_width_pt: float, + label: str, + font_size_pt: float, + is_horizontal: bool = True, +) -> None: + """Draw pitch dimension between two hole centers.""" + if is_horizontal: + y_pitch = pitch_y_mm + p_h1 = _pt_to_str(to_pdf_pt(np.array([[cx1, y_pitch]]))) + p_h2 = _pt_to_str(to_pdf_pt(np.array([[cx2, y_pitch]]))) + p_e1 = _pt_to_str(to_pdf_pt(np.array([[cx1, cy1]]))) + p_e2 = _pt_to_str(to_pdf_pt(np.array([[cx2, cy2]]))) + + stream_cmds.append(f"{p_e1[0]:.2f} {p_e1[1]:.2f} m {p_h1[0]:.2f} {p_h1[1]:.2f} l S") + stream_cmds.append(f"{p_e2[0]:.2f} {p_e2[1]:.2f} m {p_h2[0]:.2f} {p_h2[1]:.2f} l S") + stream_cmds.append(f"{p_h1[0]:.2f} {p_h1[1]:.2f} m {p_h2[0]:.2f} {p_h2[1]:.2f} l S") + + label_x = (p_h1[0] + p_h2[0]) / 2.0 + label_y = p_h1[1] - font_size_pt * 0.9 + + else: + x_pitch = pitch_mm + p_v1 = _pt_to_str(to_pdf_pt(np.array([[x_pitch, cy1]]))) + p_v2 = _pt_to_str(to_pdf_pt(np.array([[x_pitch, cy2]]))) + p_e1 = _pt_to_str(to_pdf_pt(np.array([[cx1, cy1]]))) + p_e2 = _pt_to_str(to_pdf_pt(np.array([[cx2, cy2]]))) + + stream_cmds.append(f"{p_e1[0]:.2f} {p_e1[1]:.2f} m {p_v1[0]:.2f} {p_v1[1]:.2f} l S") + stream_cmds.append(f"{p_e2[0]:.2f} {p_e2[1]:.2f} m {p_v2[0]:.2f} {p_v2[1]:.2f} l S") + stream_cmds.append(f"{p_v1[0]:.2f} {p_v1[1]:.2f} m {p_v2[0]:.2f} {p_v2[1]:.2f} l S") + + label_x = p_v1[0] + 2.0 + label_y = (p_v1[1] + p_v2[1]) / 2.0 + + # Draw label + stream_cmds.append(pdf_text_begin()) + stream_cmds.append(pdf_set_font("F1", font_size_pt * 0.8)) + stream_cmds.append(f"1 0 0 1 {label_x:.2f} {label_y:.2f} Tm") + stream_cmds.append(f"({escape_pdf_text(label)}) Tj") + stream_cmds.append(pdf_text_end()) + + +# ============================================================================= +# Main Render Function +# ============================================================================= + def render_sa2_measures_pdf( stream_cmds: list[str], raw_geometry: Any, @@ -193,110 +556,52 @@ def render_sa2_measures_pdf( font_size_pt / MM_TO_PT if font_size_pt else 0.0, minor_step_mm ) - - stream_cmds.append("/GS100 gs") - sa2_lw_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") + # Setup: graphics state, line style, and SA2 color + sa2_lw_pt = line_width_pt * 0.8 + sa2_color = (0.85, 0.15, 0.15) + + stream_cmds.append(pdf_set_graphics_state("GS100")) + stream_cmds.append(pdf_set_line_width(sa2_lw_pt)) + stream_cmds.append(pdf_set_line_cap_join(1, 1)) + stream_cmds.append(pdf_set_rgb_color(*sa2_color, fill=False)) 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 sizing = SA2Sizing(w_obj_mm, h_obj_mm, line_width_pt, font_size_pt) - offset_mm = sizing.offset_mm - ext_overhang_mm = sizing.ext_overhang_mm - ext_gap_mm = sizing.ext_gap_mm - arrow_len_pt = sizing.arrow_len_pt - arrow_half_w_pt = sizing.arrow_half_w_pt - + + # Get 3D mesh data for depth calculations mesh_verts_3d = None total_z_span = 0.0 if hasattr(so_item, 'mesh') and so_item.mesh.size > 0: mesh_verts_3d = so_item.mesh[:, :3, :3].reshape(-1, 3) total_z_span = float(mesh_verts_3d[:, 2].max() - mesh_verts_3d[:, 2].min()) - # --- Width Dimension (Horizontal below SlicingObject) --- - y_dim_mm = min_y - offset_mm - - p_ext_l1 = to_pdf_pt(np.array([[min_x, min_y - ext_gap_mm]])) - 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 - ext_gap_mm]])) - 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 - ext_gap_mm, 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 - ext_gap_mm, 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") - + # --- Width Dimension --- 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"({escape_pdf_text(w_str)}) Tj") + _draw_horizontal_dimension( + stream_cmds, + min_x, min_y - sizing.offset_mm, max_x, + sizing.ext_gap_mm, sizing.ext_overhang_mm, + to_pdf_pt, sa2_color, sa2_lw_pt, + sizing.arrow_len_pt, sizing.arrow_half_w_pt, + w_str, font_size_pt + ) + # --- Height Dimension --- 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"({escape_pdf_text(h_str)}) Tj") - stream_cmds.append("ET") + _draw_vertical_dimension( + stream_cmds, + min_x - sizing.offset_mm, min_y, max_y, + sizing.ext_gap_mm, sizing.ext_overhang_mm, + to_pdf_pt, sa2_color, sa2_lw_pt, + sizing.arrow_len_pt, sizing.arrow_half_w_pt, + h_str, font_size_pt + ) - # --- SA2 Hole Features Extraction (Ø) --- + # --- Hole Features --- holes = extract_slicing_object_features( so_item, draw_wireframe=draw_wireframe, @@ -307,77 +612,37 @@ def render_sa2_measures_pdf( bounds=(min_x, max_x, min_y, max_y), ) - # Concentric Bores Grouping & ISO 129-1 Callouts with Z-depth Specs (Electric Blue) + # Concentric Bores Grouping & Hole Callouts if draw_sa2_holes and 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") + hole_color = (0.10, 0.45, 0.90) + stream_cmds.append(pdf_set_rgb_color(*hole_color, fill=False)) + stream_cmds.append(pdf_set_rgb_color(*hole_color, fill=True)) - # Group holes by center location (tolerance = 0.5mm) - concentric_groups: list[list[tuple[float, float, float]]] = [] - for cx, cy, dia in holes: - added = False - for grp in concentric_groups: - gcx, gcy, _ = grp[0] - if math.hypot(cx - gcx, cy - gcy) < 0.5: - grp.append((cx, cy, dia)) - added = True - break - if not added: - concentric_groups.append([(cx, cy, dia)]) + concentric_groups = _group_concentric_holes(holes) for grp in concentric_groups: grp.sort(key=lambda h: h[2], reverse=True) gcx, gcy, max_dia = grp[0] - slant_mm, leader_mm = sizing.get_hole_callout_lengths(max_dia) - px_edge = gcx + (max_dia / 2.0) * 0.7071 - py_edge = gcy + (max_dia / 2.0) * 0.7071 - px_sh1 = px_edge + slant_mm - py_sh1 = py_edge + slant_mm - - p_edge = to_pdf_pt(np.array([[px_edge, py_edge]])) - p_sh1 = to_pdf_pt(np.array([[px_sh1, py_sh1]])) - p_sh2 = to_pdf_pt(np.array([[px_sh1 + leader_mm, py_sh1]])) - - xe, ye = p_edge[0, 0], p_edge[0, 1] - xs1, ys1 = p_sh1[0, 0], p_sh1[0, 1] - - stream_cmds.append(f"{xe:.2f} {ye:.2f} m {xs1:.2f} {ys1:.2f} l {p_sh2[0, 0]:.2f} {ys1:.2f} l S") - stream_cmds.append( - f"{xe + arrow_len_pt * 0.7071:.2f} {ye + arrow_len_pt * 0.7071:.2f} m " - f"{xe:.2f} {ye:.2f} l " - f"{xe + arrow_len_pt * 0.3535:.2f} {ye + arrow_len_pt * 0.95:.2f} l S" + xe, ye, xs1, ys1 = _draw_hole_leader_and_arrow( + stream_cmds, gcx, gcy, max_dia, + to_pdf_pt, sizing, hole_color, sa2_lw_pt, sizing.arrow_len_pt ) for line_idx, (hcx, hcy, hdia) in enumerate(grp): - depth_str = "" - if mesh_verts_3d is not None and total_z_span > 0: - r_h = hdia / 2.0 - d_mask = np.hypot(mesh_verts_3d[:, 0] - hcx, mesh_verts_3d[:, 1] - hcy) <= (r_h + 0.3) - if np.any(d_mask): - z_sub = mesh_verts_3d[d_mask, 2] - h_z_depth = float(z_sub.max() - z_sub.min()) - if abs(h_z_depth - total_z_span) < 0.2: - depth_str = " THRU" - elif h_z_depth > 0.1: - depth_str = f" x {h_z_depth:.1f} DP" + depth_str = _get_hole_depth_str( + mesh_verts_3d, total_z_span, hcx, hcy, hdia + ) + _draw_hole_callout_text_only( + stream_cmds, hdia, depth_str, xs1, ys1, + font_size_pt, hole_color, line_idx + ) - dia_val = round(hdia, 1) - dia_str = f"Ø {dia_val:g}{depth_str}" - y_text_offset = ys1 + 2.0 - (line_idx * font_size_pt * 1.1) - - stream_cmds.append("BT") - stream_cmds.append(f"/F1 {font_size_pt * 0.85:.1f} Tf") - stream_cmds.append(f"1 0 0 1 {xs1 + 2.0:.2f} {y_text_offset:.2f} Tm") - stream_cmds.append(f"({escape_pdf_text(dia_str)}) Tj") - stream_cmds.append("ET") - - # Orthogonal Hole Center Pitch & Datum Offset Baselines (Teal Green) + # --- Pitch Dimensions --- if draw_sa2_holes and 'concentric_groups' in locals() and len(concentric_groups) >= 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") + pitch_color = (0.05, 0.58, 0.53) + stream_cmds.append(pdf_set_rgb_color(*pitch_color, fill=False)) + stream_cmds.append(pdf_set_rgb_color(*pitch_color, fill=True)) distinct_centers = [grp[0][:2] for grp in concentric_groups] distinct_centers.sort(key=lambda c: (c[0], c[1])) @@ -387,41 +652,20 @@ def render_sa2_measures_pdf( dx_mm = abs(cx2 - cx1) dy_mm = abs(cy2 - cy1) - # Horizontal Pitch Axis (if dx_mm > 0.5) if dx_mm > 0.5: y_pitch_mm = min(cy1, cy2) - sizing.offset_mm * 0.5 - p_h1 = to_pdf_pt(np.array([[cx1, y_pitch_mm]])) - p_h2 = to_pdf_pt(np.array([[cx2, y_pitch_mm]])) - p_e1 = to_pdf_pt(np.array([[cx1, cy1]])) - p_e2 = to_pdf_pt(np.array([[cx2, cy2]])) + _draw_pitch_marks( + stream_cmds, cx1, cy1, cx2, cy2, + dx_mm, y_pitch_mm, to_pdf_pt, sizing, + pitch_color, sa2_lw_pt, f"{dx_mm:.1f} C-C", + font_size_pt, is_horizontal=True + ) - stream_cmds.append(f"{p_e1[0,0]:.2f} {p_e1[0,1]:.2f} m {p_h1[0,0]:.2f} {p_h1[0,1]:.2f} l S") - stream_cmds.append(f"{p_e2[0,0]:.2f} {p_e2[0,1]:.2f} m {p_h2[0,0]:.2f} {p_h2[0,1]:.2f} l S") - stream_cmds.append(f"{p_h1[0,0]:.2f} {p_h1[0,1]:.2f} m {p_h2[0,0]:.2f} {p_h2[0,1]:.2f} l S") - - pitch_str = f"{dx_mm:.1f} C-C" - stream_cmds.append("BT") - stream_cmds.append(f"/F1 {font_size_pt * 0.8:.1f} Tf") - stream_cmds.append(f"1 0 0 1 {(p_h1[0,0]+p_h2[0,0])/2.0:.2f} {p_h1[0,1] - font_size_pt * 0.9:.2f} Tm") - stream_cmds.append(f"({escape_pdf_text(pitch_str)}) Tj") - stream_cmds.append("ET") - - # Vertical Pitch Axis (if dy_mm > 0.5) if dy_mm > 0.5: x_pitch_mm = max(cx1, cx2) + sizing.offset_mm * 0.5 - p_v1 = to_pdf_pt(np.array([[x_pitch_mm, cy1]])) - p_v2 = to_pdf_pt(np.array([[x_pitch_mm, cy2]])) - p_e1 = to_pdf_pt(np.array([[cx1, cy1]])) - p_e2 = to_pdf_pt(np.array([[cx2, cy2]])) - - stream_cmds.append(f"{p_e1[0,0]:.2f} {p_e1[0,1]:.2f} m {p_v1[0,0]:.2f} {p_v1[0,1]:.2f} l S") - stream_cmds.append(f"{p_e2[0,0]:.2f} {p_e2[0,1]:.2f} m {p_v2[0,0]:.2f} {p_v2[0,1]:.2f} l S") - stream_cmds.append(f"{p_v1[0,0]:.2f} {p_v1[0,1]:.2f} m {p_v2[0,0]:.2f} {p_v2[0,1]:.2f} l S") - - pitch_str = f"{dy_mm:.1f} C-C" - stream_cmds.append("BT") - stream_cmds.append(f"/F1 {font_size_pt * 0.8:.1f} Tf") - stream_cmds.append(f"1 0 0 1 {p_v1[0,0] + 2.0:.2f} {(p_v1[0,1]+p_v2[0,1])/2.0:.2f} Tm") - stream_cmds.append(f"({escape_pdf_text(pitch_str)}) Tj") - stream_cmds.append("ET") - + _draw_pitch_marks( + stream_cmds, cx1, cy1, cx2, cy2, + x_pitch_mm, 0, to_pdf_pt, sizing, + pitch_color, sa2_lw_pt, f"{dy_mm:.1f} C-C", + font_size_pt, is_horizontal=False + ) diff --git a/core/pdf_exporter/writer.py b/core/pdf_exporter/writer.py index a4d583a..0f6a94c 100644 --- a/core/pdf_exporter/writer.py +++ b/core/pdf_exporter/writer.py @@ -29,12 +29,7 @@ from .sizing import ( MM_TO_PT, ) - -def escape_pdf_text(text: str) -> str: - # For PDF text strings, escape only the PDF special characters: \, (, ) - # Non-ASCII characters will be encoded as cp1252 bytes in the stream - # which matches the WinAnsiEncoding of the font - return text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") +from .pdf_utils import escape_pdf_text def _format_pdf_lines(lines_arr: NDArray[np.float64]) -> list[str]: