""" 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, extract_outer_contour_edges, extract_crease_edges, get_plane_2d_indices, ) from .sizing import SA2Sizing, compute_effective_stroke_and_font, MM_TO_PT from .pdf_utils import PdfStream, escape_pdf_text # ============================================================================= # Geometry Utilities # ============================================================================= def circumcircle_3pts(p1: NDArray[Any], p2: NDArray[Any], p3: NDArray[Any]) -> 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 # ============================================================================= # 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 or SlicePaths in mesh_data. """ from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject from ..slice import SlicePaths 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, SlicePaths): all_pts = [p[:, :2] for p in item if p is not None and len(p) > 0] if all_pts: verts_2d = np.concatenate(all_pts, axis=0) 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()) name = getattr(item, "name", "") or fallback_name or "SlicePaths" results.append((item, name, min_x, max_x, min_y, max_y)) 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 _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, draw_boundary: bool = False, draw_contour: bool = False, draw_crease: bool = False, crease_angle: float = 30.0, bounds: tuple[float, float, float, float] | None = None, plane: str = "XY", ) -> list[tuple[float, float, float]]: """ Extracts circular bore / hole features (center_x, center_y, diameter_mm) from a SlicingObject per ISO 129-1 standards based on visible edge layers. """ 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 [] from shapely.geometry import LineString from shapely.ops import polygonize lines = [LineString([(float(p0[0]), float(p0[1])), (float(p1[0]), float(p1[1]))]) for p0, p1 in b_edges] polygons = list(polygonize(lines)) loops: list[NDArray[np.float64]] = [np.array(poly.exterior.coords, dtype=np.float64) for poly in polygons] 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 = 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 > 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 _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_text( s: PdfStream, 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 s.fill_color(*color) s.begin_text().font("F1", font_size_pt).text_position(text_x, text_y).show(text).end_text() def _draw_horizontal_dimension( s: PdfStream, 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]]))) s.stroke_color(*color).line_width(line_width_pt) s.line_stroke(p_l1[0], p_l1[1], p_l2[0], p_l2[1]) s.line_stroke(p_r1[0], p_r1[1], p_r2[0], p_r2[1]) # 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]]))) s.line_stroke(p_d1[0], p_d1[1], p_d2[0], p_d2[1]) # Arrowheads s.polyline_stroke( (p_d1[0] + arrow_len_pt, p_d1[1] + arrow_half_w_pt), (p_d1[0], p_d1[1]), (p_d1[0] + arrow_len_pt, p_d1[1] - arrow_half_w_pt), ) s.polyline_stroke( (p_d2[0] - arrow_len_pt, p_d2[1] + arrow_half_w_pt), (p_d2[0], p_d2[1]), (p_d2[0] - arrow_len_pt, p_d2[1] - arrow_half_w_pt), ) # Text label _draw_text( s, 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( s: PdfStream, 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]]))) s.stroke_color(*color).line_width(line_width_pt) s.line_stroke(p_b1[0], p_b1[1], p_b2[0], p_b2[1]) s.line_stroke(p_t1[0], p_t1[1], p_t2[0], p_t2[1]) # 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]]))) s.line_stroke(p_d1[0], p_d1[1], p_d2[0], p_d2[1]) # Arrowheads s.polyline_stroke( (p_d1[0] - arrow_half_w_pt, p_d1[1] + arrow_len_pt), (p_d1[0], p_d1[1]), (p_d1[0] + arrow_half_w_pt, p_d1[1] + arrow_len_pt), ) s.polyline_stroke( (p_d2[0] - arrow_half_w_pt, p_d2[1] - arrow_len_pt), (p_d2[0], p_d2[1]), (p_d2[0] + arrow_half_w_pt, p_d2[1] - arrow_len_pt), ) # Text label _draw_text( s, 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( s: PdfStream, 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 s.stroke_color(*color).line_width(line_width_pt) s.polyline_stroke((xe, ye), (xs1, ys1), (p_sh2[0], ys1)) # Arrowhead at edge (45 degree orientation) s.polyline_stroke( (xe + arrow_len_pt * 0.7071, ye + arrow_len_pt * 0.7071), (xe, ye), (xe + arrow_len_pt * 0.3535, ye + arrow_len_pt * 0.95), ) return xe, ye, xs1, ys1 def _draw_hole_callout_text_only( s: PdfStream, 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"\u00d8 {dia_val:g}{depth_str}" y_offset = ys1 + 2.0 - (line_idx * font_size_pt * 1.1) s.begin_text().font("F1", font_size_pt * 0.85) s.text_position(xs1 + 2.0, y_offset).show(dia_str) s.end_text() # ============================================================================= # Pitch Drawing # ============================================================================= def _draw_pitch_marks( s: PdfStream, 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]]))) s.line_stroke(p_e1[0], p_e1[1], p_h1[0], p_h1[1]) s.line_stroke(p_e2[0], p_e2[1], p_h2[0], p_h2[1]) s.line_stroke(p_h1[0], p_h1[1], p_h2[0], p_h2[1]) 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]]))) s.line_stroke(p_e1[0], p_e1[1], p_v1[0], p_v1[1]) s.line_stroke(p_e2[0], p_e2[1], p_v2[0], p_v2[1]) s.line_stroke(p_v1[0], p_v1[1], p_v2[0], p_v2[1]) label_x = p_v1[0] + 2.0 label_y = (p_v1[1] + p_v2[1]) / 2.0 # Draw label s.begin_text().font("F1", font_size_pt * 0.8) s.text_position(label_x, label_y).show(label) s.end_text() # ============================================================================= # Main Render Function # ============================================================================= def render_sa2_measures_pdf( s: PdfStream, raw_geometry: Any, draw_sa2_holes: bool = True, minor_step_mm: float = 1.0, main_step: float = 10.0, to_pdf_pt: Callable[[NDArray[np.float64]], NDArray[np.float64]] | None = None, font_size_pt: float | None = None, line_width_pt: float | None = None, draw_wireframe: bool = True, draw_boundary: bool = False, draw_contour: bool = False, draw_crease: bool = False, crease_angle: float = 30.0, plane: str = "XY", ) -> None: """ Renders ISO 129-1 compliant SA2 hole measurements (bores, concentric steps, Z-depth, pitch) on PDF stream. """ if raw_geometry is None or not draw_sa2_holes or to_pdf_pt is None: return sa2_objects = extract_slicing_objects_bounds(raw_geometry) if not sa2_objects: return # Automatic sizing based on minor grid line_width_pt, font_size_pt = compute_effective_stroke_and_font( line_width_pt / MM_TO_PT if line_width_pt else 0.0, font_size_pt / MM_TO_PT if font_size_pt else 0.0, minor_step_mm ) # Setup: graphics state, line style, and SA2 color sa2_lw_pt = line_width_pt * 0.8 sa2_color = (0.85, 0.15, 0.15) s.opacity("GS100").line_width(sa2_lw_pt).round_caps() s.stroke_color(*sa2_color) 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) # 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 --- w_str = f"{w_obj_mm/1000:g}m" if main_step >= 1000.0 else f"{w_obj_mm:g}mm" _draw_horizontal_dimension( s, 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" _draw_vertical_dimension( s, 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 ) # --- Hole Features --- holes = extract_slicing_object_features( so_item, draw_wireframe=draw_wireframe, draw_boundary=draw_boundary, draw_contour=draw_contour, draw_crease=draw_crease, crease_angle=crease_angle, bounds=(min_x, max_x, min_y, max_y), ) # Concentric Bores Grouping & Hole Callouts concentric_groups: list[list[tuple[float, float, float]]] = [] if draw_sa2_holes and holes: hole_color = (0.10, 0.45, 0.90) s.stroke_color(*hole_color).fill_color(*hole_color) 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] xe, ye, xs1, ys1 = _draw_hole_leader_and_arrow( s, 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 = _get_hole_depth_str( mesh_verts_3d, total_z_span, hcx, hcy, hdia ) _draw_hole_callout_text_only( s, hdia, depth_str, xs1, ys1, font_size_pt, hole_color, line_idx ) # --- Pitch Dimensions --- if draw_sa2_holes and len(concentric_groups) >= 2: pitch_color = (0.05, 0.58, 0.53) s.stroke_color(*pitch_color).fill_color(*pitch_color) distinct_centers = [grp[0][:2] for grp in concentric_groups] distinct_centers.sort(key=lambda c: (c[0], c[1])) for i in range(len(distinct_centers) - 1): (cx1, cy1), (cx2, cy2) = distinct_centers[i], distinct_centers[i + 1] dx_mm = abs(cx2 - cx1) dy_mm = abs(cy2 - cy1) if dx_mm > 0.5: y_pitch_mm = min(cy1, cy2) - sizing.offset_mm * 0.5 _draw_pitch_marks( s, 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 ) if dy_mm > 0.5: x_pitch_mm = max(cx1, cx2) + sizing.offset_mm * 0.5 _draw_pitch_marks( s, 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 )