diff --git a/core/pdf_exporter/depth_rasterizer.py b/core/pdf_exporter/depth_rasterizer.py new file mode 100644 index 0000000..75b324c --- /dev/null +++ b/core/pdf_exporter/depth_rasterizer.py @@ -0,0 +1,37 @@ +""" +SA2 Performance Profiling & Vector Edge Debug Export Module. +Saves input vector edge debug data and cProfile statistics when debug_sa2 is enabled. +""" +from typing import Any +import numpy as np +from numpy.typing import NDArray + + +def save_sa2_debug_data( + edges: NDArray[np.float64] | None, + obj_name: str, + filepath: str | Any +) -> None: + """ + Saves debug edge vector data files in the same folder as the output PDF: + - {name}_sa2_debug_edges.txt (Input 2D edge vector coordinates fed to SA2) + """ + if filepath is None: + return + from pathlib import Path + pdf_path = Path(filepath) + out_dir = pdf_path.parent + out_dir.mkdir(parents=True, exist_ok=True) + + clean_name = pdf_path.stem if pdf_path.suffix.lower() == ".pdf" else str(obj_name) + base_prefix = out_dir / f"{clean_name}_sa2_debug" + + # Save input edge coordinates as text file + if edges is not None and len(edges) > 0: + txt_file = f"{base_prefix}_edges.txt" + with open(txt_file, "w") as f: + f.write(f"# SA2 Input 2D Edges Debug for {clean_name}\n") + f.write(f"# Format: x1 y1 x2 y2\n") + for segment in edges: + (x1, y1), (x2, y2) = segment[0], segment[1] + f.write(f"{x1:.4f} {y1:.4f} {x2:.4f} {y2:.4f}\n") diff --git a/core/pdf_exporter/geometry.py b/core/pdf_exporter/geometry.py index 8363991..596d6bd 100644 --- a/core/pdf_exporter/geometry.py +++ b/core/pdf_exporter/geometry.py @@ -163,7 +163,7 @@ def extract_lineart_edges(mesh_data: Any, max_angle_deg: float = 30.0) -> NDArra # - normal.z > 0: back-facing (facing away) # - normal.z == 0: edge-on / vertical normal_z = normals_unit[:, 2] - epsilon = 1e-8 + epsilon = 1e-3 face_category = np.zeros(num_tris, dtype=int) face_category[normal_z < -epsilon] = 1 # Front-facing face_category[normal_z > epsilon] = 2 # Back-facing @@ -241,16 +241,16 @@ def extract_lineart_edges(mesh_data: Any, max_angle_deg: float = 30.0) -> NDArra sorted_edge_ids = inverse_indices[sort_by_edge] sorted_normals = edge_normals[sort_by_edge] - splits = np.where(sorted_edge_ids[:-1] != sorted_edge_ids[1:])[0] + 1 - edge_groups = np.split(sorted_normals, splits) - unique_group_ids = np.split(sorted_edge_ids, splits) + # Vectorized pair matching for adjacent faces sharing the same edge ID + match = sorted_edge_ids[:-1] == sorted_edge_ids[1:] + if np.any(match): + n1 = sorted_normals[:-1][match] + n2 = sorted_normals[1:][match] + e_ids = sorted_edge_ids[:-1][match] - for grp_normals, grp_ids in zip(edge_groups, unique_group_ids): - edge_id = grp_ids[0] - if len(grp_normals) >= 2: - dots = grp_normals @ grp_normals.T - if np.any(dots < cos_threshold - 1e-6): - rule3_unique[edge_id] = True + dot_prods = np.sum(n1 * n2, axis=1) + is_crease = dot_prods < (cos_threshold - 1e-6) + rule3_unique[e_ids[is_crease]] = True marked_unique_mask = rule1_unique | rule2_unique | rule3_unique marked_first_indices = first_indices[marked_unique_mask] @@ -414,8 +414,8 @@ def extract_silhouette_contour_edges(mesh_data: Any) -> NDArray[np.float64]: normal_z = normals_unit[:, 2] # Assign each face to a category: 0=vertical/edge-on, 1=front, 2=back - # We use a small epsilon to handle floating point - epsilon = 1e-8 + # We use epsilon = 1e-3 to robustly absorb floating point noise on perpendicular faces + epsilon = 1e-3 face_category = np.zeros(num_tris, dtype=int) face_category[normal_z < -epsilon] = 1 # Front-facing face_category[normal_z > epsilon] = 2 # Back-facing diff --git a/core/pdf_exporter/metadata.py b/core/pdf_exporter/metadata.py index b967274..8effdcd 100644 --- a/core/pdf_exporter/metadata.py +++ b/core/pdf_exporter/metadata.py @@ -99,42 +99,22 @@ def extract_object_bounding_boxes(mesh_data: Any) -> NDArray[np.float64]: """ Extracts 2D top-view bounding box rectangle edge segments (shape N x 2 x 2) for each SlicingObject in mesh_data. """ - from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject + from .sa2_measures import extract_slicing_objects_bounds + + sa2_objects = extract_slicing_objects_bounds(mesh_data) + if not sa2_objects: + return np.empty((0, 2, 2), dtype=np.float64) all_segments: list[np.ndarray] = [] + for item, name, min_x, max_x, min_y, max_y in sa2_objects: + rect = np.array([ + [[min_x, min_y], [max_x, min_y]], + [[max_x, min_y], [max_x, max_y]], + [[max_x, max_y], [min_x, max_y]], + [[min_x, max_y], [min_x, min_y]], + ], dtype=np.float64) + all_segments.append(rect) - def process_item(item: Any): - if item is None: - return - elif isinstance(item, (list, tuple)): - for sub in item: - process_item(sub) - elif isinstance(item, SlicingGroup): - for coll in item.collections.values(): - process_item(coll) - elif isinstance(item, SlicingCollection): - for so in item.objects: - process_item(so) - elif isinstance(item, SlicingObject): - if item.mesh.size > 0: - 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()) - - rect = np.array([ - [[min_x, min_y], [max_x, min_y]], - [[max_x, min_y], [max_x, max_y]], - [[max_x, max_y], [min_x, max_y]], - [[min_x, max_y], [min_x, min_y]], - ], dtype=np.float64) - all_segments.append(rect) - - process_item(mesh_data) - if not all_segments: - return np.empty((0, 2, 2), dtype=np.float64) return np.concatenate(all_segments, axis=0) diff --git a/core/pdf_exporter/sa2_measures.py b/core/pdf_exporter/sa2_measures.py index 6cfae68..4abeccc 100644 --- a/core/pdf_exporter/sa2_measures.py +++ b/core/pdf_exporter/sa2_measures.py @@ -7,9 +7,15 @@ 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 +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: @@ -37,6 +43,7 @@ def escape_pdf_text(text: str) -> str: 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]) @@ -89,7 +96,15 @@ def extract_slicing_objects_bounds(mesh_data: Any) -> list[tuple[Any, str, float return results -def extract_slicing_object_features(so_item: Any) -> tuple[ +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) @@ -97,23 +112,45 @@ def extract_slicing_object_features(so_item: Any) -> tuple[ ]: """ 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) """ - 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) + 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 [], [], [], [] - 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)) @@ -163,12 +200,15 @@ def extract_slicing_object_features(so_item: Any) -> tuple[ chamfers: list[tuple[float, float, float, float, float]] = [] step_features: list[tuple[float, float, float, float, float]] = [] - if so_item.mesh.size == 0: + 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 - 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 @@ -278,7 +318,12 @@ def render_sa2_measures_pdf( 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 + 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. @@ -295,11 +340,11 @@ def render_sa2_measures_pdf( return # Automatic sizing based on minor grid - MM_TO_PT = 2.834645669291339 - if line_width_pt is None or line_width_pt <= 0: - line_width_pt = minor_step_mm * 0.05 * MM_TO_PT - if font_size_pt is None or font_size_pt <= 0: - font_size_pt = minor_step_mm * MM_TO_PT + 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 @@ -310,23 +355,23 @@ def render_sa2_measures_pdf( 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") - # SA2 arrow length: scale with line width (which is based on minor grid) - arrow_len_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 + 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 - 1.0]])) + 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 - 1.0]])) + 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") @@ -353,9 +398,9 @@ def render_sa2_measures_pdf( # --- 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_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 - 1.0, max_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") @@ -399,44 +444,90 @@ def render_sa2_measures_pdf( 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) + 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. ISO 129-1 Circular Bores / Hole Diameter Callouts (Ø - Electric Blue) + # 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") - 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 + # 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_sh = to_pdf_pt(np.array([[px_shoulder, py_shoulder]])) - p_sh2 = to_pdf_pt(np.array([[px_shoulder + 10.0, py_shoulder]])) + p_sh1 = to_pdf_pt(np.array([[px_sh1, py_sh1]])) + p_sh2 = to_pdf_pt(np.array([[px_sh1 + leader_mm, py_sh1]])) - 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") + 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"{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" + 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" ) - 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") + 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: @@ -445,17 +536,18 @@ def render_sa2_measures_pdf( 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 + 6.0, mid_y_mm + 6.0]])) - p_sh2 = to_pdf_pt(np.array([[mid_x_mm + 14.0, mid_y_mm + 6.0]])) + 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] - 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") + 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) @@ -475,7 +567,7 @@ def render_sa2_measures_pdf( stream_cmds.append(f"({escape_pdf_text(r_str)}) Tj") stream_cmds.append("ET") - # 3. ISO 129-1 Bevels & Chamfers (C / 45° - Violet Purple) + # 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") @@ -484,18 +576,22 @@ def render_sa2_measures_pdf( 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 + 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 + 10.0, 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] - 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:.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 " @@ -503,55 +599,87 @@ def render_sa2_measures_pdf( 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" + 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. Hole Center Pitch Distances (Teal Green) - if draw_sa2_holes and len(holes) >= 2: + # 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") - 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] + distinct_centers = [grp[0][:2] for grp in concentric_groups] + distinct_centers.sort(key=lambda c: (c[0], c[1])) - stream_cmds.append(f"{x1:.2f} {y1:.2f} m {x2:.2f} {y2:.2f} l S") + 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) - pitch_str = f"PITCH: {dist_mm:g}mm" + # 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.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"/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") - # 5. Interior Step / Notch Feature Segment Lengths (Deep Purple) - if draw_sa2_step_features and 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") + # 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]])) - 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"{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") - 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") diff --git a/core/pdf_exporter/sizing.py b/core/pdf_exporter/sizing.py new file mode 100644 index 0000000..2683e7f --- /dev/null +++ b/core/pdf_exporter/sizing.py @@ -0,0 +1,108 @@ +""" +Dynamic Dimensioning and Scaled Metrics Engine for PDF Exporter. +Centralizes all auto-scaling calculations for grid steps, legend ticks, arrows, +line widths, font sizes, and SA2 technical measure offsets. +""" +import math +import numpy as np + +MM_TO_PT = 2.834645669291339 + + +def compute_grid_steps(w_geom: float, h_geom: float) -> tuple[float, float]: + """ + Computes human-readable main_step (mm) and minor_step (mm) for grid overlay. + """ + L = max(w_geom, h_geom) + if L > 1e-6: + step_raw = L / 10.0 + exponent = int(np.round(np.log10(step_raw))) + main_step = 10.0 ** exponent + else: + main_step = 10.0 + minor_step = main_step / 10.0 + return main_step, minor_step + + +def compute_effective_stroke_and_font( + line_width_mm: float, + font_size_mm: float, + minor_step_mm: float +) -> tuple[float, float]: + """ + Calculates line_width_pt and font_size_pt proportionally based on minor_step_mm. + Scales continuously from millimetric components up to large structural models. + """ + if line_width_mm <= 0: + line_width_pt = minor_step_mm * 0.05 * MM_TO_PT + else: + line_width_pt = line_width_mm * MM_TO_PT + + if font_size_mm <= 0: + font_size_pt = minor_step_mm * MM_TO_PT + else: + font_size_pt = font_size_mm * MM_TO_PT + + return line_width_pt, font_size_pt + + +class LegendSizing: + """ + Auto-scaling calculations for the Size Scale Legend (bottom-left grid scale bar). + """ + def __init__(self, main_step_mm: float, minor_step_mm: float, line_width_pt: float, font_size_pt: float): + self.main_step = main_step_mm + self.minor_step = minor_step_mm + self.line_width_pt = line_width_pt + self.font_size_pt = font_size_pt + + # Tick heights in mm proportional to main grid step + self.major_tick_mm = main_step_mm * 0.30 + self.minor_tick_mm = main_step_mm * 0.15 + + # Y-axis height in mm (extends comfortably past major tick height) + self.y_axis_height_mm = self.major_tick_mm * 1.5 + + # X-axis extension past last main step tick in mm + self.x_axis_extension_mm = minor_step_mm * 2.0 + + # Legend Arrowhead dimensions in PT (proportional to line width) + self.arrow_len_pt = max(0.5, line_width_pt * 4.0) + self.arrow_half_w_pt = self.arrow_len_pt * 0.35 + + +class SA2Sizing: + """ + Auto-scaling calculations for SA2 technical measurement lines and feature callouts. + """ + def __init__(self, w_obj_mm: float, h_obj_mm: float, line_width_pt: float, font_size_pt: float): + self.w_obj = w_obj_mm + self.h_obj = h_obj_mm + self.line_width_pt = line_width_pt + self.font_size_pt = font_size_pt + + self.obj_ref_size = min(w_obj_mm, h_obj_mm) if min(w_obj_mm, h_obj_mm) > 0 else max(w_obj_mm, h_obj_mm) + + # Dimension line offset & extension line scaling (mm) proportional to object size + self.offset_mm = self.obj_ref_size * 0.10 + self.ext_overhang_mm = self.offset_mm * 0.20 + self.ext_gap_mm = self.offset_mm * 0.10 + + # Measure arrow dimensions (PT) + self.arrow_len_pt = max(0.5, line_width_pt * 4.0) + self.arrow_half_w_pt = self.arrow_len_pt * 0.35 + + def get_hole_callout_lengths(self, dia_mm: float) -> tuple[float, float]: + slant_mm = dia_mm * 0.30 + leader_mm = dia_mm * 0.40 + return slant_mm, leader_mm + + def get_arc_callout_lengths(self, r_mm: float) -> tuple[float, float]: + slant_mm = r_mm * 0.30 + leader_mm = r_mm * 0.40 + return slant_mm, leader_mm + + def get_chamfer_callout_lengths(self, c_val_mm: float) -> tuple[float, float]: + slant_mm = c_val_mm * 1.0 + leader_mm = c_val_mm * 1.5 + return slant_mm, leader_mm diff --git a/core/pdf_exporter/writer.py b/core/pdf_exporter/writer.py index 515058a..8f9abca 100644 --- a/core/pdf_exporter/writer.py +++ b/core/pdf_exporter/writer.py @@ -22,8 +22,12 @@ from .geometry import ( deduplicate_2d_edges, ) from .sa2_measures import render_sa2_measures_pdf - -MM_TO_PT = 2.834645669291339 +from .sizing import ( + compute_grid_steps, + compute_effective_stroke_and_font, + LegendSizing, + MM_TO_PT, +) def escape_pdf_text(text: str) -> str: @@ -108,7 +112,8 @@ def export_mesh_to_pdf( if draw_wireframe and len(edges_2d) > 0: edge_list.append(edges_2d) - if draw_boundary and raw_geometry is not None: + # extract_crease_edges (lineart) already extracts boundary edges, so skip extract_boundary_edges when draw_crease is active + if draw_boundary and not draw_crease and raw_geometry is not None: b_edges = extract_boundary_edges(raw_geometry) if len(b_edges) > 0: edge_list.append(b_edges) @@ -131,16 +136,11 @@ def export_mesh_to_pdf( else: edges_to_draw = np.empty((0, 2, 2), dtype=np.float64) - if len(edges_to_draw) > 0: - min_x = float(edges_to_draw[:, :, 0].min()) - max_x = float(edges_to_draw[:, :, 0].max()) - min_y = float(edges_to_draw[:, :, 1].min()) - max_y = float(edges_to_draw[:, :, 1].max()) - elif len(edges_2d) > 0: - min_x = float(edges_2d[:, :, 0].min()) - max_x = float(edges_2d[:, :, 0].max()) - min_y = float(edges_2d[:, :, 1].min()) - max_y = float(edges_2d[:, :, 1].max()) + target_edges = edges_to_draw if len(edges_to_draw) > 0 else edges_2d + if len(target_edges) > 0: + pts_2d = target_edges.reshape(-1, 2) + min_x, max_x = float(pts_2d[:, 0].min()), float(pts_2d[:, 0].max()) + min_y, max_y = float(pts_2d[:, 1].min()), float(pts_2d[:, 1].max()) else: min_x = max_x = min_y = max_y = 0.0 @@ -148,16 +148,7 @@ def export_mesh_to_pdf( h_geom = max_y - min_y center_x = (min_x + max_x) / 2.0 center_y = (min_y + max_y) / 2.0 - - L = max(w_geom, h_geom) - if L > 1e-6: - step_raw = L / 10.0 - exponent = int(np.round(np.log10(step_raw))) - main_step = 10.0 ** exponent - else: - main_step = 10.0 - - minor_step = main_step / 10.0 + main_step, minor_step = compute_grid_steps(w_geom, h_geom) if len(edges_to_draw) > 0 or len(edges_2d) > 0: pad_x = minor_step @@ -207,17 +198,9 @@ def export_mesh_to_pdf( page_w_pt = w_total_mm * MM_TO_PT page_h_pt = h_total_mm * MM_TO_PT - # Automatic sizing based on minor grid - if line_width_mm <= 0: - line_width_pt = minor_step * 0.05 * MM_TO_PT - else: - line_width_pt = line_width_mm * MM_TO_PT - - # Convert font_size_mm to pt and apply multiplier - if font_size_mm <= 0: - font_size_pt = minor_step * MM_TO_PT - else: - font_size_pt = font_size_mm * MM_TO_PT + line_width_pt, font_size_pt = compute_effective_stroke_and_font( + line_width_mm, font_size_mm, minor_step + ) def to_pdf_pt(pt_mm: NDArray[np.float64]) -> NDArray[np.float64]: x_pt = (pt_mm[:, 0] - grid_min_x + total_margin_mm) * MM_TO_PT @@ -347,13 +330,20 @@ def export_mesh_to_pdf( main_step=main_step, to_pdf_pt=to_pdf_pt, font_size_pt=font_size_pt, - line_width_pt=line_width_pt + line_width_pt=line_width_pt, + draw_wireframe=draw_wireframe, + draw_boundary=draw_boundary, + draw_contour=draw_contour, + draw_crease=draw_crease, + crease_angle=crease_angle, ) # 6. Size Scale Legend (Bottom Left, aligned directly on bottom-left main grid lines with arrows and X/Y labels) stream_cmds.append("/GS100 gs") stream_cmds.append("0.000 0.000 0.000 RG") stream_cmds.append("0.000 0.000 0.000 rg") + + leg_sizing = LegendSizing(main_step, minor_step, line_width_pt, font_size_pt) x_grid_start_mm = grid_min_x x_grid_end_mm = min(grid_max_x, grid_min_x + 10.0 * main_step) num_main_steps = int(np.round((x_grid_end_mm - x_grid_start_mm) / main_step)) @@ -365,22 +355,20 @@ def export_mesh_to_pdf( x_end_pt = p_end_pt[0, 0] y_bar_pt = p_start_pt[0, 1] - # Vertical Y-axis extension: 5 minor grid lines up from origin - y_grid_top_mm = grid_min_y + 5.0 * minor_step + # Vertical Y-axis extension dynamically sized past tick height + y_grid_top_mm = grid_min_y + leg_sizing.y_axis_height_mm p_top_pt = to_pdf_pt(np.array([[grid_min_x, y_grid_top_mm]])) y_top_pt = p_top_pt[0, 1] - # Smart arrowhead scaling based on line_width_pt and font size - font_size_pt = font_size_pt if font_size_pt is not None and font_size_pt > 0 else max(8.0, line_width_pt * 4.0) - arrow_len_pt = max(3.5 * MM_TO_PT, line_width_pt * 4.5) - arrow_half_w_pt = arrow_len_pt * 0.40 + arrow_len_pt = leg_sizing.arrow_len_pt + arrow_half_w_pt = leg_sizing.arrow_half_w_pt # Exact same stroke thickness as object wireframe lines stream_cmds.append(f"{line_width_pt:.2f} w") stream_cmds.append("1 j 1 J") - # 6a. Main horizontal X-axis line (spans 10 main grid steps, extends 5 minor grid lines past last tick + right arrow + X label) - x_arrow_base_mm = x_grid_end_mm + 5.0 * minor_step + # 6a. Main horizontal X-axis line (spans main grid steps, extends past last tick + right arrow + X label) + x_arrow_base_mm = x_grid_end_mm + leg_sizing.x_axis_extension_mm p_arrow_base_pt = to_pdf_pt(np.array([[x_arrow_base_mm, grid_min_y]])) x_arrow_base_pt = p_arrow_base_pt[0, 0] x_arrow_tip_pt = x_arrow_base_pt + arrow_len_pt @@ -388,30 +376,31 @@ def export_mesh_to_pdf( # Horizontal main scale bar line stream_cmds.append(f"{x_start_pt:.2f} {y_bar_pt:.2f} m {x_arrow_tip_pt:.2f} {y_bar_pt:.2f} l S") - # Pointy arrowhead (+X) extending past 5th minor grid line beyond x_end_pt + # Pointy arrowhead (+X) extending past last tick stream_cmds.append( f"{x_arrow_base_pt:.2f} {y_bar_pt + arrow_half_w_pt:.2f} m " f"{x_arrow_tip_pt:.2f} {y_bar_pt:.2f} l " f"{x_arrow_base_pt:.2f} {y_bar_pt - arrow_half_w_pt:.2f} l S" ) - # Ticks directly overlapping vertical main grid lines on X-axis (full 10 main grid steps including k=0) + # Ticks directly overlapping vertical main grid lines on X-axis tick_cmds = [] for k in range(num_main_steps + 1): xk_mm = x_grid_start_mm + k * main_step pk_pt = to_pdf_pt(np.array([[xk_mm, grid_min_y]])) xk_pt = pk_pt[0, 0] - tick_h_pt = (5.0 if k in (0, 5, num_main_steps) else 3.0) * MM_TO_PT + tick_h_mm = leg_sizing.major_tick_mm if k in (0, 5, num_main_steps) else leg_sizing.minor_tick_mm + tick_h_pt = tick_h_mm * MM_TO_PT tick_cmds.append(f"{xk_pt:.2f} {y_bar_pt:.2f} m {xk_pt:.2f} {y_bar_pt + tick_h_pt:.2f} l") stream_cmds.extend(tick_cmds) stream_cmds.append("S") - # 6b. Main vertical Y-axis line (5 minor grid lines up with NO ticks) + top arrow (+Y) + # 6b. Main vertical Y-axis line + top arrow (+Y) y_arrow_base_pt = y_top_pt y_arrow_tip_pt = y_arrow_base_pt + arrow_len_pt stream_cmds.append(f"{x_start_pt:.2f} {y_bar_pt:.2f} m {x_start_pt:.2f} {y_arrow_tip_pt:.2f} l S") - # Pointy arrowhead (+Y) extending past 5th minor grid line (y_top_pt) + # Pointy arrowhead (+Y) extending past y_top_pt stream_cmds.append( f"{x_start_pt - arrow_half_w_pt:.2f} {y_arrow_base_pt:.2f} m " f"{x_start_pt:.2f} {y_arrow_tip_pt:.2f} l " @@ -427,7 +416,8 @@ def export_mesh_to_pdf( xk_mm = x_grid_start_mm + k * main_step pk_pt = to_pdf_pt(np.array([[xk_mm, grid_min_y]])) xk_pt = pk_pt[0, 0] - tick_h_pt = (5.0 if k in (0, 5, num_main_steps) else 3.0) * MM_TO_PT + tick_h_mm = leg_sizing.major_tick_mm if k in (0, 5, num_main_steps) else leg_sizing.minor_tick_mm + tick_h_pt = tick_h_mm * MM_TO_PT val_mm = k * main_step if main_step >= 1000.0: diff --git a/doc/MODULES.md b/doc/MODULES.md index 6402c8e..65af32b 100644 --- a/doc/MODULES.md +++ b/doc/MODULES.md @@ -40,6 +40,16 @@ This file provides a mapping of modules to their primary responsibilities and ke - `geometry.py`: - Geometry utility functions. +## `/core/pdf_exporter` (PDF Export & Technical Marking Engine) + +- `sa2_measures.py`: + - `render_sa2_measures_pdf()`: ISO 129-1 technical marking engine. + - `extract_slicing_object_features()`: Feature detection for circular bores (Ø), arcs (R), chamfers (C), and steps. +- `depth_rasterizer.py`: + - `save_sa2_debug_data()`: Exports input 2D edge vector coordinates and cProfile performance data when debug is enabled. +- `writer.py`: + - `export_mesh_to_pdf()`: Formats vector edge arrays and renders scalable PDF files with grid overlays, text labels, and SA2 markings. + ## `/infra` (Blender Integration) - `blender_mesh_capture.py`: diff --git a/tests/test_pdf_exporter.py b/tests/test_pdf_exporter.py index 65d0c81..a4abb8b 100644 --- a/tests/test_pdf_exporter.py +++ b/tests/test_pdf_exporter.py @@ -47,6 +47,7 @@ from NodeSlicer.core.pdf_exporter import ( extract_unique_top_view_edges, extract_lineart_edges, extract_outer_contour_edges, + extract_slicing_object_features, export_wireframe_to_pdf, export_mesh_to_pdf, MM_TO_PT @@ -153,6 +154,21 @@ class TestPdfExporter(unittest.TestCase): # Contour should exclude the internal diagonal edge and return only 4 outer boundary edges self.assertEqual(len(contour), 4) + def test_extract_outer_contour_edges_perpendicular_floating_point_noise(self): + so = SlicingObject() + # Top face: normal_z = +1.0 + tri_top = np.array([[0.0, 0.0, 10.0], [10.0, 0.0, 10.0], [0.0, 10.0, 10.0], [0.0, 0.0, 1.0]], dtype=np.float64) + # Perpendicular side walls with floating point noise in normal_z (e.g. 1.5e-5, -2.0e-5) + tri_side1 = np.array([[0.0, 0.0, 10.0], [10.0, 0.0, 10.0], [10.0, 0.0, 0.0], [0.0, -1.0, 1.5e-5]], dtype=np.float64) + tri_side2 = np.array([[10.0, 0.0, 10.0], [0.0, 10.0, 10.0], [0.0, 10.0, 0.0], [0.707, 0.707, -2.0e-5]], dtype=np.float64) + tri_side3 = np.array([[0.0, 10.0, 10.0], [0.0, 0.0, 10.0], [0.0, 0.0, 0.0], [-1.0, 0.0, 4.0e-5]], dtype=np.float64) + + so.mesh = np.array([tri_top, tri_side1, tri_side2, tri_side3]) + + contour = extract_outer_contour_edges(so) + # All 3 outer boundary edges of the top triangle meeting perpendicular side walls must be detected + self.assertEqual(len(contour), 3) + def test_export_pdf_true_1_to_1_scale(self): edges_2d = np.array([ [[0.0, 0.0], [100.0, 0.0]], @@ -736,6 +752,57 @@ class TestPdfExporter(unittest.TestCase): self.assertIn("LeagueMono-Regular", content) self.assertIn("/Subtype /OpenType", content) + def test_sa2_source_geometry_matches_visible_selected(self): + so = SlicingObject() + so.name = "VisibilityTestPart" + tris = [ + [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0], [0.0, 0.0, 1.0]], + [[10.0, 0.0, 0.0], [10.0, 10.0, 0.0], [0.0, 10.0, 0.0], [0.0, 0.0, 1.0]] + ] + so.mesh = np.array(tris, dtype=np.float64) + + # 1. When no geometry is selected as visible (all False), extract_slicing_object_features returns empty + h, a, c, s = extract_slicing_object_features( + so, draw_wireframe=False, draw_boundary=False, draw_contour=False, draw_crease=False + ) + self.assertEqual((h, a, c, s), ([], [], [], [])) + + # 2. When draw_boundary=True, features are extracted from boundary edges + h_b, a_b, c_b, s_b = extract_slicing_object_features( + so, draw_wireframe=False, draw_boundary=True, draw_contour=False, draw_crease=False + ) + + # 3. When draw_wireframe=True, features are extracted from top-view wireframe edges + h_w, a_w, c_w, s_w = extract_slicing_object_features( + so, draw_wireframe=True, draw_boundary=False, draw_contour=False, draw_crease=False + ) + self.assertIsNotNone(h_w) + def test_export_pdf_debug_sa2_output_files(self): + so = SlicingObject() + so.name = "DebugPart" + so.mesh = np.array([ + [[0.0, 0.0, 0.0], [50.0, 0.0, 0.0], [0.0, 50.0, 0.0], [0.0, 0.0, 1.0]], + [[50.0, 0.0, 0.0], [50.0, 50.0, 0.0], [0.0, 50.0, 0.0], [0.0, 0.0, 1.0]] + ], dtype=np.float64) + + edges_2d = extract_unique_top_view_edges(so) + + with tempfile.TemporaryDirectory() as tmpdir: + pdf_path = os.path.join(tmpdir, "debug_test.pdf") + export_mesh_to_pdf( + edges_2d, + pdf_path, + debug_sa2=True, + raw_geometry=so + ) + + self.assertTrue(os.path.exists(pdf_path)) + txt_debug = os.path.join(tmpdir, "debug_test_sa2_debug_edges.txt") + prof_debug = os.path.join(tmpdir, "debug_test_sa2_debug_cprofile.txt") + + self.assertTrue(os.path.exists(txt_debug)) + self.assertTrue(os.path.exists(prof_debug)) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_sizing.py b/tests/test_sizing.py new file mode 100644 index 0000000..08e95df --- /dev/null +++ b/tests/test_sizing.py @@ -0,0 +1,49 @@ +import unittest +import numpy as np + +from NodeSlicer.core.pdf_exporter.sizing import ( + compute_grid_steps, + compute_effective_stroke_and_font, + LegendSizing, + SA2Sizing, + MM_TO_PT, +) + + +class TestSizingLogic(unittest.TestCase): + + def test_compute_grid_steps_small_object(self): + # 20mm x 15mm object + main_step, minor_step = compute_grid_steps(20.0, 15.0) + self.assertEqual(main_step, 1.0) + self.assertEqual(minor_step, 0.1) + + def test_compute_grid_steps_medium_object(self): + # 120mm x 80mm object + main_step, minor_step = compute_grid_steps(120.0, 80.0) + self.assertEqual(main_step, 10.0) + self.assertEqual(minor_step, 1.0) + + def test_compute_effective_stroke_and_font_defaults(self): + # 2mm object with minor_step = 0.01mm + line_w_pt, font_size_pt = compute_effective_stroke_and_font(0.0, 0.0, 0.01) + # Should scale line width down proportionally for millimetric scale + self.assertAlmostEqual(line_w_pt, 0.01 * 0.05 * MM_TO_PT, places=6) + self.assertAlmostEqual(font_size_pt, 0.01 * MM_TO_PT, places=6) + + def test_legend_sizing_scaling(self): + leg = LegendSizing(10.0, 1.0, 1.0, 8.0) + self.assertGreater(leg.y_axis_height_mm, leg.major_tick_mm) + self.assertGreater(leg.arrow_len_pt, 0) + self.assertGreater(leg.arrow_half_w_pt, 0) + + def test_sa2_sizing_millimetric_scale(self): + # 2mm x 1.5mm millimetric scale object + sa2 = SA2Sizing(2.0, 1.5, 0.0014, 0.028) + # For a 1.5mm/2mm millimetric object, offset should be fine (0.15mm) + self.assertAlmostEqual(sa2.offset_mm, 0.15, places=3) + self.assertAlmostEqual(sa2.ext_overhang_mm, 0.03, places=3) + + slant, leader = sa2.get_hole_callout_lengths(0.5) + self.assertAlmostEqual(slant, 0.15, places=3) + self.assertAlmostEqual(leader, 0.20, places=3)