From 612a21f561816ac33efcd112d91729dcd76b6cda Mon Sep 17 00:00:00 2001 From: Antoine Date: Tue, 17 Feb 2026 01:47:36 +0000 Subject: [PATCH] feat(adaptive-isogrid): preserve arcs as typed segments instead of polyline discretization Schema v2.0: outer_boundary is now a list of typed segments: - {type: 'line', start: [x,y], end: [x,y]} - {type: 'arc', start: [x,y], end: [x,y], center: [x,y], radius: R, clockwise: bool} Extract: detect arcs via UF Eval.IsArc/AskArc, output exact geometry. Import: create NX sketch arcs (3-point) for arc segments, backward-compatible with v1.0 polylines. --- .../src/nx/extract_sandbox.py | 497 +++++++----------- .../adaptive-isogrid/src/nx/import_profile.py | 128 ++++- 2 files changed, 299 insertions(+), 326 deletions(-) diff --git a/tools/adaptive-isogrid/src/nx/extract_sandbox.py b/tools/adaptive-isogrid/src/nx/extract_sandbox.py index 038f882a..5aa1f170 100644 --- a/tools/adaptive-isogrid/src/nx/extract_sandbox.py +++ b/tools/adaptive-isogrid/src/nx/extract_sandbox.py @@ -5,11 +5,15 @@ Runs from the .sim file context. Navigates: SIM → FEM → Idealized Part → find bodies with ISOGRID_SANDBOX attribute For each sandbox body, exports `geometry_.json` containing: - - outer_boundary: 2D polyline of the sandbox outline - - inner_boundaries: 2D polylines of cutouts (reserved cylinder intersections, etc.) + - outer_boundary: list of typed segments (line or arc) preserving exact geometry + - inner_boundaries: same format for cutouts - transform: 3D <-> 2D mapping for reimporting geometry - thickness: from NX midsurface (if available) +Schema v2.0: segments are typed objects, not flat polylines. + Line: {"type": "line", "start": [x,y], "end": [x,y]} + Arc: {"type": "arc", "start": [x,y], "end": [x,y], "center": [x,y], "radius": R, "clockwise": bool} + Inner loops are treated as boundary constraints (edges), NOT as holes to rib around, because hole reservations are handled by separate solid cylinders in the fixed geometry. @@ -93,44 +97,36 @@ def unproject_to_3d(points2d: Sequence[Point2D], frame: LocalFrame) -> List[Poin # --------------------------------------------------------------------------- -# NX edge sampling +# Edge segment types # --------------------------------------------------------------------------- -def _sample_edge_polyline(edge: Any, chord_tol_mm: float, lister: Any = None) -> List[Point3D]: - """ - Sample an NX edge as a polyline. +@dataclass +class EdgeSegment: + """A typed geometry segment — either a line or an arc.""" + seg_type: str # "line" or "arc" + start_3d: Point3D + end_3d: Point3D + # Arc-specific (None for lines) + center_3d: Point3D | None = None + radius: float | None = None - 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 + +# --------------------------------------------------------------------------- +# NX edge analysis — extract type + arc parameters +# --------------------------------------------------------------------------- + +def _analyze_edge(edge: Any, lister: Any = None) -> EdgeSegment: + """ + Analyze an NX edge and return a typed EdgeSegment. + For arcs: extracts center + radius from UF. + For lines: just start/end vertices. + For unknown curves: falls back to line (vertices only) with a warning. """ 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 + # Get vertices try: v1, v2 = edge.GetVertices() p1 = (float(v1.X), float(v1.Y), float(v1.Z)) @@ -138,237 +134,106 @@ def _sample_edge_polyline(edge: Any, chord_tol_mm: float, lister: Any = None) -> except Exception as exc: raise RuntimeError(f"Edge.GetVertices() failed: {exc}") - is_linear = False - is_circular = False - is_closed = (_norm(_sub(p1, p2)) < 0.001) + # Classify edge type edge_type_str = "?" - try: edge_type_str = str(edge.SolidEdgeType) - is_linear = "Linear" in edge_type_str - is_circular = "Circular" in edge_type_str except Exception: pass - # 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 + is_linear = "Linear" in edge_type_str + is_circular = "Circular" in edge_type_str - tol = max(float(chord_tol_mm), 0.5) # 0.5mm chord tolerance — good balance - n_pts = max(8, int(math.ceil(length / tol))) - if is_circular or is_closed: - n_pts = max(24, n_pts) - # Cap to avoid absurd point counts on long straight edges - n_pts = min(n_pts, 500) + # Linear edges — simple + if is_linear: + return EdgeSegment(seg_type="line", start_3d=p1, end_3d=p2) - if is_linear and not is_closed: - return [p1, p2] - - _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 curve evaluation — try multiple API patterns + # Try UF Eval to detect arc try: import NXOpen import NXOpen.UF - uf = NXOpen.UF.UFSession.GetUFSession() - eval_obj = uf.Eval - pts: List[Point3D] = [] - parse_failures = 0 - + evaluator = None try: evaluator = eval_obj.Initialize2(edge.Tag) - limits = eval_obj.AskLimits(evaluator) - t0, t1 = float(limits[0]), float(limits[1]) - # Try arc-specific analytical approach first - is_arc_edge = False - try: - is_arc_edge = eval_obj.IsArc(evaluator) - except Exception: - pass + if eval_obj.IsArc(evaluator): + arc_data = eval_obj.AskArc(evaluator) - if is_arc_edge: - # Get arc data and generate points analytically - try: - arc_data = eval_obj.AskArc(evaluator) - # arc_data is UFEval.Arc struct with: center, radius, etc. - # Extract what we can - _log(f"[edge] Arc data type: {type(arc_data).__name__}, attrs: {[a for a in dir(arc_data) if not a.startswith('_')]}") - # Try to access fields - center = None - radius = None - for attr in ('center', 'Center', 'arc_center'): - if hasattr(arc_data, attr): - center = getattr(arc_data, attr) - break - for attr in ('radius', 'Radius'): - if hasattr(arc_data, attr): - radius = float(getattr(arc_data, attr)) - break - if center is not None and radius is not None: - _log(f"[edge] Arc: center={center}, radius={radius}, t0={t0}, t1={t1}") - except Exception as exc: - _log(f"[edge] AskArc failed: {exc}") + # Extract center and radius from arc_data + center = None + radius = None - # Use EvaluateUnitVectors for parametric sampling - # Signature: EvaluateUnitVectors(evaluator, param) → returns point + tangent + ... - for i in range(n_pts + 1): - t = t0 + (t1 - t0) * (float(i) / float(n_pts)) - try: - result = eval_obj.EvaluateUnitVectors(evaluator, t) - # Parse result — could be tuple of (point, tangent, normal, binormal) - pt = _parse_eval_point(result) - if pt is not None: - pts.append(pt) - else: - parse_failures += 1 - if parse_failures <= 2: - _log(f"[edge] Parse failed at t={t:.4f}, type={type(result).__name__}, repr={repr(result)[:300]}") - except Exception as exc: - parse_failures += 1 - if parse_failures <= 2: - _log(f"[edge] EvaluateUnitVectors failed at t={t:.4f}: {exc}") + # Try named attributes (NXOpen struct) + for attr in ('center', 'Center', 'arc_center'): + if hasattr(arc_data, attr): + c = getattr(arc_data, attr) + if hasattr(c, 'X'): + center = (float(c.X), float(c.Y), float(c.Z)) + elif isinstance(c, (list, tuple)) and len(c) >= 3: + center = (float(c[0]), float(c[1]), float(c[2])) + break + for attr in ('radius', 'Radius'): + if hasattr(arc_data, attr): + radius = float(getattr(arc_data, attr)) + break + + # If named attrs didn't work, try UF Curve API + if center is None or radius is None: + try: + curve_data = uf.Curve.AskArcData(edge.Tag) + if hasattr(curve_data, 'arc_center') and hasattr(curve_data, 'radius'): + c = curve_data.arc_center + center = (float(c[0]), float(c[1]), float(c[2])) + radius = float(curve_data.radius) + except Exception: + pass + + if center is not None and radius is not None and radius > 0.0: + _log(f"[edge] ARC: center=({center[0]:.3f},{center[1]:.3f},{center[2]:.3f}) " + f"r={radius:.3f}") + return EdgeSegment( + seg_type="arc", start_3d=p1, end_3d=p2, + center_3d=center, radius=radius, + ) + else: + _log(f"[edge] IsArc=True but could not extract center/radius. " + f"arc_data attrs: {[a for a in dir(arc_data) if not a.startswith('_')]}") finally: if evaluator is not None: try: eval_obj.Free(evaluator) except Exception: pass - - if len(pts) >= 2: - _log(f"[edge] sampled via EvaluateUnitVectors ({len(pts)} pts, {parse_failures} failures)") - return pts - - _log(f"[edge] EvaluateUnitVectors insufficient points ({len(pts)}), falling back") except Exception as exc: - _log(f"[edge] UF Eval failed: {exc}") + _log(f"[edge] UF arc detection 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 + # Fallback: try UF Curve.AskArcData directly (for circular edges not caught above) if is_circular: try: import NXOpen import NXOpen.UF - uf = NXOpen.UF.UFSession.GetUFSession() - arc_data = uf.Curve.AskArcData(edge.Tag) - - # Robust extraction from varying arc_data layouts + curve_data = uf.Curve.AskArcData(edge.Tag) center = None radius = None - start_angle = None - end_angle = None - - if hasattr(arc_data, "arc_center") and hasattr(arc_data, "radius"): - c = arc_data.arc_center + if hasattr(curve_data, 'arc_center') and hasattr(curve_data, 'radius'): + c = curve_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 - - 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] - + radius = float(curve_data.radius) 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)}") + _log(f"[edge] ARC (UF Curve fallback): r={radius:.3f}") + return EdgeSegment( + seg_type="arc", start_3d=p1, end_3d=p2, + center_3d=center, radius=radius, + ) except Exception as exc: - _log(f"[edge] UF arc fallback failed: {exc}") + _log(f"[edge] UF Curve.AskArcData fallback failed: {exc}") - _log("[edge] fallback to vertices only") - return [p1, p2] - - -def _close_polyline(points: List[Point3D]) -> List[Point3D]: - if not points: - return points - if _norm(_sub(points[0], points[-1])) > 1e-6: - points.append(points[0]) - return points + # Unknown curve type — warn and treat as line + _log(f"[edge] WARNING: Non-line/arc edge type={edge_type_str}, treating as line (vertices only)") + return EdgeSegment(seg_type="line", start_3d=p1, end_3d=p2) # --------------------------------------------------------------------------- @@ -379,13 +244,12 @@ def _chain_edges_into_loops( edges: List[Any], lister: Any = None, tol: float = 0.01, - chord_tol_mm: float = 1.0, -) -> List[Tuple[bool, List[Point3D]]]: +) -> List[Tuple[bool, List[EdgeSegment]]]: """ Chain edges into closed loops by matching vertex endpoints. - Returns list of (is_outer, points_3d) tuples. - The largest loop (by area/perimeter) is assumed to be the outer loop. + Returns list of (is_outer, segments) tuples where segments are EdgeSegment objects. + The largest loop (by perimeter) is assumed to be the outer loop. """ def _log(msg): if lister: @@ -394,30 +258,27 @@ def _chain_edges_into_loops( if not edges: return [] - # Build edge segments as (start_pt, end_pt, edge_ref) - segments = [] + # Analyze each edge into a typed segment + analyzed: List[Tuple[EdgeSegment, Any]] = [] # (segment, original_edge) for edge in edges: try: - v1, v2 = edge.GetVertices() - p1 = (float(v1.X), float(v1.Y), float(v1.Z)) - p2 = (float(v2.X), float(v2.Y), float(v2.Z)) - segments.append((p1, p2, edge)) + seg = _analyze_edge(edge, lister) + analyzed.append((seg, edge)) except Exception as exc: - _log(f"[chain] Edge.GetVertices failed: {exc}") + _log(f"[chain] Edge analysis failed: {exc}") continue - _log(f"[chain] {len(segments)} edge segments to chain") + _log(f"[chain] {len(analyzed)} edges analyzed ({sum(1 for s,_ in analyzed if s.seg_type == 'arc')} arcs, " + f"{sum(1 for s,_ in analyzed if s.seg_type == 'line')} lines)") # Chain into loops - used = [False] * len(segments) - loops_points: List[List[Point3D]] = [] - loops_edges: List[List[Any]] = [] + used = [False] * len(analyzed) + loops: List[List[EdgeSegment]] = [] def pts_match(a: Point3D, b: Point3D) -> bool: return _norm(_sub(a, b)) < tol while True: - # Find first unused segment start_idx = None for i, u in enumerate(used): if not u: @@ -426,78 +287,82 @@ def _chain_edges_into_loops( if start_idx is None: break - # Start a new loop - chain_pts: List[Point3D] = [] - chain_edges: List[Any] = [] + chain: List[EdgeSegment] = [] used[start_idx] = True - p_start, p_end, edge = segments[start_idx] + seg, _ = analyzed[start_idx] + chain.append(seg) - # Sample this edge - edge_pts = _sample_edge_polyline(edge, chord_tol_mm=chord_tol_mm, lister=lister) - chain_pts.extend(edge_pts) - chain_edges.append(edge) + current_end = seg.end_3d + loop_start = seg.start_3d - current_end = p_end - loop_start = p_start - - # Follow the chain - max_iters = len(segments) + 1 + max_iters = len(analyzed) + 1 for _ in range(max_iters): - if pts_match(current_end, loop_start) and len(chain_edges) > 1: - # Loop closed + if pts_match(current_end, loop_start) and len(chain) > 1: break - # Find next segment connecting to current_end found = False - for i, (s1, s2, e) in enumerate(segments): + for i, (s, _e) in enumerate(analyzed): if used[i]: continue - if pts_match(current_end, s1): + if pts_match(current_end, s.start_3d): used[i] = True - 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 + chain.append(s) + current_end = s.end_3d found = True break - elif pts_match(current_end, s2): - # Edge is reversed — traverse backward + elif pts_match(current_end, s.end_3d): + # Reversed edge — swap start/end used[i] = True - 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) - current_end = s1 + reversed_seg = EdgeSegment( + seg_type=s.seg_type, + start_3d=s.end_3d, + end_3d=s.start_3d, + center_3d=s.center_3d, + radius=s.radius, + ) + chain.append(reversed_seg) + current_end = s.start_3d found = True break if not found: - _log(f"[chain] Warning: could not continue chain at {current_end}") + _log(f"[chain] Warning: could not continue chain at " + f"({current_end[0]:.3f}, {current_end[1]:.3f}, {current_end[2]:.3f})") break - loops_points.append(chain_pts) - loops_edges.append(chain_edges) + loops.append(chain) - _log(f"[chain] Built {len(loops_points)} loop(s)") + _log(f"[chain] Built {len(loops)} loop(s)") - if not loops_points: + if not loops: return [] - # Determine which loop is outer (largest perimeter) - def _perimeter(pts: List[Point3D]) -> float: + # Determine outer loop by perimeter + def _loop_perimeter(segs: List[EdgeSegment]) -> float: total = 0.0 - for i in range(len(pts) - 1): - total += _norm(_sub(pts[i + 1], pts[i])) + for s in segs: + if s.seg_type == "arc" and s.center_3d is not None and s.radius is not None: + # Arc length = radius * angle + r1 = _sub(s.start_3d, s.center_3d) + r2 = _sub(s.end_3d, s.center_3d) + cos_a = max(-1.0, min(1.0, _dot(_normalize(r1), _normalize(r2)))) + angle = math.acos(cos_a) + total += s.radius * angle + else: + total += _norm(_sub(s.end_3d, s.start_3d)) return total - perimeters = [_perimeter(pts) for pts in loops_points] + perimeters = [_loop_perimeter(segs) for segs in loops] outer_idx = perimeters.index(max(perimeters)) - result: List[Tuple[bool, List[Point3D]]] = [] - for i, pts in enumerate(loops_points): + result: List[Tuple[bool, List[EdgeSegment]]] = [] + for i, segs in enumerate(loops): is_outer = (i == outer_idx) - result.append((is_outer, pts)) - _log(f"[chain] loop {i}: {len(pts)} pts, perimeter={perimeters[i]:.1f} mm {'(OUTER)' if is_outer else '(inner)'}") + n_arcs = sum(1 for s in segs if s.seg_type == "arc") + n_lines = sum(1 for s in segs if s.seg_type == "line") + result.append((is_outer, segs)) + _log(f"[chain] loop {i}: {len(segs)} segments ({n_lines} lines, {n_arcs} arcs), " + f"perimeter={perimeters[i]:.1f} mm {'(OUTER)' if is_outer else '(inner)'}") return result @@ -744,44 +609,78 @@ def find_sandbox_bodies( # Core extraction # --------------------------------------------------------------------------- +def _project_point_2d(pt3d: Point3D, frame: LocalFrame) -> Point2D: + """Project a single 3D point to local 2D.""" + v = _sub(pt3d, frame.origin) + return (_dot(v, frame.x_axis), _dot(v, frame.y_axis)) + + +def _segments_to_json(segments: List[EdgeSegment], frame: LocalFrame) -> List[Dict[str, Any]]: + """Convert a list of EdgeSegments to JSON-serializable dicts in 2D.""" + result = [] + for seg in segments: + start_2d = _project_point_2d(seg.start_3d, frame) + end_2d = _project_point_2d(seg.end_3d, frame) + entry: Dict[str, Any] = { + "type": seg.seg_type, + "start": [round(start_2d[0], 6), round(start_2d[1], 6)], + "end": [round(end_2d[0], 6), round(end_2d[1], 6)], + } + if seg.seg_type == "arc" and seg.center_3d is not None: + center_2d = _project_point_2d(seg.center_3d, frame) + entry["center"] = [round(center_2d[0], 6), round(center_2d[1], 6)] + entry["radius"] = round(seg.radius, 6) + # Determine clockwise/ccw: cross product of (start-center) × (end-center) + # projected onto the face normal + r1 = _sub(seg.start_3d, seg.center_3d) + r2 = _sub(seg.end_3d, seg.center_3d) + cross = _cross(r1, r2) + dot_normal = _dot(cross, frame.normal) + entry["clockwise"] = (dot_normal < 0) + result.append(entry) + return result + + def extract_sandbox_geometry( face: Any, body: Any, sandbox_id: str, lister: Any, - chord_tol_mm: float = 1.0, ) -> Dict[str, Any]: """ Extract a sandbox face into a JSON-serializable dict. + Schema v2.0: typed segments (line/arc) instead of polylines. Inner loops are boundary constraints (reserved geometry edges), not holes. """ frame = _face_local_frame(face, lister) - outer_2d: List[List[float]] = [] + outer_segments: List[Dict[str, Any]] = [] inner_boundaries: List[Dict[str, Any]] = [] # Get all edges on the face and chain them into loops 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, chord_tol_mm=chord_tol_mm) + loops = _chain_edges_into_loops(all_edges, lister) lister.WriteLine(f"[extract_sandbox] {sandbox_id}: {len(loops)} loop(s) built") - for loop_index, (is_outer, loop_pts3d) in enumerate(loops): - loop_pts3d = _close_polyline(loop_pts3d) - loop_pts2d = project_to_2d(loop_pts3d, frame) + for loop_index, (is_outer, loop_segs) in enumerate(loops): + seg_json = _segments_to_json(loop_segs, frame) + n_arcs = sum(1 for s in seg_json if s["type"] == "arc") + n_lines = sum(1 for s in seg_json if s["type"] == "line") if is_outer: - outer_2d = [[round(x, 6), round(y, 6)] for x, y in loop_pts2d] - lister.WriteLine(f"[extract_sandbox] outer loop: {len(outer_2d)} pts") + outer_segments = seg_json + lister.WriteLine(f"[extract_sandbox] outer loop: {len(seg_json)} segments " + f"({n_lines} lines, {n_arcs} arcs)") else: - boundary = [[round(x, 6), round(y, 6)] for x, y in loop_pts2d] inner_boundaries.append({ "index": len(inner_boundaries), - "boundary": boundary, - "num_points": len(boundary), + "segments": seg_json, + "num_segments": len(seg_json), }) - lister.WriteLine(f"[extract_sandbox] inner loop {len(inner_boundaries)}: {len(boundary)} pts") + lister.WriteLine(f"[extract_sandbox] inner loop {len(inner_boundaries)}: " + f"{len(seg_json)} segments ({n_lines} lines, {n_arcs} arcs)") # Try thickness thickness = None @@ -791,10 +690,10 @@ def extract_sandbox_geometry( pass return { - "schema_version": "1.0", + "schema_version": "2.0", "units": "mm", "sandbox_id": sandbox_id, - "outer_boundary": outer_2d, + "outer_boundary": outer_segments, "inner_boundaries": inner_boundaries, "num_inner_boundaries": len(inner_boundaries), "thickness": thickness, diff --git a/tools/adaptive-isogrid/src/nx/import_profile.py b/tools/adaptive-isogrid/src/nx/import_profile.py index deb18f98..86048082 100644 --- a/tools/adaptive-isogrid/src/nx/import_profile.py +++ b/tools/adaptive-isogrid/src/nx/import_profile.py @@ -351,31 +351,105 @@ def _arc_midpoint_2d(arc): return [cx + r * math.cos(mid_angle), cy + r * math.sin(mid_angle)] -def _draw_outer_boundary(part, outer_2d, transform, lister): - """Draw the outer boundary as a closed polyline.""" - outer_3d = unproject_to_3d(outer_2d, transform) - n = len(outer_3d) - if n < 2: - return 0 +def _draw_segment(part, seg, transform, lister): + """ + Draw a single typed segment (line or arc) in the active sketch. + Supports schema v2.0 segment dicts with "type", "start", "end", "center", "radius". + Returns ("line"|"arc", success_bool). + """ + seg_type = seg.get("type", "line") + start_3d = unproject_point_to_3d(seg["start"], transform) + end_3d = unproject_point_to_3d(seg["end"], transform) - # Strip closing duplicate - if n >= 3: - d = math.sqrt(sum((a - b) ** 2 for a, b in zip(outer_3d[0], outer_3d[-1]))) - if d < 0.001: - n -= 1 - - count = 0 - for i in range(n): - p1 = outer_3d[i] - p2 = outer_3d[(i + 1) % n] + if seg_type == "arc" and "center" in seg: + center_3d = unproject_point_to_3d(seg["center"], transform) + radius = seg["radius"] + # Compute midpoint of arc for 3-point arc creation + cx, cy = seg["center"] + sx, sy = seg["start"] + ex, ey = seg["end"] + # Angles from center + sa = math.atan2(sy - cy, sx - cx) + ea = math.atan2(ey - cy, ex - cx) + clockwise = seg.get("clockwise", False) + # Compute mid-angle + if clockwise: + # CW: sa → ea going clockwise (decreasing angle) + da = sa - ea + if da <= 0: + da += 2 * math.pi + mid_angle = sa - da / 2.0 + else: + # CCW: sa → ea going counter-clockwise (increasing angle) + da = ea - sa + if da <= 0: + da += 2 * math.pi + mid_angle = sa + da / 2.0 + mid_2d = [cx + radius * math.cos(mid_angle), cy + radius * math.sin(mid_angle)] + mid_3d = unproject_point_to_3d(mid_2d, transform) try: - _draw_line(part, p1, p2) - count += 1 + _draw_arc_3pt(part, start_3d, mid_3d, end_3d) + return ("arc", True) except Exception as exc: - if count == 0: - lister.WriteLine(f"[import] ERROR: First line failed: {exc}") - return 0 - return count + lister.WriteLine(f"[import] Arc failed, fallback to line: {exc}") + # Fallback: draw as line + try: + _draw_line(part, start_3d, end_3d) + return ("line", True) + except Exception: + return ("line", False) + else: + try: + _draw_line(part, start_3d, end_3d) + return ("line", True) + except Exception: + return ("line", False) + + +def _draw_outer_boundary(part, outer_boundary, transform, lister): + """ + Draw the outer boundary. Handles both: + - Schema v2.0: list of typed segment dicts + - Schema v1.0: list of [x,y] points (polyline) + Returns (num_lines, num_arcs). + """ + if not outer_boundary: + return 0, 0 + + # Detect schema version: v2.0 segments are dicts with "type" key + if isinstance(outer_boundary[0], dict) and "type" in outer_boundary[0]: + # Schema v2.0 — typed segments + n_lines = 0 + n_arcs = 0 + for seg in outer_boundary: + kind, ok = _draw_segment(part, seg, transform, lister) + if ok: + if kind == "arc": + n_arcs += 1 + else: + n_lines += 1 + return n_lines, n_arcs + else: + # Schema v1.0 — flat polyline points + outer_3d = unproject_to_3d(outer_boundary, transform) + n = len(outer_3d) + if n < 2: + return 0, 0 + # Strip closing duplicate + if n >= 3: + d = math.sqrt(sum((a - b) ** 2 for a, b in zip(outer_3d[0], outer_3d[-1]))) + if d < 0.001: + n -= 1 + count = 0 + for i in range(n): + try: + _draw_line(part, outer_3d[i], outer_3d[(i + 1) % n]) + count += 1 + except Exception as exc: + if count == 0: + lister.WriteLine(f"[import] ERROR: First line failed: {exc}") + return 0, 0 + return count, 0 def _draw_structured_pocket(part, pocket, transform, lister): @@ -812,13 +886,13 @@ def main(): if is_structured: lister.WriteLine(f"[import] Structured format: {len(pockets)} pockets + outer boundary") - # Outer boundary - outer_lines = _draw_outer_boundary(work_part, outer_2d, transform, lister) - lister.WriteLine(f"[import] Outer boundary: {outer_lines} lines ({len(outer_2d)} pts)") + # Outer boundary (handles both v1.0 polyline and v2.0 typed segments) + outer_lines, outer_arcs = _draw_outer_boundary(work_part, outer_2d, transform, lister) + lister.WriteLine(f"[import] Outer boundary: {outer_lines} lines + {outer_arcs} arcs") # Pockets total_lines = outer_lines - total_arcs = 0 + total_arcs = outer_arcs for idx, pocket in enumerate(pockets): nl, na = _draw_structured_pocket(work_part, pocket, transform, lister) total_lines += nl @@ -831,7 +905,7 @@ def main(): else: # Legacy format: pockets are point lists lister.WriteLine(f"[import] Legacy format: {len(pockets)} pocket polylines") - outer_lines = _draw_outer_boundary(work_part, outer_2d, transform, lister) + outer_lines, outer_arcs = _draw_outer_boundary(work_part, outer_2d, transform, lister) total_lines = outer_lines for pocket_pts in pockets: if len(pocket_pts) < 3: