nodeslicer/core/pdf_exporter/sa2_measures.py
2026-07-30 12:47:36 +03:00

543 lines
23 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
MM_TO_PT = 2.834645669291339
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) -> tuple[
list[tuple[float, float, float]], # Holes (cx, cy, dia)
list[tuple[float, float, float, float, float]], # Arcs (cx, cy, r, mid_x, mid_y)
list[tuple[float, float, float, float, float]], # Chamfers (x1, y1, x2, y2, c_val)
list[tuple[float, float, float, float, float]] # Steps (x1, y1, x2, y2, length)
]:
"""
Extracts geometric features from a SlicingObject per ISO 129-1 standards:
1. Circular bores / holes: list of (center_x, center_y, diameter_mm)
2. Arc cutouts & fillets: list of (center_x, center_y, radius_mm, mid_x, mid_y)
3. Bevels / Chamfers: list of (x1, y1, x2, y2, c_val_mm)
4. Internal step / notch segments: list of (x1, y1, x2, y2, length_mm)
"""
b_edges = extract_boundary_edges(so_item)
if len(b_edges) == 0:
b_edges = extract_lineart_edges(so_item)
if len(b_edges) == 0:
b_edges = extract_unique_top_view_edges(so_item)
if len(b_edges) == 0:
return [], [], [], []
b_edges = deduplicate_2d_edges(b_edges)
b_edges = deduplicate_2d_edges(b_edges)
adj: dict[tuple[float, float], list[tuple[float, float]]] = {}
for p0, p1 in b_edges:
pt0 = (round(float(p0[0]), 2), round(float(p0[1]), 2))
pt1 = (round(float(p1[0]), 2), round(float(p1[1]), 2))
if pt0 == pt1:
continue
adj.setdefault(pt0, []).append(pt1)
adj.setdefault(pt1, []).append(pt0)
for pt, nbrs in adj.items():
nbrs.sort(key=lambda n: (n[0] - pt[0]) ** 2 + (n[1] - pt[1]) ** 2)
visited_edges: set[tuple[tuple[float, float], tuple[float, float]]] = set()
loops: list[NDArray[np.float64]] = []
for start_pt in list(adj.keys()):
for neighbor in adj[start_pt]:
edge_key = tuple(sorted([start_pt, neighbor]))
if edge_key in visited_edges:
continue
temp_visited = set()
loop_pts = [start_pt]
temp_visited.add(edge_key)
curr = neighbor
while curr != start_pt:
loop_pts.append(curr)
next_pt = None
for nxt in adj.get(curr, []):
ek = tuple(sorted([curr, nxt]))
if ek not in visited_edges and ek not in temp_visited:
temp_visited.add(ek)
next_pt = nxt
break
if next_pt is None:
break
curr = next_pt
if curr == start_pt:
visited_edges.update(temp_visited)
if len(loop_pts) >= 3:
loops.append(np.array(loop_pts, dtype=np.float64))
holes: list[tuple[float, float, float]] = []
arcs: list[tuple[float, float, float, float, float]] = []
chamfers: list[tuple[float, float, float, float, float]] = []
step_features: list[tuple[float, float, float, float, float]] = []
if so_item.mesh.size == 0:
return holes, arcs, chamfers, step_features
verts_2d = so_item.mesh[:, :3, :2].reshape(-1, 2)
min_x, max_x = float(verts_2d[:, 0].min()), float(verts_2d[:, 0].max())
min_y, max_y = float(verts_2d[:, 1].min()), float(verts_2d[:, 1].max())
overall_w = max_x - min_x
overall_h = max_y - min_y
for loop in loops:
N = len(loop)
if N < 3:
continue
cx_full = float(loop[:, 0].mean())
cy_full = float(loop[:, 1].mean())
dists_full = np.sqrt((loop[:, 0] - cx_full) ** 2 + (loop[:, 1] - cy_full) ** 2)
r_mean_full = float(dists_full.mean())
r_std_full = float(dists_full.std())
# 1. ISO Full Circular Bore (Ø)
if N >= 5 and r_mean_full > 0.5 and (r_std_full / r_mean_full) < 0.20:
if r_mean_full < max(overall_w, overall_h) * 0.48:
holes.append((cx_full, cy_full, r_mean_full * 2.0))
continue
# 2. Trace ISO Arc Cutouts & Fillets (R)
idx_i = 0
visited_indices: set[int] = set()
while idx_i < N:
if idx_i in visited_indices:
idx_i += 1
continue
p1 = loop[idx_i]
p2 = loop[(idx_i + 1) % N]
p3 = loop[(idx_i + 2) % N]
center, r_arc = circumcircle_3pts(p1, p2, p3)
if center is not None and 1.0 <= r_arc <= max(overall_w, overall_h) * 0.45:
arc_seq = [idx_i, (idx_i + 1) % N, (idx_i + 2) % N]
for j in range(idx_i + 3, idx_i + N):
idx_nxt = j % N
p_nxt = loop[idx_nxt]
dist_to_c = np.sqrt((p_nxt[0] - center[0]) ** 2 + (p_nxt[1] - center[1]) ** 2)
if abs(dist_to_c - r_arc) / r_arc < 0.12:
arc_seq.append(idx_nxt)
else:
break
if len(arc_seq) >= 4:
mid_idx = arc_seq[len(arc_seq) // 2]
mid_pt = loop[mid_idx]
arcs.append((float(center[0]), float(center[1]), float(r_arc), float(mid_pt[0]), float(mid_pt[1])))
visited_indices.update(arc_seq)
idx_i += len(arc_seq) - 1
continue
idx_i += 1
# 3. Trace ISO Chamfers / Bevels (C)
for k in range(N):
if k in visited_indices:
continue
p_prev = loop[(k - 1) % N]
p_curr = loop[k]
p_next = loop[(k + 1) % N]
p_next2 = loop[(k + 2) % N]
v1 = p_curr - p_prev
v2 = p_next - p_curr
v3 = p_next2 - p_next
l1, l2, l3 = float(np.linalg.norm(v1)), float(np.linalg.norm(v2)), float(np.linalg.norm(v3))
if l1 > 0 and l2 > 0 and l3 > 0:
cos12 = float(np.dot(v1, v2) / (l1 * l2))
cos23 = float(np.dot(v2, v3) / (l2 * l3))
if 0.45 <= abs(cos12) <= 0.88 and 0.45 <= abs(cos23) <= 0.88:
if 1.0 <= l2 <= min(overall_w, overall_h) * 0.4:
c_val = float(l2 * math.sin(math.pi / 4.0))
chamfers.append((float(p_curr[0]), float(p_curr[1]), float(p_next[0]), float(p_next[1]), c_val))
# 4. Interior Step Segments
for i in range(N):
p1 = loop[i]
p2 = loop[(i + 1) % N]
seg_len = float(np.sqrt((p2[0] - p1[0])**2 + (p2[1] - p1[1])**2))
is_horiz = abs(p2[1] - p1[1]) < 1e-2 and seg_len > 4.0 and abs(seg_len - overall_w) > 1e-2
is_vert = abs(p2[0] - p1[0]) < 1e-2 and seg_len > 4.0 and abs(seg_len - overall_h) > 1e-2
if is_horiz or is_vert:
on_outer_edge = (
abs(p1[0] - min_x) < 1e-2 and abs(p2[0] - min_x) < 1e-2 or
abs(p1[0] - max_x) < 1e-2 and abs(p2[0] - max_x) < 1e-2 or
abs(p1[1] - min_y) < 1e-2 and abs(p2[1] - min_y) < 1e-2 or
abs(p1[1] - max_y) < 1e-2 and abs(p2[1] - max_y) < 1e-2
)
if not on_outer_edge:
step_features.append((float(p1[0]), float(p1[1]), float(p2[0]), float(p2[1]), seg_len))
return holes, arcs, chamfers, step_features
def render_sa2_measures_pdf(
stream_cmds: list[str],
raw_geometry: Any,
draw_sa2_measures: bool,
line_width_pt: float,
main_step: float,
to_pdf_pt: Callable[[NDArray[np.float64]], NDArray[np.float64]],
font_size_pt: float | None = None
) -> None:
"""
Renders ISO 129-1 compliant SA2 automated technical measurements on PDF stream.
"""
if not draw_sa2_measures or raw_geometry is None:
return
sa2_objects = extract_slicing_objects_bounds(raw_geometry)
if not sa2_objects:
return
stream_cmds.append("/GS100 gs")
sa2_lw_pt = max(0.4 * MM_TO_PT, line_width_pt * 0.8)
stream_cmds.append(f"{sa2_lw_pt:.2f} w")
stream_cmds.append("1 j 1 J")
# Vivid red / crimson color for main linear SA2 measures
red_r, red_g, red_b = 0.85, 0.15, 0.15
stream_cmds.append(f"{red_r:.3f} {red_g:.3f} {red_b:.3f} RG")
if font_size_pt is None or font_size_pt <= 0:
font_size_pt = max(8.0, line_width_pt * 4.0)
arrow_len_pt = max(3.5 * MM_TO_PT, line_width_pt * 4.0)
arrow_half_w_pt = arrow_len_pt * 0.40
for so_item, obj_name, min_x, max_x, min_y, max_y in sa2_objects:
w_obj_mm = max_x - min_x
h_obj_mm = max_y - min_y
offset_mm = 8.0
ext_overhang_mm = 2.0
# --- Width Dimension (Horizontal below SlicingObject) ---
y_dim_mm = min_y - offset_mm
p_ext_l1 = to_pdf_pt(np.array([[min_x, min_y - 1.0]]))
p_ext_l2 = to_pdf_pt(np.array([[min_x, y_dim_mm - ext_overhang_mm]]))
p_ext_r1 = to_pdf_pt(np.array([[max_x, min_y - 1.0]]))
p_ext_r2 = to_pdf_pt(np.array([[max_x, y_dim_mm - ext_overhang_mm]]))
stream_cmds.append(f"{p_ext_l1[0,0]:.2f} {p_ext_l1[0,1]:.2f} m {p_ext_l2[0,0]:.2f} {p_ext_l2[0,1]:.2f} l S")
stream_cmds.append(f"{p_ext_r1[0,0]:.2f} {p_ext_r1[0,1]:.2f} m {p_ext_r2[0,0]:.2f} {p_ext_r2[0,1]:.2f} l S")
p_dim_w1 = to_pdf_pt(np.array([[min_x, y_dim_mm]]))
p_dim_w2 = to_pdf_pt(np.array([[max_x, y_dim_mm]]))
x_w1, y_w = p_dim_w1[0, 0], p_dim_w1[0, 1]
x_w2 = p_dim_w2[0, 0]
stream_cmds.append(f"{x_w1:.2f} {y_w:.2f} m {x_w2:.2f} {y_w:.2f} l S")
stream_cmds.append(
f"{x_w1 + arrow_len_pt:.2f} {y_w + arrow_half_w_pt:.2f} m "
f"{x_w1:.2f} {y_w:.2f} l "
f"{x_w1 + arrow_len_pt:.2f} {y_w - arrow_half_w_pt:.2f} l S"
)
stream_cmds.append(
f"{x_w2 - arrow_len_pt:.2f} {y_w + arrow_half_w_pt:.2f} m "
f"{x_w2:.2f} {y_w:.2f} l "
f"{x_w2 - arrow_len_pt:.2f} {y_w - arrow_half_w_pt:.2f} l S"
)
# --- Height Dimension (Vertical to the left of SlicingObject) ---
x_dim_mm = min_x - offset_mm
p_ext_b1 = to_pdf_pt(np.array([[min_x - 1.0, min_y]]))
p_ext_b2 = to_pdf_pt(np.array([[x_dim_mm - ext_overhang_mm, min_y]]))
p_ext_t1 = to_pdf_pt(np.array([[min_x - 1.0, max_y]]))
p_ext_t2 = to_pdf_pt(np.array([[x_dim_mm - ext_overhang_mm, max_y]]))
stream_cmds.append(f"{p_ext_b1[0,0]:.2f} {p_ext_b1[0,1]:.2f} m {p_ext_b2[0,0]:.2f} {p_ext_b2[0,1]:.2f} l S")
stream_cmds.append(f"{p_ext_t1[0,0]:.2f} {p_ext_t1[0,1]:.2f} m {p_ext_t2[0,0]:.2f} {p_ext_t2[0,1]:.2f} l S")
p_dim_h1 = to_pdf_pt(np.array([[x_dim_mm, min_y]]))
p_dim_h2 = to_pdf_pt(np.array([[x_dim_mm, max_y]]))
x_h, y_h1 = p_dim_h1[0, 0], p_dim_h1[0, 1]
y_h2 = p_dim_h2[0, 1]
stream_cmds.append(f"{x_h:.2f} {y_h1:.2f} m {x_h:.2f} {y_h2:.2f} l S")
stream_cmds.append(
f"{x_h - arrow_half_w_pt:.2f} {y_h1 + arrow_len_pt:.2f} m "
f"{x_h:.2f} {y_h1:.2f} l "
f"{x_h + arrow_half_w_pt:.2f} {y_h1 + arrow_len_pt:.2f} l S"
)
stream_cmds.append(
f"{x_h - arrow_half_w_pt:.2f} {y_h2 - arrow_len_pt:.2f} m "
f"{x_h:.2f} {y_h2:.2f} l "
f"{x_h + arrow_half_w_pt:.2f} {y_h2 - arrow_len_pt:.2f} l S"
)
# --- Text Labels for Width and Height ---
stream_cmds.append(f"{red_r:.3f} {red_g:.3f} {red_b:.3f} rg")
stream_cmds.append("BT")
stream_cmds.append(f"/F1 {font_size_pt:.1f} Tf")
w_str = f"{w_obj_mm/1000:g}m" if main_step >= 1000.0 else f"{w_obj_mm:g}mm"
x_w_text = (x_w1 + x_w2) / 2.0 - (len(w_str) * font_size_pt * 0.28)
y_w_text = y_w - font_size_pt * 1.1
stream_cmds.append(f"1 0 0 1 {x_w_text:.2f} {y_w_text:.2f} Tm")
stream_cmds.append(f"({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)
# 1. ISO 129-1 Circular Bores / Hole Diameter Callouts (Ø - Electric Blue)
if holes:
blue_r, blue_g, blue_b = 0.10, 0.45, 0.90
stream_cmds.append(f"{blue_r:.3f} {blue_g:.3f} {blue_b:.3f} RG")
stream_cmds.append(f"{blue_r:.3f} {blue_g:.3f} {blue_b:.3f} rg")
for cx_mm, cy_mm, dia_mm in holes:
r_mm = dia_mm / 2.0
px_edge = cx_mm + r_mm * 0.7071
py_edge = cy_mm + r_mm * 0.7071
px_shoulder = px_edge + 8.0
py_shoulder = py_edge + 8.0
p_edge = to_pdf_pt(np.array([[px_edge, py_edge]]))
p_sh = to_pdf_pt(np.array([[px_shoulder, py_shoulder]]))
p_sh2 = to_pdf_pt(np.array([[px_shoulder + 10.0, py_shoulder]]))
x_e, y_e = p_edge[0, 0], p_edge[0, 1]
x_s1, y_s1 = p_sh[0, 0], p_sh[0, 1]
x_s2 = p_sh2[0, 0]
stream_cmds.append(f"{x_e:.2f} {y_e:.2f} m {x_s1:.2f} {y_s1:.2f} l {x_s2:.2f} {y_s1:.2f} l S")
stream_cmds.append(
f"{x_e + arrow_len_pt * 0.7071:.2f} {y_e + arrow_len_pt * 0.7071:.2f} m "
f"{x_e:.2f} {y_e:.2f} l "
f"{x_e + arrow_len_pt * 0.3535:.2f} {y_e + arrow_len_pt * 0.95:.2f} l S"
)
dia_val = round(dia_mm, 1)
dia_str = f"Ø{dia_val:g}mm" if main_step < 1000.0 else f"Ø{dia_val/1000:g}m"
stream_cmds.append("BT")
stream_cmds.append(f"/F1 {font_size_pt * 0.9:.1f} Tf")
stream_cmds.append(f"1 0 0 1 {x_s1 + 2.0:.2f} {y_s1 + 2.0:.2f} Tm")
stream_cmds.append(f"({escape_pdf_text(dia_str)}) Tj")
stream_cmds.append("ET")
# 2. ISO 129-1 Arc Cutouts & Corner Fillets (R - Amber Orange)
if arcs:
amber_r, amber_g, amber_b = 0.90, 0.45, 0.10
stream_cmds.append(f"{amber_r:.3f} {amber_g:.3f} {amber_b:.3f} RG")
stream_cmds.append(f"{amber_r:.3f} {amber_g:.3f} {amber_b:.3f} rg")
for acx_mm, acy_mm, r_mm, mid_x_mm, mid_y_mm in arcs[:4]:
p_center = to_pdf_pt(np.array([[acx_mm, acy_mm]]))
p_mid = to_pdf_pt(np.array([[mid_x_mm, mid_y_mm]]))
p_sh1 = to_pdf_pt(np.array([[mid_x_mm + 6.0, mid_y_mm + 6.0]]))
p_sh2 = to_pdf_pt(np.array([[mid_x_mm + 14.0, mid_y_mm + 6.0]]))
xc, yc = p_center[0, 0], p_center[0, 1]
xm, ym = p_mid[0, 0], p_mid[0, 1]
xs1, ys1 = p_sh1[0, 0], p_sh1[0, 1]
xs2 = p_sh2[0, 0]
stream_cmds.append(f"{xm:.2f} {ym:.2f} m {xs1:.2f} {ys1:.2f} l {xs2:.2f} {ys1:.2f} l S")
ux, uy = (xm - xc), (ym - yc)
dist_cm = math.sqrt(ux*ux + uy*uy)
if dist_cm > 1e-3:
ux, uy = ux / dist_cm, uy / dist_cm
stream_cmds.append(
f"{xm - arrow_len_pt * ux + arrow_half_w_pt * uy:.2f} {ym - arrow_len_pt * uy - arrow_half_w_pt * ux:.2f} m "
f"{xm:.2f} {ym:.2f} l "
f"{xm - arrow_len_pt * ux - arrow_half_w_pt * uy:.2f} {ym - arrow_len_pt * uy + arrow_half_w_pt * ux:.2f} l S"
)
r_val = round(r_mm, 1)
r_str = f"R{r_val:g}mm"
stream_cmds.append("BT")
stream_cmds.append(f"/F1 {font_size_pt * 0.9:.1f} Tf")
stream_cmds.append(f"1 0 0 1 {xs1 + 2.0:.2f} {ys1 + 2.0:.2f} Tm")
stream_cmds.append(f"({escape_pdf_text(r_str)}) Tj")
stream_cmds.append("ET")
# 3. ISO 129-1 Bevels & Chamfers (C / 45° - Violet Purple)
if chamfers:
purple_r, purple_g, purple_b = 0.60, 0.20, 0.85
stream_cmds.append(f"{purple_r:.3f} {purple_g:.3f} {purple_b:.3f} RG")
stream_cmds.append(f"{purple_r:.3f} {purple_g:.3f} {purple_b:.3f} rg")
for ch_x1, ch_y1, ch_x2, ch_y2, c_val in chamfers[:4]:
ch_mid_x = (ch_x1 + ch_x2) / 2.0
ch_mid_y = (ch_y1 + ch_y2) / 2.0
sh_x = ch_mid_x + 6.0
sh_y = ch_mid_y + 6.0
p_ch_mid = to_pdf_pt(np.array([[ch_mid_x, ch_mid_y]]))
p_ch_sh1 = to_pdf_pt(np.array([[sh_x, sh_y]]))
p_ch_sh2 = to_pdf_pt(np.array([[sh_x + 10.0, sh_y]]))
x_cm, y_cm = p_ch_mid[0, 0], p_ch_mid[0, 1]
x_cs1, y_cs1 = p_ch_sh1[0, 0], p_ch_sh1[0, 1]
x_cs2 = p_ch_sh2[0, 0]
stream_cmds.append(f"{x_cm:.2f} {y_cm:.2f} m {x_cs1:.2f} {y_cs1:.2f} l {x_cs2:.2f} {y_cs1:.2f} l S")
stream_cmds.append(
f"{x_cm + arrow_len_pt * 0.7071:.2f} {y_cm + arrow_len_pt * 0.7071:.2f} m "
f"{x_cm:.2f} {y_cm:.2f} l "
f"{x_cm + arrow_len_pt * 0.3535:.2f} {y_cm + arrow_len_pt * 0.95:.2f} l S"
)
c_val_rounded = round(c_val, 1)
c_str = f"C{c_val_rounded:g}mm"
stream_cmds.append("BT")
stream_cmds.append(f"/F1 {font_size_pt * 0.85:.1f} Tf")
stream_cmds.append(f"1 0 0 1 {x_cs1 + 2.0:.2f} {y_cs1 + 2.0:.2f} Tm")
stream_cmds.append(f"({escape_pdf_text(c_str)}) Tj")
stream_cmds.append("ET")
# 4. Hole Center Pitch Distances (Teal Green)
if len(holes) >= 2:
teal_r, teal_g, teal_b = 0.05, 0.58, 0.53
stream_cmds.append(f"{teal_r:.3f} {teal_g:.3f} {teal_b:.3f} RG")
stream_cmds.append(f"{teal_r:.3f} {teal_g:.3f} {teal_b:.3f} rg")
sorted_holes = sorted(holes, key=lambda h: (h[0], h[1]))
for i in range(len(sorted_holes) - 1):
h1, h2 = sorted_holes[i], sorted_holes[i + 1]
dist_mm = math.sqrt((h2[0] - h1[0])**2 + (h2[1] - h1[1])**2)
if dist_mm > 1.0:
p_h1 = to_pdf_pt(np.array([[h1[0], h1[1]]]))
p_h2 = to_pdf_pt(np.array([[h2[0], h2[1]]]))
x1, y1 = p_h1[0, 0], p_h1[0, 1]
x2, y2 = p_h2[0, 0], p_h2[0, 1]
stream_cmds.append(f"{x1:.2f} {y1:.2f} m {x2:.2f} {y2:.2f} l S")
pitch_str = f"PITCH: {dist_mm:g}mm"
stream_cmds.append("BT")
stream_cmds.append(f"/F1 {font_size_pt * 0.85:.1f} Tf")
stream_cmds.append(f"1 0 0 1 {(x1+x2)/2.0:.2f} {(y1+y2)/2.0 + 3.0:.2f} Tm")
stream_cmds.append(f"({escape_pdf_text(pitch_str)}) Tj")
stream_cmds.append("ET")
# 5. Interior Step / Notch Feature Segment Lengths (Deep Purple)
if step_features:
purple_r, purple_g, purple_b = 0.48, 0.23, 0.92
stream_cmds.append(f"{purple_r:.3f} {purple_g:.3f} {purple_b:.3f} RG")
stream_cmds.append(f"{purple_r:.3f} {purple_g:.3f} {purple_b:.3f} rg")
for x1_m, y1_m, x2_m, y2_m, step_len in step_features[:4]:
p_s1 = to_pdf_pt(np.array([[x1_m, y1_m]]))
p_s2 = to_pdf_pt(np.array([[x2_m, y2_m]]))
xs1, ys1 = p_s1[0, 0], p_s1[0, 1]
xs2, ys2 = p_s2[0, 0], p_s2[0, 1]
stream_cmds.append(f"{xs1:.2f} {ys1:.2f} m {xs2:.2f} {ys2:.2f} l S")
step_str = f"{step_len:g}mm"
stream_cmds.append("BT")
stream_cmds.append(f"/F1 {font_size_pt * 0.8:.1f} Tf")
stream_cmds.append(f"1 0 0 1 {(xs1+xs2)/2.0:.2f} {(ys1+ys2)/2.0 + 2.0:.2f} Tm")
stream_cmds.append(f"({escape_pdf_text(step_str)}) Tj")
stream_cmds.append("ET")