nodeslicer/core/pdf_exporter/sa2_measures.py
2026-07-31 14:25:38 +03:00

685 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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")