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

View file

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

View file

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

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,
)
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:

View file

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

View file

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

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)