From 26100a962439948252a75d040340914d061ac4e1 Mon Sep 17 00:00:00 2001 From: Antoine Date: Mon, 16 Feb 2026 17:20:28 +0000 Subject: [PATCH] =?UTF-8?q?feat(adaptive-isogrid):=20extract=5Fsandbox.py?= =?UTF-8?q?=20v2=20-=20NX=20journal=20compatible,=20no=20argparse,=20sim?= =?UTF-8?q?=E2=86=92idealized=20navigation,=20listing=20window=20output?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/adaptive-isogrid/src/nx/__init__.py | 0 .../src/nx/extract_sandbox.py | 438 ++++++++++++++++++ 2 files changed, 438 insertions(+) create mode 100644 tools/adaptive-isogrid/src/nx/__init__.py create mode 100644 tools/adaptive-isogrid/src/nx/extract_sandbox.py diff --git a/tools/adaptive-isogrid/src/nx/__init__.py b/tools/adaptive-isogrid/src/nx/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tools/adaptive-isogrid/src/nx/extract_sandbox.py b/tools/adaptive-isogrid/src/nx/extract_sandbox.py new file mode 100644 index 00000000..0afc2577 --- /dev/null +++ b/tools/adaptive-isogrid/src/nx/extract_sandbox.py @@ -0,0 +1,438 @@ +""" +NXOpen script — Extract sandbox face geometry for Adaptive Isogrid. + +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.) + - transform: 3D <-> 2D mapping for reimporting geometry + - thickness: from NX midsurface (if available) + +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. + +Usage (NX Journal — just run it, no args needed): + File > Execute > NX Journal > extract_sandbox.py + +Author: Atomizer / Adaptive Isogrid +Created: 2026-02-16 +""" + +from __future__ import annotations + +import json +import math +import os +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Sequence, Tuple + +# --------------------------------------------------------------------------- +# Geometry helpers (pure math, no NX dependency) +# --------------------------------------------------------------------------- + +Point3D = Tuple[float, float, float] +Point2D = Tuple[float, float] + + +@dataclass +class LocalFrame: + origin: Point3D + x_axis: Point3D + y_axis: Point3D + normal: Point3D + + +def _norm(v: Sequence[float]) -> float: + return math.sqrt(sum(c * c for c in v)) + + +def _normalize(v: Sequence[float]) -> Tuple[float, float, float]: + n = _norm(v) + if n < 1e-12: + return (0.0, 0.0, 1.0) + return (v[0] / n, v[1] / n, v[2] / n) + + +def _dot(a: Sequence[float], b: Sequence[float]) -> float: + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + + +def _cross(a: Sequence[float], b: Sequence[float]) -> Tuple[float, float, float]: + return ( + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ) + + +def _sub(a: Sequence[float], b: Sequence[float]) -> Tuple[float, float, float]: + return (a[0] - b[0], a[1] - b[1], a[2] - b[2]) + + +def project_to_2d(points3d: Sequence[Point3D], frame: LocalFrame) -> List[Point2D]: + out: List[Point2D] = [] + for p in points3d: + v = _sub(p, frame.origin) + out.append((_dot(v, frame.x_axis), _dot(v, frame.y_axis))) + return out + + +def unproject_to_3d(points2d: Sequence[Point2D], frame: LocalFrame) -> List[Point3D]: + """Inverse of project_to_2d — reconstruct 3D from local 2D coords.""" + out: List[Point3D] = [] + for x, y in points2d: + px = frame.origin[0] + x * frame.x_axis[0] + y * frame.y_axis[0] + py = frame.origin[1] + x * frame.x_axis[1] + y * frame.y_axis[1] + pz = frame.origin[2] + x * frame.x_axis[2] + y * frame.y_axis[2] + out.append((px, py, pz)) + return out + + +# --------------------------------------------------------------------------- +# NX edge sampling +# --------------------------------------------------------------------------- + +def _sample_edge_polyline(edge: Any, chord_tol_mm: float) -> List[Point3D]: + """ + Sample an NX edge as a polyline with adaptive point density. + Falls back to vertex extraction if evaluator is unavailable. + """ + # Preferred: parametric evaluator + try: + evaluator = edge.CreateEvaluator() + t0, t1 = evaluator.GetLimits() + length = edge.GetLength() + n = max(2, int(length / max(chord_tol_mm, 1e-3))) + pts: List[Point3D] = [] + for i in range(n + 1): + t = t0 + (t1 - t0) * (i / n) + p, _ = evaluator.Evaluate(t) + pts.append((float(p.X), float(p.Y), float(p.Z))) + return pts + except Exception: + pass + + # Fallback: edge vertices only + try: + verts = edge.GetVertices() + return [(float(v.Coordinates.X), float(v.Coordinates.Y), float(v.Coordinates.Z)) for v in verts] + except Exception as exc: + raise RuntimeError(f"Could not sample edge polyline: {exc}") + + +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 + + +# --------------------------------------------------------------------------- +# Face local frame +# --------------------------------------------------------------------------- + +def _face_local_frame(face: Any) -> LocalFrame: + """ + Build a stable local frame on a planar face. + """ + loops = face.GetLoops() + first_edge = loops[0].GetEdges()[0] + sample = _sample_edge_polyline(first_edge, chord_tol_mm=1.0)[0] + + # Get face normal + normal = (0.0, 0.0, 1.0) + try: + import NXOpen + pt = NXOpen.Point3d(sample[0], sample[1], sample[2]) + n = face.GetFaceNormal(pt) + normal = _normalize((float(n.X), float(n.Y), float(n.Z))) + except Exception: + try: + n = face.GetFaceNormal(sample[0], sample[1], sample[2]) + normal = _normalize((float(n.X), float(n.Y), float(n.Z))) + except Exception: + pass + + # Build orthonormal basis + ref = (1.0, 0.0, 0.0) if abs(normal[0]) < 0.95 else (0.0, 1.0, 0.0) + x_axis = _normalize(_cross(ref, normal)) + y_axis = _normalize(_cross(normal, x_axis)) + return LocalFrame(origin=sample, x_axis=x_axis, y_axis=y_axis, normal=normal) + + +# --------------------------------------------------------------------------- +# Attribute reading +# --------------------------------------------------------------------------- + +def _get_string_attribute(obj: Any, title: str) -> str | None: + """Try multiple NX API patterns to read a string attribute.""" + for method_name in ("GetStringUserAttribute", "GetUserAttributeAsString"): + try: + method = getattr(obj, method_name) + val = method(title, -1) + if val: + return str(val) + except Exception: + continue + return None + + +# --------------------------------------------------------------------------- +# SIM -> Idealized Part navigation +# --------------------------------------------------------------------------- + +def _navigate_sim_to_idealized(session: Any) -> Any: + """ + From the active .sim work part, navigate to the idealized part (_i.prt). + Sets idealized part as work part and returns it. + """ + work_part = session.Parts.Work + part_name = work_part.Name if hasattr(work_part, "Name") else "" + + lister = session.ListingWindow + lister.Open() + lister.WriteLine(f"[extract_sandbox] Starting from: {part_name}") + + # Check if already in idealized part + if part_name.endswith("_i"): + lister.WriteLine("[extract_sandbox] Already in idealized part.") + return work_part + + # Search loaded parts for the idealized part + idealized_part = None + for part in session.Parts: + pname = part.Name if hasattr(part, "Name") else "" + if pname.endswith("_i"): + idealized_part = part + lister.WriteLine(f"[extract_sandbox] Found idealized part: {pname}") + break + + if idealized_part is None: + raise RuntimeError( + "Could not find idealized part (*_i.prt). " + "Ensure the SIM is open with FEM + idealized part loaded." + ) + + # Set as work part + try: + session.Parts.SetWork(idealized_part) + lister.WriteLine(f"[extract_sandbox] Set work part to: {idealized_part.Name}") + except Exception as exc: + lister.WriteLine(f"[extract_sandbox] Warning: SetWork failed: {exc}") + + return idealized_part + + +# --------------------------------------------------------------------------- +# Sandbox discovery +# --------------------------------------------------------------------------- + +def find_sandbox_bodies( + part: Any, + lister: Any, + attr_name: str = "ISOGRID_SANDBOX", +) -> List[Tuple[str, Any, Any]]: + """ + Find bodies tagged with ISOGRID_SANDBOX attribute. + Returns list of (sandbox_id, body, face) tuples. + """ + tagged: List[Tuple[str, Any, Any]] = [] + + bodies = [] + try: + bodies = list(part.Bodies.ToArray()) if hasattr(part.Bodies, "ToArray") else list(part.Bodies) + except Exception: + bodies = list(part.Bodies) + + lister.WriteLine(f"[extract_sandbox] Scanning {len(bodies)} bodies...") + + for body in bodies: + body_name = "" + try: + body_name = body.Name if hasattr(body, "Name") else str(body) + except Exception: + pass + + # Check body-level attribute + sandbox_id = _get_string_attribute(body, attr_name) + if sandbox_id: + faces = body.GetFaces() + if faces: + tagged.append((sandbox_id, body, faces[0])) + lister.WriteLine(f"[extract_sandbox] Found: {sandbox_id} (body attr on '{body_name}', {len(faces)} face(s))") + continue + + # Check face-level attribute + for face in body.GetFaces(): + sandbox_id = _get_string_attribute(face, attr_name) + if sandbox_id: + tagged.append((sandbox_id, body, face)) + lister.WriteLine(f"[extract_sandbox] Found: {sandbox_id} (face attr on '{body_name}')") + + # Fallback: body name matching + if not tagged: + lister.WriteLine("[extract_sandbox] No attributes found, trying name matching...") + for body in bodies: + bname = "" + try: + bname = body.Name if hasattr(body, "Name") else str(body) + except Exception: + continue + if "sandbox" in bname.lower(): + faces = body.GetFaces() + if faces: + sid = bname.lower().replace(" ", "_") + tagged.append((sid, body, faces[0])) + lister.WriteLine(f"[extract_sandbox] Found by name: {sid}") + + return tagged + + +# --------------------------------------------------------------------------- +# Core extraction +# --------------------------------------------------------------------------- + +def extract_sandbox_geometry( + face: Any, + body: Any, + sandbox_id: str, + lister: Any, + chord_tol_mm: float = 0.1, +) -> Dict[str, Any]: + """ + Extract a sandbox face into a JSON-serializable dict. + Inner loops are boundary constraints (reserved geometry edges), not holes. + """ + frame = _face_local_frame(face) + + outer_2d: List[List[float]] = [] + inner_boundaries: List[Dict[str, Any]] = [] + + loops = face.GetLoops() + lister.WriteLine(f"[extract_sandbox] {sandbox_id}: {len(loops)} loop(s)") + + for loop_index, loop in enumerate(loops): + loop_pts3d: List[Point3D] = [] + edges = loop.GetEdges() + for edge in edges: + pts = _sample_edge_polyline(edge, chord_tol_mm) + if loop_pts3d and pts: + pts = pts[1:] + loop_pts3d.extend(pts) + + loop_pts3d = _close_polyline(loop_pts3d) + loop_pts2d = project_to_2d(loop_pts3d, frame) + + # Determine outer vs inner + is_outer = False + try: + is_outer = loop.IsOuter() + except Exception: + is_outer = (loop_index == 0) + + 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") + 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), + }) + lister.WriteLine(f"[extract_sandbox] inner loop {len(inner_boundaries)}: {len(boundary)} pts") + + # Try thickness + thickness = None + try: + thickness = float(body.GetThickness()) + except Exception: + pass + + return { + "schema_version": "1.0", + "units": "mm", + "sandbox_id": sandbox_id, + "outer_boundary": outer_2d, + "inner_boundaries": inner_boundaries, + "num_inner_boundaries": len(inner_boundaries), + "thickness": thickness, + "transform": { + "origin": [round(c, 6) for c in frame.origin], + "x_axis": [round(c, 6) for c in frame.x_axis], + "y_axis": [round(c, 6) for c in frame.y_axis], + "normal": [round(c, 6) for c in frame.normal], + }, + } + + +# --------------------------------------------------------------------------- +# Main — NX Journal entry point +# --------------------------------------------------------------------------- + +def main(): + import NXOpen + + session = NXOpen.Session.GetSession() + lister = session.ListingWindow + lister.Open() + + lister.WriteLine("=" * 60) + lister.WriteLine(" Adaptive Isogrid — Sandbox Geometry Extraction") + lister.WriteLine("=" * 60) + + # Navigate to idealized part + idealized_part = _navigate_sim_to_idealized(session) + + # Find sandboxes + sandbox_entries = find_sandbox_bodies(idealized_part, lister) + if not sandbox_entries: + lister.WriteLine("[extract_sandbox] ERROR: No sandbox bodies found!") + lister.WriteLine("Ensure bodies have ISOGRID_SANDBOX attribute set.") + return + + lister.WriteLine(f"[extract_sandbox] Found {len(sandbox_entries)} sandbox(es)") + + # Output directory: next to the .sim file (or idealized part) + try: + part_dir = os.path.dirname(idealized_part.FullPath) + except Exception: + part_dir = os.getcwd() + + output_dir = os.path.join(part_dir, "adaptive_isogrid_data") + os.makedirs(output_dir, exist_ok=True) + lister.WriteLine(f"[extract_sandbox] Output dir: {output_dir}") + + # Extract each sandbox + for sandbox_id, body, face in sandbox_entries: + lister.WriteLine(f"\n--- Extracting {sandbox_id} ---") + geom = extract_sandbox_geometry( + face=face, + body=body, + sandbox_id=sandbox_id, + lister=lister, + chord_tol_mm=0.1, + ) + + out_path = os.path.join(output_dir, f"geometry_{sandbox_id}.json") + with open(out_path, "w") as f: + json.dump(geom, f, indent=2) + lister.WriteLine(f"[extract_sandbox] Wrote: {out_path}") + + # Summary + lister.WriteLine(f" outer_boundary: {len(geom['outer_boundary'])} points") + lister.WriteLine(f" inner_boundaries: {geom['num_inner_boundaries']}") + lister.WriteLine(f" thickness: {geom['thickness']}") + + lister.WriteLine("\n" + "=" * 60) + lister.WriteLine(f" Done — {len(sandbox_entries)} sandbox(es) exported") + lister.WriteLine(f" Output: {output_dir}") + lister.WriteLine("=" * 60) + + +main()