This commit is contained in:
Nicolas Predella 2026-07-31 14:25:38 +03:00
parent c862e8976b
commit 7b8c54c2a5
9 changed files with 565 additions and 196 deletions

View file

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

View file

@ -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: back-facing (facing away)
# - normal.z == 0: edge-on / vertical # - normal.z == 0: edge-on / vertical
normal_z = normals_unit[:, 2] normal_z = normals_unit[:, 2]
epsilon = 1e-8 epsilon = 1e-3
face_category = np.zeros(num_tris, dtype=int) face_category = np.zeros(num_tris, dtype=int)
face_category[normal_z < -epsilon] = 1 # Front-facing face_category[normal_z < -epsilon] = 1 # Front-facing
face_category[normal_z > epsilon] = 2 # Back-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_edge_ids = inverse_indices[sort_by_edge]
sorted_normals = edge_normals[sort_by_edge] sorted_normals = edge_normals[sort_by_edge]
splits = np.where(sorted_edge_ids[:-1] != sorted_edge_ids[1:])[0] + 1 # Vectorized pair matching for adjacent faces sharing the same edge ID
edge_groups = np.split(sorted_normals, splits) match = sorted_edge_ids[:-1] == sorted_edge_ids[1:]
unique_group_ids = np.split(sorted_edge_ids, splits) 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): dot_prods = np.sum(n1 * n2, axis=1)
edge_id = grp_ids[0] is_crease = dot_prods < (cos_threshold - 1e-6)
if len(grp_normals) >= 2: rule3_unique[e_ids[is_crease]] = True
dots = grp_normals @ grp_normals.T
if np.any(dots < cos_threshold - 1e-6):
rule3_unique[edge_id] = True
marked_unique_mask = rule1_unique | rule2_unique | rule3_unique marked_unique_mask = rule1_unique | rule2_unique | rule3_unique
marked_first_indices = first_indices[marked_unique_mask] 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] normal_z = normals_unit[:, 2]
# Assign each face to a category: 0=vertical/edge-on, 1=front, 2=back # Assign each face to a category: 0=vertical/edge-on, 1=front, 2=back
# We use a small epsilon to handle floating point # We use epsilon = 1e-3 to robustly absorb floating point noise on perpendicular faces
epsilon = 1e-8 epsilon = 1e-3
face_category = np.zeros(num_tris, dtype=int) face_category = np.zeros(num_tris, dtype=int)
face_category[normal_z < -epsilon] = 1 # Front-facing face_category[normal_z < -epsilon] = 1 # Front-facing
face_category[normal_z > epsilon] = 2 # Back-facing face_category[normal_z > epsilon] = 2 # Back-facing

View file

@ -99,31 +99,14 @@ 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. 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] = [] all_segments: list[np.ndarray] = []
for item, name, min_x, max_x, min_y, max_y in sa2_objects:
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([ rect = np.array([
[[min_x, min_y], [max_x, min_y]], [[min_x, min_y], [max_x, min_y]],
[[max_x, min_y], [max_x, max_y]], [[max_x, min_y], [max_x, max_y]],
@ -132,9 +115,6 @@ def extract_object_bounding_boxes(mesh_data: Any) -> NDArray[np.float64]:
], dtype=np.float64) ], dtype=np.float64)
all_segments.append(rect) 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) return np.concatenate(all_segments, axis=0)

View file

@ -7,9 +7,15 @@ from typing import Any, Callable
import numpy as np import numpy as np
from numpy.typing import NDArray from numpy.typing import NDArray
from .geometry import extract_lineart_edges, extract_unique_top_view_edges, deduplicate_2d_edges, extract_boundary_edges from .geometry import (
extract_lineart_edges,
MM_TO_PT = 2.834645669291339 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: def escape_pdf_text(text: str) -> str:
@ -37,6 +43,7 @@ def escape_pdf_text(text: str) -> str:
return "".join(res) return "".join(res)
def circumcircle_3pts(p1: NDArray, p2: NDArray, p3: NDArray) -> tuple[tuple[float, float] | None, float | None]: 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.""" """Calculate center and radius of circumcircle passing through 3 2D points."""
ax, ay = float(p1[0]), float(p1[1]) 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 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]], # 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]], # 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]], # 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: 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) 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) 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) 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) 4. Internal step / notch segments: list of (x1, y1, x2, y2, length_mm)
""" """
b_edges = extract_boundary_edges(so_item) edge_list: list[NDArray[np.float64]] = []
if len(b_edges) == 0:
b_edges = extract_lineart_edges(so_item) if draw_wireframe:
if len(b_edges) == 0: w_edges = extract_unique_top_view_edges(so_item)
b_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: if len(b_edges) == 0:
return [], [], [], [] return [], [], [], []
b_edges = deduplicate_2d_edges(b_edges)
b_edges = deduplicate_2d_edges(b_edges)
adj: dict[tuple[float, float], list[tuple[float, float]]] = {} adj: dict[tuple[float, float], list[tuple[float, float]]] = {}
for p0, p1 in b_edges: for p0, p1 in b_edges:
pt0 = (round(float(p0[0]), 2), round(float(p0[1]), 2)) 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]] = [] chamfers: list[tuple[float, float, float, float, float]] = []
step_features: 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:
return holes, arcs, chamfers, step_features 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) 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_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()) min_y, max_y = float(verts_2d[:, 1].min()), float(verts_2d[:, 1].max())
else:
return holes, arcs, chamfers, step_features
overall_w = max_x - min_x overall_w = max_x - min_x
overall_h = max_y - min_y overall_h = max_y - min_y
@ -278,7 +318,12 @@ def render_sa2_measures_pdf(
main_step: float = 10.0, main_step: float = 10.0,
to_pdf_pt: Callable[[NDArray[np.float64]], NDArray[np.float64]] | None = None, to_pdf_pt: Callable[[NDArray[np.float64]], NDArray[np.float64]] | None = None,
font_size_pt: float | 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: ) -> None:
""" """
Renders ISO 129-1 compliant SA2 automated technical measurements on PDF stream. Renders ISO 129-1 compliant SA2 automated technical measurements on PDF stream.
@ -295,11 +340,11 @@ def render_sa2_measures_pdf(
return return
# Automatic sizing based on minor grid # Automatic sizing based on minor grid
MM_TO_PT = 2.834645669291339 line_width_pt, font_size_pt = compute_effective_stroke_and_font(
if line_width_pt is None or line_width_pt <= 0: line_width_pt / MM_TO_PT if line_width_pt else 0.0,
line_width_pt = minor_step_mm * 0.05 * MM_TO_PT font_size_pt / MM_TO_PT if font_size_pt else 0.0,
if font_size_pt is None or font_size_pt <= 0: minor_step_mm
font_size_pt = minor_step_mm * MM_TO_PT )
stream_cmds.append("/GS100 gs") stream_cmds.append("/GS100 gs")
sa2_lw_pt = line_width_pt * 0.8 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 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") 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: for so_item, obj_name, min_x, max_x, min_y, max_y in sa2_objects:
w_obj_mm = max_x - min_x w_obj_mm = max_x - min_x
h_obj_mm = max_y - min_y h_obj_mm = max_y - min_y
offset_mm = 8.0 sizing = SA2Sizing(w_obj_mm, h_obj_mm, line_width_pt, font_size_pt)
ext_overhang_mm = 2.0 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) --- # --- Width Dimension (Horizontal below SlicingObject) ---
y_dim_mm = min_y - offset_mm 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_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]])) 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_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) --- # --- Height Dimension (Vertical to the left of SlicingObject) ---
x_dim_mm = min_x - offset_mm 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_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]])) 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_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,42 +444,88 @@ def render_sa2_measures_pdf(
stream_cmds.append("ET") stream_cmds.append("ET")
# --- Additional SA2 Feature Measures: Bores (Ø), Arcs (R), Chamfers (C) & Pitch --- # --- 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: if draw_sa2_holes and holes:
blue_r, blue_g, blue_b = 0.10, 0.45, 0.90 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")
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: # Group holes by center location (tolerance = 0.5mm)
r_mm = dia_mm / 2.0 concentric_groups: list[list[tuple[float, float, float]]] = []
px_edge = cx_mm + r_mm * 0.7071 for cx, cy, dia in holes:
py_edge = cy_mm + r_mm * 0.7071 added = False
px_shoulder = px_edge + 8.0 for grp in concentric_groups:
py_shoulder = py_edge + 8.0 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_edge = to_pdf_pt(np.array([[px_edge, py_edge]]))
p_sh = to_pdf_pt(np.array([[px_shoulder, py_shoulder]])) p_sh1 = to_pdf_pt(np.array([[px_sh1, py_sh1]]))
p_sh2 = to_pdf_pt(np.array([[px_shoulder + 10.0, py_shoulder]])) 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] xe, ye = p_edge[0, 0], p_edge[0, 1]
x_s1, y_s1 = p_sh[0, 0], p_sh[0, 1] xs1, ys1 = p_sh1[0, 0], p_sh1[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"{xe:.2f} {ye:.2f} m {xs1:.2f} {ys1:.2f} l {p_sh2[0, 0]:.2f} {ys1:.2f} l S")
stream_cmds.append( stream_cmds.append(
f"{x_e + arrow_len_pt * 0.7071:.2f} {y_e + arrow_len_pt * 0.7071:.2f} m " f"{xe + arrow_len_pt * 0.7071:.2f} {ye + arrow_len_pt * 0.7071:.2f} m "
f"{x_e:.2f} {y_e:.2f} l " f"{xe:.2f} {ye:.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.3535:.2f} {ye + arrow_len_pt * 0.95:.2f} l S"
) )
dia_val = round(dia_mm, 1) for line_idx, (hcx, hcy, hdia) in enumerate(grp):
dia_str = f"Ø{dia_val:g}mm" if main_step < 1000.0 else f"Ø{dia_val/1000:g}m" 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("BT")
stream_cmds.append(f"/F1 {font_size_pt * 0.9:.1f} Tf") stream_cmds.append(f"/F1 {font_size_pt * 0.85:.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"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(f"({escape_pdf_text(dia_str)}) Tj")
stream_cmds.append("ET") stream_cmds.append("ET")
@ -445,17 +536,18 @@ def render_sa2_measures_pdf(
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]: 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_center = to_pdf_pt(np.array([[acx_mm, acy_mm]]))
p_mid = to_pdf_pt(np.array([[mid_x_mm, mid_y_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_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 + 14.0, mid_y_mm + 6.0]])) 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] xc, yc = p_center[0, 0], p_center[0, 1]
xm, ym = p_mid[0, 0], p_mid[0, 1] xm, ym = p_mid[0, 0], p_mid[0, 1]
xs1, ys1 = p_sh1[0, 0], p_sh1[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) ux, uy = (xm - xc), (ym - yc)
dist_cm = math.sqrt(ux*ux + uy*uy) 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(f"({escape_pdf_text(r_str)}) Tj")
stream_cmds.append("ET") 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: if draw_sa2_chamfers and chamfers:
purple_r, purple_g, purple_b = 0.60, 0.20, 0.85 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")
@ -484,18 +576,22 @@ def render_sa2_measures_pdf(
for ch_x1, ch_y1, ch_x2, ch_y2, c_val in chamfers[:4]: 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_x = (ch_x1 + ch_x2) / 2.0
ch_mid_y = (ch_y1 + ch_y2) / 2.0 ch_mid_y = (ch_y1 + ch_y2) / 2.0
sh_x = ch_mid_x + 6.0 dx_ch = abs(ch_x2 - ch_x1)
sh_y = ch_mid_y + 6.0 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_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_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_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_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( stream_cmds.append(
f"{x_cm + arrow_len_pt * 0.7071:.2f} {y_cm + arrow_len_pt * 0.7071:.2f} m " 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" 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:.1f}mm ({angle_deg:.0f}°)"
c_str = f"C{c_val_rounded:g}mm"
stream_cmds.append("BT") stream_cmds.append("BT")
stream_cmds.append(f"/F1 {font_size_pt * 0.85:.1f} Tf") 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"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(f"({escape_pdf_text(c_str)}) Tj")
stream_cmds.append("ET") stream_cmds.append("ET")
# 4. Hole Center Pitch Distances (Teal Green) # 4. Orthogonal Hole Center Pitch & Datum Offset Baselines (Teal Green)
if draw_sa2_holes and len(holes) >= 2: 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 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")
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])) distinct_centers = [grp[0][:2] for grp in concentric_groups]
for i in range(len(sorted_holes) - 1): distinct_centers.sort(key=lambda c: (c[0], c[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") 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("BT")
stream_cmds.append(f"/F1 {font_size_pt * 0.85:.1f} Tf") stream_cmds.append(f"/F1 {font_size_pt * 0.8:.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"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(f"({escape_pdf_text(pitch_str)}) Tj")
stream_cmds.append("ET") stream_cmds.append("ET")
# 5. Interior Step / Notch Feature Segment Lengths (Deep Purple) # Vertical Pitch Axis (if dy_mm > 0.5)
if draw_sa2_step_features and step_features: if dy_mm > 0.5:
purple_r, purple_g, purple_b = 0.48, 0.23, 0.92 x_pitch_mm = max(cx1, cx2) + sizing.offset_mm * 0.5
stream_cmds.append(f"{purple_r:.3f} {purple_g:.3f} {purple_b:.3f} RG") p_v1 = to_pdf_pt(np.array([[x_pitch_mm, cy1]]))
stream_cmds.append(f"{purple_r:.3f} {purple_g:.3f} {purple_b:.3f} rg") 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]: 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")
p_s1 = to_pdf_pt(np.array([[x1_m, y1_m]])) 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")
p_s2 = to_pdf_pt(np.array([[x2_m, y2_m]])) 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")
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") pitch_str = f"PITCH Y: {dy_mm:.1f}mm"
step_str = f"{step_len:g}mm"
stream_cmds.append("BT") stream_cmds.append("BT")
stream_cmds.append(f"/F1 {font_size_pt * 0.8:.1f} Tf") 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"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(step_str)}) Tj") stream_cmds.append(f"({escape_pdf_text(pitch_str)}) Tj")
stream_cmds.append("ET") 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")

108
core/pdf_exporter/sizing.py Normal file
View file

@ -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

View file

@ -22,8 +22,12 @@ from .geometry import (
deduplicate_2d_edges, deduplicate_2d_edges,
) )
from .sa2_measures import render_sa2_measures_pdf from .sa2_measures import render_sa2_measures_pdf
from .sizing import (
MM_TO_PT = 2.834645669291339 compute_grid_steps,
compute_effective_stroke_and_font,
LegendSizing,
MM_TO_PT,
)
def escape_pdf_text(text: str) -> str: def escape_pdf_text(text: str) -> str:
@ -108,7 +112,8 @@ def export_mesh_to_pdf(
if draw_wireframe and len(edges_2d) > 0: if draw_wireframe and len(edges_2d) > 0:
edge_list.append(edges_2d) 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) b_edges = extract_boundary_edges(raw_geometry)
if len(b_edges) > 0: if len(b_edges) > 0:
edge_list.append(b_edges) edge_list.append(b_edges)
@ -131,16 +136,11 @@ def export_mesh_to_pdf(
else: else:
edges_to_draw = np.empty((0, 2, 2), dtype=np.float64) edges_to_draw = np.empty((0, 2, 2), dtype=np.float64)
if len(edges_to_draw) > 0: target_edges = edges_to_draw if len(edges_to_draw) > 0 else edges_2d
min_x = float(edges_to_draw[:, :, 0].min()) if len(target_edges) > 0:
max_x = float(edges_to_draw[:, :, 0].max()) pts_2d = target_edges.reshape(-1, 2)
min_y = float(edges_to_draw[:, :, 1].min()) min_x, max_x = float(pts_2d[:, 0].min()), float(pts_2d[:, 0].max())
max_y = float(edges_to_draw[:, :, 1].max()) min_y, max_y = float(pts_2d[:, 1].min()), float(pts_2d[:, 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())
else: else:
min_x = max_x = min_y = max_y = 0.0 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 h_geom = max_y - min_y
center_x = (min_x + max_x) / 2.0 center_x = (min_x + max_x) / 2.0
center_y = (min_y + max_y) / 2.0 center_y = (min_y + max_y) / 2.0
main_step, minor_step = compute_grid_steps(w_geom, h_geom)
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
if len(edges_to_draw) > 0 or len(edges_2d) > 0: if len(edges_to_draw) > 0 or len(edges_2d) > 0:
pad_x = minor_step pad_x = minor_step
@ -207,17 +198,9 @@ def export_mesh_to_pdf(
page_w_pt = w_total_mm * MM_TO_PT page_w_pt = w_total_mm * MM_TO_PT
page_h_pt = h_total_mm * MM_TO_PT page_h_pt = h_total_mm * MM_TO_PT
# Automatic sizing based on minor grid line_width_pt, font_size_pt = compute_effective_stroke_and_font(
if line_width_mm <= 0: line_width_mm, font_size_mm, minor_step
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
def to_pdf_pt(pt_mm: NDArray[np.float64]) -> NDArray[np.float64]: 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 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, main_step=main_step,
to_pdf_pt=to_pdf_pt, to_pdf_pt=to_pdf_pt,
font_size_pt=font_size_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) # 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("/GS100 gs")
stream_cmds.append("0.000 0.000 0.000 RG") stream_cmds.append("0.000 0.000 0.000 RG")
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_start_mm = grid_min_x
x_grid_end_mm = min(grid_max_x, grid_min_x + 10.0 * main_step) 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)) 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] x_end_pt = p_end_pt[0, 0]
y_bar_pt = p_start_pt[0, 1] y_bar_pt = p_start_pt[0, 1]
# Vertical Y-axis extension: 5 minor grid lines up from origin # Vertical Y-axis extension dynamically sized past tick height
y_grid_top_mm = grid_min_y + 5.0 * minor_step 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]])) p_top_pt = to_pdf_pt(np.array([[grid_min_x, y_grid_top_mm]]))
y_top_pt = p_top_pt[0, 1] y_top_pt = p_top_pt[0, 1]
# Smart arrowhead scaling based on line_width_pt and font size arrow_len_pt = leg_sizing.arrow_len_pt
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_half_w_pt = leg_sizing.arrow_half_w_pt
arrow_len_pt = max(3.5 * MM_TO_PT, line_width_pt * 4.5)
arrow_half_w_pt = arrow_len_pt * 0.40
# Exact same stroke thickness as object wireframe lines # Exact same stroke thickness as object wireframe lines
stream_cmds.append(f"{line_width_pt:.2f} w") stream_cmds.append(f"{line_width_pt:.2f} w")
stream_cmds.append("1 j 1 J") 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) # 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 + 5.0 * minor_step 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]])) 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_base_pt = p_arrow_base_pt[0, 0]
x_arrow_tip_pt = x_arrow_base_pt + arrow_len_pt 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 # 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") 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( stream_cmds.append(
f"{x_arrow_base_pt:.2f} {y_bar_pt + arrow_half_w_pt:.2f} m " 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_tip_pt:.2f} {y_bar_pt:.2f} l "
f"{x_arrow_base_pt:.2f} {y_bar_pt - arrow_half_w_pt:.2f} l S" 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 = [] tick_cmds = []
for k in range(num_main_steps + 1): for k in range(num_main_steps + 1):
xk_mm = x_grid_start_mm + k * main_step xk_mm = x_grid_start_mm + k * main_step
pk_pt = to_pdf_pt(np.array([[xk_mm, grid_min_y]])) pk_pt = to_pdf_pt(np.array([[xk_mm, grid_min_y]]))
xk_pt = pk_pt[0, 0] 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") 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.extend(tick_cmds)
stream_cmds.append("S") 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_base_pt = y_top_pt
y_arrow_tip_pt = y_arrow_base_pt + arrow_len_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") 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( stream_cmds.append(
f"{x_start_pt - arrow_half_w_pt:.2f} {y_arrow_base_pt:.2f} m " 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 " 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 xk_mm = x_grid_start_mm + k * main_step
pk_pt = to_pdf_pt(np.array([[xk_mm, grid_min_y]])) pk_pt = to_pdf_pt(np.array([[xk_mm, grid_min_y]]))
xk_pt = pk_pt[0, 0] 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 val_mm = k * main_step
if main_step >= 1000.0: if main_step >= 1000.0:

View file

@ -40,6 +40,16 @@ This file provides a mapping of modules to their primary responsibilities and ke
- `geometry.py`: - `geometry.py`:
- Geometry utility functions. - 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) ## `/infra` (Blender Integration)
- `blender_mesh_capture.py`: - `blender_mesh_capture.py`:

View file

@ -47,6 +47,7 @@ from NodeSlicer.core.pdf_exporter import (
extract_unique_top_view_edges, extract_unique_top_view_edges,
extract_lineart_edges, extract_lineart_edges,
extract_outer_contour_edges, extract_outer_contour_edges,
extract_slicing_object_features,
export_wireframe_to_pdf, export_wireframe_to_pdf,
export_mesh_to_pdf, export_mesh_to_pdf,
MM_TO_PT 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 # Contour should exclude the internal diagonal edge and return only 4 outer boundary edges
self.assertEqual(len(contour), 4) 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): def test_export_pdf_true_1_to_1_scale(self):
edges_2d = np.array([ edges_2d = np.array([
[[0.0, 0.0], [100.0, 0.0]], [[0.0, 0.0], [100.0, 0.0]],
@ -736,6 +752,57 @@ class TestPdfExporter(unittest.TestCase):
self.assertIn("LeagueMono-Regular", content) self.assertIn("LeagueMono-Regular", content)
self.assertIn("/Subtype /OpenType", 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__': if __name__ == '__main__':
unittest.main() unittest.main()

49
tests/test_sizing.py Normal file
View file

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