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

445 lines
19 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,
) -> 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.
"""
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)
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]
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
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())
# 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))
return holes
def render_sa2_measures_pdf(
stream_cmds: list[str],
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,
) -> 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:
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
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")
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")
# --- SA2 Hole Features Extraction (Ø) ---
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 & 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)])
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")
# 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")