""" 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, ) from .sizing import SA2Sizing, compute_effective_stroke_and_font, MM_TO_PT def escape_pdf_text(text: str) -> str: """ Escapes special PDF characters (parentheses, backslashes) and converts WinAnsiEncoding symbols (Ø -> \330, ° -> \260, × -> \327, ± -> \261, µ -> \265) into PDF octal escape codes. """ res = [] for ch in text: if ch == "(": res.append("\\(") elif ch == ")": res.append("\\)") elif ch == "\\": res.append("\\\\") else: code = ord(ch) if code < 128: res.append(ch) elif code <= 255: res.append(f"\\{code:03o}") else: res.append("?") return "".join(res) 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, 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, ) -> 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: Source geometry is derived from the visible edge layers (wireframe, boundary, contour, crease). 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) """ 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) # Note: extract_crease_edges (lineart) already includes all boundary edges, # so we avoid redundant extract_boundary_edges when draw_crease is active. 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) if len(b_edges) == 0: return [], [], [], [] 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 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, arcs, chamfers, step_features 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_holes: bool = False, draw_sa2_arcs: bool = False, draw_sa2_chamfers: bool = False, draw_sa2_step_features: bool = False, 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, ) -> None: """ Renders ISO 129-1 compliant SA2 automated technical measurements on PDF stream. """ if raw_geometry is None: return # Check if any SA2 feature is enabled if not (draw_sa2_holes or draw_sa2_arcs or draw_sa2_chamfers or draw_sa2_step_features): 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 ) 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") 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 # --- 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") 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") 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") # --- Additional SA2 Feature Measures: Bores (Ø), Arcs (R), Chamfers (C) & Pitch --- holes, arcs, chamfers, step_features = 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), debug_sa2=debug_sa2, filepath=filepath, ) # 1. Concentric Bores Grouping & ISO 129-1 Callouts with Z-depth Specs (Electric Blue) 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") # 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)]) # Query 3D Z-depth information for hole callouts 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()) 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" ) 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" \u2334{h_z_depth:.1f}mm" dia_val = round(hdia, 1) dia_str = f"Ø{dia_val:g}mm{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") # 2. ISO 129-1 Arc Cutouts & Corner Fillets (R - Amber Orange) if draw_sa2_arcs and 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]: arc_slant, arc_leader = sizing.get_arc_callout_lengths(r_mm) 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 + arc_slant, mid_y_mm + arc_slant]])) p_sh2 = to_pdf_pt(np.array([[mid_x_mm + arc_slant + arc_leader, mid_y_mm + arc_slant]])) 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] stream_cmds.append(f"{xm:.2f} {ym:.2f} m {xs1:.2f} {ys1:.2f} l {p_sh2[0, 0]:.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"({escape_pdf_text(r_str)}) Tj") stream_cmds.append("ET") # 3. ISO 129-1 Bevels, Chamfers & Rib Angles (C / θ - Violet Purple) if draw_sa2_chamfers and 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 dx_ch = abs(ch_x2 - ch_x1) dy_ch = abs(ch_y2 - ch_y1) angle_deg = math.degrees(math.atan2(dy_ch, max(dx_ch, 1e-4))) ch_slant, ch_leader = sizing.get_chamfer_callout_lengths(c_val) sh_x = ch_mid_x + ch_slant sh_y = ch_mid_y + ch_slant 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 + ch_leader, 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] stream_cmds.append(f"{x_cm:.2f} {y_cm:.2f} m {x_cs1:.2f} {y_cs1:.2f} l {p_ch_sh2[0, 0]:.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_str = f"C{c_val:.1f}mm ({angle_deg:.0f}°)" 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"({escape_pdf_text(c_str)}) Tj") stream_cmds.append("ET") # 4. Orthogonal Hole Center Pitch & Datum Offset Baselines (Teal Green) 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") 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) # 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]])) 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"PITCH X: {dx_mm:.1f}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 {(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"PITCH Y: {dy_mm:.1f}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 {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") # 5. Flange Outer Geometry & Z-Depth Thickness Callouts if mesh_verts_3d is not None and total_z_span > 0: flange_mask = mesh_verts_3d[:, 0] > (min_x + 0.55 * w_obj_mm) if np.any(flange_mask): f_verts = mesh_verts_3d[flange_mask] f_y_min, f_y_max = float(f_verts[:, 1].min()), float(f_verts[:, 1].max()) f_z_min, f_z_max = float(f_verts[:, 2].min()), float(f_verts[:, 2].max()) flange_dia_mm = f_y_max - f_y_min flange_depth_mm = f_z_max - f_z_min if flange_dia_mm > 2.0: p_flange_right = to_pdf_pt(np.array([[max_x + sizing.offset_mm * 0.5, (f_y_min + f_y_max) / 2.0]])) xf_label = p_flange_right[0, 0] yf_label = p_flange_right[0, 1] flange_str = f"FLANGE Ø{flange_dia_mm:.1f}mm \u2334{flange_depth_mm:.1f}mm" stream_cmds.append("0.100 0.550 0.200 RG") stream_cmds.append("0.100 0.550 0.200 rg") stream_cmds.append("BT") stream_cmds.append(f"/F1 {font_size_pt * 0.85:.1f} Tf") stream_cmds.append(f"1 0 0 1 {xf_label:.2f} {yf_label:.2f} Tm") stream_cmds.append(f"({escape_pdf_text(flange_str)}) Tj") stream_cmds.append("ET")