nodeslicer/core/pdf_exporter/sa2_measures.py
2026-07-31 15:15:31 +03:00

427 lines
18 KiB
Python

"""
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:
# For PDF text strings, escape only the PDF special characters: \, (, )
# Non-ASCII characters will be encoded as cp1252 bytes in the stream
# which matches the WinAnsiEncoding of the font
return text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
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" x {h_z_depth:.1f} DP"
dia_val = round(hdia, 1)
dia_str = f"Ø {dia_val:g}{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"{dx_mm:.1f} C-C"
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"{dy_mm:.1f} C-C"
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")