diff --git a/tools/adaptive-isogrid/src/nx/extract_sandbox.py b/tools/adaptive-isogrid/src/nx/extract_sandbox.py index d4eab4e9..2f76d2bf 100644 --- a/tools/adaptive-isogrid/src/nx/extract_sandbox.py +++ b/tools/adaptive-isogrid/src/nx/extract_sandbox.py @@ -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):