Fix NX curved edge sampling with robust UF_EVAL parsing

This commit is contained in:
2026-02-17 01:24:55 +00:00
parent 20d035205a
commit 89e0ffbbf2

View File

@@ -100,15 +100,36 @@ def _sample_edge_polyline(edge: Any, chord_tol_mm: float, lister: Any = None) ->
"""
Sample an NX edge as a polyline.
Uses Edge.GetVertices() which returns (start_point, end_point).
For curved edges (arcs), we subdivide using the edge length and
IBaseCurve evaluation if available.
NXOpen Python API on Edge:
- GetVertices() -> Tuple[Point3d, Point3d] (start, end)
- GetLength() -> float (inherited from IBaseCurve)
- SolidEdgeType -> Edge.EdgeType
Strategy (in order):
1) Linear edges -> vertices only
2) UF_EVAL sampling (robust parsing for NX Python return variants)
3) IBaseCurve.Evaluate fallback (if available)
4) UF arc analytic fallback for circular edges
5) Last resort -> vertices only
"""
def _log(msg: str) -> None:
if lister:
lister.WriteLine(msg)
def _parse_eval_point(result: Any) -> Point3D | None:
"""Parse NX UF_EVAL/IBaseCurve return variants into a 3D point."""
# Direct NXOpen.Point3d-like object
if hasattr(result, "X") and hasattr(result, "Y") and hasattr(result, "Z"):
return (float(result.X), float(result.Y), float(result.Z))
# Flat numeric array [x,y,z,...]
if isinstance(result, (list, tuple)):
if len(result) >= 3 and all(isinstance(v, (int, float)) for v in result[:3]):
return (float(result[0]), float(result[1]), float(result[2]))
# Nested tuple patterns, e.g. (point,), (point, deriv), etc.
for item in result:
p = _parse_eval_point(item)
if p is not None:
return p
return None
# Get start and end vertices
try:
v1, v2 = edge.GetVertices()
@@ -117,10 +138,10 @@ def _sample_edge_polyline(edge: Any, chord_tol_mm: float, lister: Any = None) ->
except Exception as exc:
raise RuntimeError(f"Edge.GetVertices() failed: {exc}")
# Check edge type
is_linear = False
is_circular = False
is_closed = (_norm(_sub(p1, p2)) < 0.001) # closed edge = start == end
is_closed = (_norm(_sub(p1, p2)) < 0.001)
edge_type_str = "?"
try:
edge_type_str = str(edge.SolidEdgeType)
@@ -129,95 +150,172 @@ def _sample_edge_polyline(edge: Any, chord_tol_mm: float, lister: Any = None) ->
except Exception:
pass
if lister and (is_closed or is_circular):
lister.WriteLine(f"[edge] type={edge_type_str if 'edge_type_str' in dir() else '?'} closed={is_closed} circ={is_circular} p1={p1}")
# Point density driven by chord_tol_mm (tighter tol => more points)
try:
length = float(edge.GetLength())
except Exception:
length = _norm(_sub(p2, p1)) if not is_closed else 50.0
tol = max(float(chord_tol_mm), 0.01)
n_pts = max(8, int(math.ceil(length / tol)))
if is_circular or is_closed:
n_pts = max(24, n_pts)
if is_linear and not is_closed:
return [p1, p2]
# For curved/closed edges: try UF_EVAL for parametric evaluation
_log(
f"[edge] type={edge_type_str} closed={is_closed} circular={is_circular} "
f"len={length:.3f} tol={tol:.3f} n_pts={n_pts}"
)
# 1) Primary: UF_EVAL sampling
try:
import NXOpen
session = NXOpen.Session.GetSession()
uf = session.GetUFSession()
edge_tag = edge.Tag
import NXOpen.UF
# Get edge length for point density
try:
length = edge.GetLength()
except Exception:
length = _norm(_sub(p2, p1)) if not is_closed else 50.0 # estimate
n_pts = max(8, int(length / max(chord_tol_mm, 0.1)))
if is_closed:
n_pts = max(24, n_pts) # circles need enough points
# UF_EVAL approach
evaluator = uf.Eval.Initialize2(edge_tag)
uf = NXOpen.UF.UFSession.GetUFSession()
evaluator = uf.Eval.Initialize2(edge.Tag)
limits = uf.Eval.AskLimits(evaluator)
t0 = limits[0]
t1 = limits[1]
t0, t1 = float(limits[0]), float(limits[1])
pts: List[Point3D] = []
for i in range(n_pts + 1):
t = t0 + (t1 - t0) * (i / n_pts)
# UF_EVAL_evaluate returns (point[3], derivatives[3], ...)
# The output format depends on the derivative order requested
result = uf.Eval.Evaluate(evaluator, 0, t)
# Try different result formats
if isinstance(result, (list, tuple)):
if len(result) >= 3:
pts.append((float(result[0]), float(result[1]), float(result[2])))
elif len(result) == 1 and hasattr(result[0], '__len__'):
r = result[0]
pts.append((float(r[0]), float(r[1]), float(r[2])))
elif hasattr(result, 'X'):
pts.append((float(result.X), float(result.Y), float(result.Z)))
uf.Eval.Free(evaluator)
parse_failures = 0
try:
for i in range(n_pts + 1):
t = t0 + (t1 - t0) * (i / n_pts)
result = uf.Eval.Evaluate(evaluator, 0, t)
p = _parse_eval_point(result)
if p is None:
parse_failures += 1
if parse_failures <= 3:
_log(f"[edge] UF_EVAL parse miss at t={t:.6g}, raw={repr(result)}")
continue
pts.append(p)
finally:
try:
uf.Eval.Free(evaluator)
except Exception:
pass
if len(pts) >= 2:
_log(f"[edge] sampled via UF_EVAL ({len(pts)} pts)")
return pts
except Exception:
pass
# Fallback for circular closed edges: try to get arc data from UF
if is_circular and is_closed:
_log(f"[edge] UF_EVAL insufficient points ({len(pts)}), falling back")
except Exception as exc:
_log(f"[edge] UF_EVAL failed: {exc}")
# 2) Fallback: IBaseCurve.Evaluate (signature differs by NX versions)
try:
pts: List[Point3D] = []
# Some NX APIs expose parameter limits directly on curve objects.
t0 = 0.0
t1 = 1.0
for lim_name in ("GetLimits", "AskLimits"):
lim_fn = getattr(edge, lim_name, None)
if callable(lim_fn):
try:
lim = lim_fn()
if isinstance(lim, (list, tuple)) and len(lim) >= 2:
t0, t1 = float(lim[0]), float(lim[1])
break
except Exception:
pass
for i in range(n_pts + 1):
t = t0 + (t1 - t0) * (i / n_pts)
result = edge.Evaluate(t)
p = _parse_eval_point(result)
if p is not None:
pts.append(p)
if len(pts) >= 2:
_log(f"[edge] sampled via IBaseCurve.Evaluate ({len(pts)} pts)")
return pts
_log(f"[edge] IBaseCurve.Evaluate insufficient points ({len(pts)})")
except Exception as exc:
_log(f"[edge] IBaseCurve.Evaluate failed: {exc}")
# 3) Circular analytic fallback using UF arc data
if is_circular:
try:
import NXOpen
session = NXOpen.Session.GetSession()
uf = session.GetUFSession()
import NXOpen.UF
# UF_CURVE_ask_arc_data returns (arc_center, radius, angles...)
uf = NXOpen.UF.UFSession.GetUFSession()
arc_data = uf.Curve.AskArcData(edge.Tag)
# arc_data typically: (matrix_tag, start_angle, end_angle, center[3], radius)
# or it could be structured differently
# Generate circle points manually if we can extract center + radius
except Exception:
pass
# Robust extraction from varying arc_data layouts
center = None
radius = None
start_angle = None
end_angle = None
# Fallback for closed edges: generate a small circle around the vertex
# This is wrong geometrically but at least provides a visual marker
if is_closed:
try:
length = edge.GetLength()
radius = length / (2.0 * math.pi)
# Generate circle in XY plane around vertex
# (will be projected to 2D later, so orientation matters)
pts = []
n = 24
for i in range(n + 1):
angle = 2.0 * math.pi * i / n
px = p1[0] + radius * math.cos(angle)
py = p1[1] + radius * math.sin(angle)
pz = p1[2]
pts.append((px, py, pz))
return pts
except Exception:
pass
if hasattr(arc_data, "arc_center") and hasattr(arc_data, "radius"):
c = arc_data.arc_center
center = (float(c[0]), float(c[1]), float(c[2]))
radius = float(arc_data.radius)
start_angle = float(getattr(arc_data, "start_angle", 0.0))
end_angle = float(getattr(arc_data, "end_angle", 2.0 * math.pi))
elif isinstance(arc_data, (list, tuple)):
# Look for center candidate [x,y,z] and a scalar radius
for item in arc_data:
if center is None and isinstance(item, (list, tuple)) and len(item) >= 3:
if all(isinstance(v, (int, float)) for v in item[:3]):
center = (float(item[0]), float(item[1]), float(item[2]))
if radius is None and isinstance(item, (int, float)) and abs(float(item)) > 1e-9:
# Keep first non-zero scalar as probable radius only if still missing
radius = float(item) if radius is None else radius
# Last resort: vertices only
nums = [float(x) for x in arc_data if isinstance(x, (int, float))]
if len(nums) >= 2:
start_angle = nums[-2]
end_angle = nums[-1]
if center is not None and radius is not None and radius > 0.0:
# Build local basis in edge plane from endpoints + center
r1 = _sub(p1, center)
r2 = _sub(p2, center)
if _norm(r1) < 1e-9:
r1 = (radius, 0.0, 0.0)
x_axis = _normalize(r1)
normal = _normalize(_cross(r1, r2)) if _norm(_cross(r1, r2)) > 1e-9 else (0.0, 0.0, 1.0)
y_axis = _normalize(_cross(normal, x_axis))
a0 = 0.0
a1 = math.atan2(_dot(r2, y_axis), _dot(r2, x_axis))
if is_closed and abs(a1) < 1e-9:
a1 = 2.0 * math.pi
elif a1 <= 0.0:
a1 += 2.0 * math.pi
# If UF supplied angles, prefer them when they look valid
if start_angle is not None and end_angle is not None:
da = end_angle - start_angle
if abs(da) > 1e-9:
a0, a1 = start_angle, end_angle
pts = []
for i in range(n_pts + 1):
a = a0 + (a1 - a0) * (i / n_pts)
ca, sa = math.cos(a), math.sin(a)
px = center[0] + radius * (ca * x_axis[0] + sa * y_axis[0])
py = center[1] + radius * (ca * x_axis[1] + sa * y_axis[1])
pz = center[2] + radius * (ca * x_axis[2] + sa * y_axis[2])
pts.append((px, py, pz))
if len(pts) >= 2:
_log(f"[edge] sampled via UF arc analytic ({len(pts)} pts)")
return pts
_log(f"[edge] UF arc fallback could not decode arc_data: {repr(arc_data)}")
except Exception as exc:
_log(f"[edge] UF arc fallback failed: {exc}")
_log("[edge] fallback to vertices only")
return [p1, p2]
@@ -237,6 +335,7 @@ def _chain_edges_into_loops(
edges: List[Any],
lister: Any = None,
tol: float = 0.01,
chord_tol_mm: float = 0.1,
) -> List[Tuple[bool, List[Point3D]]]:
"""
Chain edges into closed loops by matching vertex endpoints.
@@ -290,7 +389,7 @@ def _chain_edges_into_loops(
p_start, p_end, edge = segments[start_idx]
# Sample this edge
edge_pts = _sample_edge_polyline(edge, chord_tol_mm=0.5, lister=lister)
edge_pts = _sample_edge_polyline(edge, chord_tol_mm=chord_tol_mm, lister=lister)
chain_pts.extend(edge_pts)
chain_edges.append(edge)
@@ -311,7 +410,7 @@ def _chain_edges_into_loops(
continue
if pts_match(current_end, s1):
used[i] = True
edge_pts = _sample_edge_polyline(e, chord_tol_mm=0.5, lister=lister)
edge_pts = _sample_edge_polyline(e, chord_tol_mm=chord_tol_mm, lister=lister)
chain_pts.extend(edge_pts[1:]) # skip duplicate junction point
chain_edges.append(e)
current_end = s2
@@ -320,7 +419,7 @@ def _chain_edges_into_loops(
elif pts_match(current_end, s2):
# Edge is reversed — traverse backward
used[i] = True
edge_pts = _sample_edge_polyline(e, chord_tol_mm=0.5, lister=lister)
edge_pts = _sample_edge_polyline(e, chord_tol_mm=chord_tol_mm, lister=lister)
edge_pts.reverse()
chain_pts.extend(edge_pts[1:])
chain_edges.append(e)
@@ -621,7 +720,7 @@ def extract_sandbox_geometry(
all_edges = list(face.GetEdges())
lister.WriteLine(f"[extract_sandbox] {sandbox_id}: {len(all_edges)} edges on face")
loops = _chain_edges_into_loops(all_edges, lister)
loops = _chain_edges_into_loops(all_edges, lister, chord_tol_mm=chord_tol_mm)
lister.WriteLine(f"[extract_sandbox] {sandbox_id}: {len(loops)} loop(s) built")
for loop_index, (is_outer, loop_pts3d) in enumerate(loops):