diff --git a/tools/adaptive-isogrid/src/nx/extract_sandbox.py b/tools/adaptive-isogrid/src/nx/extract_sandbox.py index 72b7209b..0afc2577 100644 --- a/tools/adaptive-isogrid/src/nx/extract_sandbox.py +++ b/tools/adaptive-isogrid/src/nx/extract_sandbox.py @@ -1,20 +1,20 @@ """ -NXOpen script — Extract sandbox sheet geometry for Adaptive Isogrid. +NXOpen script — Extract sandbox face geometry for Adaptive Isogrid. Runs from the .sim file context. Navigates: - SIM → FEM → Idealized Part → find sheet bodies with ISOGRID_SANDBOX attribute + SIM → FEM → Idealized Part → find bodies with ISOGRID_SANDBOX attribute -For each sandbox sheet body, exports `geometry_.json` containing: +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 + - 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): - run_journal.exe extract_sandbox.py [--output-dir ] [--chord-tol ] +Usage (NX Journal — just run it, no args needed): + File > Execute > NX Journal > extract_sandbox.py Author: Atomizer / Adaptive Isogrid Created: 2026-02-16 @@ -22,9 +22,10 @@ Created: 2026-02-16 from __future__ import annotations -import argparse import json import math +import os +import sys from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, List, Sequence, Tuple @@ -137,10 +138,7 @@ def _close_polyline(points: List[Point3D]) -> List[Point3D]: def _face_local_frame(face: Any) -> LocalFrame: """ - Build a stable local frame on a planar face: - - origin: first loop first point - - normal: face normal at origin - - x/y: orthonormal tangent basis + Build a stable local frame on a planar face. """ loops = face.GetLoops() first_edge = loops[0].GetEdges()[0] @@ -149,7 +147,7 @@ def _face_local_frame(face: Any) -> LocalFrame: # Get face normal normal = (0.0, 0.0, 1.0) try: - import NXOpen # noqa + 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))) @@ -185,95 +183,63 @@ def _get_string_attribute(obj: Any, title: str) -> str | None: # --------------------------------------------------------------------------- -# SIM → FEM → Idealized Part navigation +# SIM -> Idealized Part navigation # --------------------------------------------------------------------------- def _navigate_sim_to_idealized(session: Any) -> Any: """ - From the active .sim work part, navigate to the idealized part. - - Path: SIM → FEM component → idealized part (_i.prt) - - Returns the idealized part (NXOpen.BasePart), and sets it as work part. + From the active .sim work part, navigate to the idealized part (_i.prt). + Sets idealized part as work part and returns it. """ - import NXOpen.CAE # noqa - work_part = session.Parts.Work part_name = work_part.Name if hasattr(work_part, "Name") else "" - part_path = work_part.FullPath if hasattr(work_part, "FullPath") else "" - print(f"[extract_sandbox] Starting from: {part_name}") - print(f"[extract_sandbox] Full path: {part_path}") + lister = session.ListingWindow + lister.Open() + lister.WriteLine(f"[extract_sandbox] Starting from: {part_name}") - # Check if we're already in an idealized part - if "_i" in part_name.lower() or part_name.endswith("_i"): - print("[extract_sandbox] Already in idealized part.") + # Check if already in idealized part + if part_name.endswith("_i"): + lister.WriteLine("[extract_sandbox] Already in idealized part.") return work_part - # If in .sim, navigate to FEM then to idealized part - # Strategy: iterate through loaded parts to find the _i.prt + # Search loaded parts for the idealized part idealized_part = None - fem_part = None - for part in session.Parts: pname = part.Name if hasattr(part, "Name") else "" - ppath = "" - try: - ppath = part.FullPath - except Exception: - pass - - if "_i" in pname and pname.endswith("_i"): - # Found idealized part candidate + if pname.endswith("_i"): idealized_part = part - print(f"[extract_sandbox] Found idealized part: {pname}") - elif "_fem" in pname.lower() and not pname.endswith("_i"): - fem_part = part - print(f"[extract_sandbox] Found FEM part: {pname}") - - if idealized_part is None: - # Try alternative: look for parts whose name contains "fem" + "_i" - for part in session.Parts: - pname = part.Name if hasattr(part, "Name") else "" - if "fem" in pname.lower() and "_i" in pname: - idealized_part = part - print(f"[extract_sandbox] Found idealized part (alt): {pname}") - break + 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 file is open and its FEM + idealized part are loaded." + "Could not find idealized part (*_i.prt). " + "Ensure the SIM is open with FEM + idealized part loaded." ) - # Set idealized part as work part + # Set as work part try: session.Parts.SetWork(idealized_part) - print(f"[extract_sandbox] Set work part to: {idealized_part.Name}") + lister.WriteLine(f"[extract_sandbox] Set work part to: {idealized_part.Name}") except Exception as exc: - print(f"[extract_sandbox] Warning: Could not set work part: {exc}") - print("[extract_sandbox] Proceeding with part reference anyway...") + lister.WriteLine(f"[extract_sandbox] Warning: SetWork failed: {exc}") return idealized_part # --------------------------------------------------------------------------- -# Sandbox discovery — find sheet bodies with ISOGRID_SANDBOX attribute +# Sandbox discovery # --------------------------------------------------------------------------- def find_sandbox_bodies( part: Any, + lister: Any, attr_name: str = "ISOGRID_SANDBOX", ) -> List[Tuple[str, Any, Any]]: """ - Find sheet bodies tagged with ISOGRID_SANDBOX attribute. - - Searches: - 1. Body-level attributes - 2. Face-level attributes (fallback) - + Find bodies tagged with ISOGRID_SANDBOX attribute. Returns list of (sandbox_id, body, face) tuples. - The face is the primary face of the sheet body. """ tagged: List[Tuple[str, Any, Any]] = [] @@ -283,29 +249,34 @@ def find_sandbox_bodies( except Exception: bodies = list(part.Bodies) + lister.WriteLine(f"[extract_sandbox] Scanning {len(bodies)} bodies...") + for body in bodies: - # Check body-level attribute first + 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: - # Sheet body — get its face(s) faces = body.GetFaces() if faces: - face = faces[0] # Sheet body typically has one face - tagged.append((sandbox_id, body, face)) - print(f"[extract_sandbox] Found sandbox body: {sandbox_id} " - f"(body attr, {len(faces)} face(s))") + 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 - # Fallback: check face-level attributes + # 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)) - print(f"[extract_sandbox] Found sandbox face: {sandbox_id} (face attr)") + lister.WriteLine(f"[extract_sandbox] Found: {sandbox_id} (face attr on '{body_name}')") + # Fallback: body name matching if not tagged: - # Last resort: try matching body names - print(f"[extract_sandbox] No attributes found, trying body name matching...") + lister.WriteLine("[extract_sandbox] No attributes found, trying name matching...") for body in bodies: bname = "" try: @@ -315,9 +286,9 @@ def find_sandbox_bodies( if "sandbox" in bname.lower(): faces = body.GetFaces() if faces: - sandbox_id = bname.lower().replace(" ", "_") - tagged.append((sandbox_id, body, faces[0])) - print(f"[extract_sandbox] Found sandbox by name: {sandbox_id}") + sid = bname.lower().replace(" ", "_") + tagged.append((sid, body, faces[0])) + lister.WriteLine(f"[extract_sandbox] Found by name: {sid}") return tagged @@ -330,15 +301,12 @@ 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 sheet face into a JSON-serializable dict. - - - outer_boundary: the sandbox outline (2D polyline) - - inner_boundaries: cutouts from reserved cylinders etc. (2D polylines) - These are boundary constraints, NOT optimization holes. - - transform: 3D ↔ 2D coordinate mapping + Extract a sandbox face into a JSON-serializable dict. + Inner loops are boundary constraints (reserved geometry edges), not holes. """ frame = _face_local_frame(face) @@ -346,16 +314,15 @@ def extract_sandbox_geometry( inner_boundaries: List[Dict[str, Any]] = [] loops = face.GetLoops() - print(f"[extract_sandbox] {sandbox_id}: {len(loops)} loop(s) on face") + lister.WriteLine(f"[extract_sandbox] {sandbox_id}: {len(loops)} loop(s)") for loop_index, loop in enumerate(loops): - # Collect all edge points for this loop 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:] # avoid duplicate at junction + pts = pts[1:] loop_pts3d.extend(pts) loop_pts3d = _close_polyline(loop_pts3d) @@ -370,7 +337,7 @@ def extract_sandbox_geometry( if is_outer: outer_2d = [[round(x, 6), round(y, 6)] for x, y in loop_pts2d] - print(f"[extract_sandbox] outer loop: {len(outer_2d)} points") + 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({ @@ -378,18 +345,16 @@ def extract_sandbox_geometry( "boundary": boundary, "num_points": len(boundary), }) - print(f"[extract_sandbox] inner loop {len(inner_boundaries)}: " - f"{len(boundary)} points") + lister.WriteLine(f"[extract_sandbox] inner loop {len(inner_boundaries)}: {len(boundary)} pts") - # Try to get thickness from body or expression + # Try thickness thickness = None try: - # NX sheet bodies may expose thickness thickness = float(body.GetThickness()) except Exception: pass - geom: Dict[str, Any] = { + return { "schema_version": "1.0", "units": "mm", "sandbox_id": sandbox_id, @@ -405,85 +370,69 @@ def extract_sandbox_geometry( }, } - return geom - # --------------------------------------------------------------------------- -# Export +# Main — NX Journal entry point # --------------------------------------------------------------------------- -def export_sandbox_geometries( - output_dir: Path, - geometries: Dict[str, Dict[str, Any]], -) -> List[Path]: - output_dir.mkdir(parents=True, exist_ok=True) - written: List[Path] = [] - for sandbox_id, payload in geometries.items(): - out = output_dir / f"geometry_{sandbox_id}.json" - out.write_text(json.dumps(payload, indent=2)) - written.append(out) - print(f"[extract_sandbox] Wrote: {out}") - return written - - -# --------------------------------------------------------------------------- -# Main entry point — runs inside NX -# --------------------------------------------------------------------------- - -def run_in_nx(output_dir: Path, chord_tol_mm: float = 0.1) -> List[Path]: - import NXOpen # type: ignore +def main(): + import NXOpen session = NXOpen.Session.GetSession() + lister = session.ListingWindow + lister.Open() - # Navigate from .sim to idealized part + 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 sandbox bodies - sandbox_entries = find_sandbox_bodies(idealized_part) + # Find sandboxes + sandbox_entries = find_sandbox_bodies(idealized_part, lister) if not sandbox_entries: - raise RuntimeError( - "No sandbox bodies found. Ensure sheet bodies have " - "the ISOGRID_SANDBOX attribute set (body or face level)." - ) + lister.WriteLine("[extract_sandbox] ERROR: No sandbox bodies found!") + lister.WriteLine("Ensure bodies have ISOGRID_SANDBOX attribute set.") + return - print(f"[extract_sandbox] Found {len(sandbox_entries)} sandbox(es)") + lister.WriteLine(f"[extract_sandbox] Found {len(sandbox_entries)} sandbox(es)") - # Extract geometry for each sandbox - payloads: Dict[str, Dict[str, Any]] = {} + # 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: - payloads[sandbox_id] = extract_sandbox_geometry( + lister.WriteLine(f"\n--- Extracting {sandbox_id} ---") + geom = extract_sandbox_geometry( face=face, body=body, sandbox_id=sandbox_id, - chord_tol_mm=chord_tol_mm, + lister=lister, + chord_tol_mm=0.1, ) - # Export - return export_sandbox_geometries(output_dir=output_dir, geometries=payloads) + 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) -def main(argv: Sequence[str] | None = None) -> int: - parser = argparse.ArgumentParser( - description="Extract NX sandbox sheet geometry to JSON (run from .sim)" - ) - parser.add_argument( - "--output-dir", default=".", - help="Directory for geometry_sandbox_*.json output files", - ) - parser.add_argument( - "--chord-tol", type=float, default=0.1, - help="Edge sampling chord tolerance in mm (default: 0.1)", - ) - args = parser.parse_args(argv) - - out_dir = Path(args.output_dir) - written = run_in_nx(output_dir=out_dir, chord_tol_mm=args.chord_tol) - - print(f"\n[extract_sandbox] Done — {len(written)} file(s) exported.") - for p in written: - print(f" → {p}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +main()