merge: take remote extract_sandbox.py v2

This commit is contained in:
2026-02-16 12:22:56 -05:00

View File

@@ -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: 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_<sandbox_id>.json` containing: For each sandbox body, exports `geometry_<sandbox_id>.json` containing:
- outer_boundary: 2D polyline of the sandbox outline - outer_boundary: 2D polyline of the sandbox outline
- inner_boundaries: 2D polylines of cutouts (reserved cylinder intersections, etc.) - 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) - thickness: from NX midsurface (if available)
Inner loops are treated as boundary constraints (edges), NOT as holes to rib around, 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. because hole reservations are handled by separate solid cylinders in the fixed geometry.
Usage (NX Journal): Usage (NX Journal — just run it, no args needed):
run_journal.exe extract_sandbox.py [--output-dir <path>] [--chord-tol <mm>] File > Execute > NX Journal > extract_sandbox.py
Author: Atomizer / Adaptive Isogrid Author: Atomizer / Adaptive Isogrid
Created: 2026-02-16 Created: 2026-02-16
@@ -22,9 +22,10 @@ Created: 2026-02-16
from __future__ import annotations from __future__ import annotations
import argparse
import json import json
import math import math
import os
import sys
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Sequence, Tuple 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: def _face_local_frame(face: Any) -> LocalFrame:
""" """
Build a stable local frame on a planar face: Build a stable local frame on a planar face.
- origin: first loop first point
- normal: face normal at origin
- x/y: orthonormal tangent basis
""" """
loops = face.GetLoops() loops = face.GetLoops()
first_edge = loops[0].GetEdges()[0] first_edge = loops[0].GetEdges()[0]
@@ -149,7 +147,7 @@ def _face_local_frame(face: Any) -> LocalFrame:
# Get face normal # Get face normal
normal = (0.0, 0.0, 1.0) normal = (0.0, 0.0, 1.0)
try: try:
import NXOpen # noqa import NXOpen
pt = NXOpen.Point3d(sample[0], sample[1], sample[2]) pt = NXOpen.Point3d(sample[0], sample[1], sample[2])
n = face.GetFaceNormal(pt) n = face.GetFaceNormal(pt)
normal = _normalize((float(n.X), float(n.Y), float(n.Z))) 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: def _navigate_sim_to_idealized(session: Any) -> Any:
""" """
From the active .sim work part, navigate to the idealized part. From the active .sim work part, navigate to the idealized part (_i.prt).
Sets idealized part as work part and returns it.
Path: SIM → FEM component → idealized part (_i.prt)
Returns the idealized part (NXOpen.BasePart), and sets it as work part.
""" """
import NXOpen.CAE # noqa
work_part = session.Parts.Work work_part = session.Parts.Work
part_name = work_part.Name if hasattr(work_part, "Name") else "" 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}") lister = session.ListingWindow
print(f"[extract_sandbox] Full path: {part_path}") lister.Open()
lister.WriteLine(f"[extract_sandbox] Starting from: {part_name}")
# Check if we're already in an idealized part # Check if already in idealized part
if "_i" in part_name.lower() or part_name.endswith("_i"): if part_name.endswith("_i"):
print("[extract_sandbox] Already in idealized part.") lister.WriteLine("[extract_sandbox] Already in idealized part.")
return work_part return work_part
# If in .sim, navigate to FEM then to idealized part # Search loaded parts for the idealized part
# Strategy: iterate through loaded parts to find the _i.prt
idealized_part = None idealized_part = None
fem_part = None
for part in session.Parts: for part in session.Parts:
pname = part.Name if hasattr(part, "Name") else "" pname = part.Name if hasattr(part, "Name") else ""
ppath = "" if pname.endswith("_i"):
try:
ppath = part.FullPath
except Exception:
pass
if "_i" in pname and pname.endswith("_i"):
# Found idealized part candidate
idealized_part = part idealized_part = part
print(f"[extract_sandbox] Found idealized part: {pname}") lister.WriteLine(f"[extract_sandbox] Found idealized part: {pname}")
elif "_fem" in pname.lower() and not pname.endswith("_i"): break
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
if idealized_part is None: if idealized_part is None:
raise RuntimeError( raise RuntimeError(
"Could not find idealized part (_i.prt). " "Could not find idealized part (*_i.prt). "
"Ensure the SIM file is open and its FEM + idealized part are loaded." "Ensure the SIM is open with FEM + idealized part loaded."
) )
# Set idealized part as work part # Set as work part
try: try:
session.Parts.SetWork(idealized_part) 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: except Exception as exc:
print(f"[extract_sandbox] Warning: Could not set work part: {exc}") lister.WriteLine(f"[extract_sandbox] Warning: SetWork failed: {exc}")
print("[extract_sandbox] Proceeding with part reference anyway...")
return idealized_part return idealized_part
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Sandbox discovery — find sheet bodies with ISOGRID_SANDBOX attribute # Sandbox discovery
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def find_sandbox_bodies( def find_sandbox_bodies(
part: Any, part: Any,
lister: Any,
attr_name: str = "ISOGRID_SANDBOX", attr_name: str = "ISOGRID_SANDBOX",
) -> List[Tuple[str, Any, Any]]: ) -> List[Tuple[str, Any, Any]]:
""" """
Find sheet bodies tagged with ISOGRID_SANDBOX attribute. Find bodies tagged with ISOGRID_SANDBOX attribute.
Searches:
1. Body-level attributes
2. Face-level attributes (fallback)
Returns list of (sandbox_id, body, face) tuples. Returns list of (sandbox_id, body, face) tuples.
The face is the primary face of the sheet body.
""" """
tagged: List[Tuple[str, Any, Any]] = [] tagged: List[Tuple[str, Any, Any]] = []
@@ -283,29 +249,34 @@ def find_sandbox_bodies(
except Exception: except Exception:
bodies = list(part.Bodies) bodies = list(part.Bodies)
lister.WriteLine(f"[extract_sandbox] Scanning {len(bodies)} bodies...")
for body in 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) sandbox_id = _get_string_attribute(body, attr_name)
if sandbox_id: if sandbox_id:
# Sheet body — get its face(s)
faces = body.GetFaces() faces = body.GetFaces()
if faces: if faces:
face = faces[0] # Sheet body typically has one face tagged.append((sandbox_id, body, faces[0]))
tagged.append((sandbox_id, body, face)) lister.WriteLine(f"[extract_sandbox] Found: {sandbox_id} (body attr on '{body_name}', {len(faces)} face(s))")
print(f"[extract_sandbox] Found sandbox body: {sandbox_id} "
f"(body attr, {len(faces)} face(s))")
continue continue
# Fallback: check face-level attributes # Check face-level attribute
for face in body.GetFaces(): for face in body.GetFaces():
sandbox_id = _get_string_attribute(face, attr_name) sandbox_id = _get_string_attribute(face, attr_name)
if sandbox_id: if sandbox_id:
tagged.append((sandbox_id, body, face)) 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: if not tagged:
# Last resort: try matching body names lister.WriteLine("[extract_sandbox] No attributes found, trying name matching...")
print(f"[extract_sandbox] No attributes found, trying body name matching...")
for body in bodies: for body in bodies:
bname = "" bname = ""
try: try:
@@ -315,9 +286,9 @@ def find_sandbox_bodies(
if "sandbox" in bname.lower(): if "sandbox" in bname.lower():
faces = body.GetFaces() faces = body.GetFaces()
if faces: if faces:
sandbox_id = bname.lower().replace(" ", "_") sid = bname.lower().replace(" ", "_")
tagged.append((sandbox_id, body, faces[0])) tagged.append((sid, body, faces[0]))
print(f"[extract_sandbox] Found sandbox by name: {sandbox_id}") lister.WriteLine(f"[extract_sandbox] Found by name: {sid}")
return tagged return tagged
@@ -330,15 +301,12 @@ def extract_sandbox_geometry(
face: Any, face: Any,
body: Any, body: Any,
sandbox_id: str, sandbox_id: str,
lister: Any,
chord_tol_mm: float = 0.1, chord_tol_mm: float = 0.1,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Extract a sandbox sheet face into a JSON-serializable dict. Extract a sandbox face into a JSON-serializable dict.
Inner loops are boundary constraints (reserved geometry edges), not holes.
- 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
""" """
frame = _face_local_frame(face) frame = _face_local_frame(face)
@@ -346,16 +314,15 @@ def extract_sandbox_geometry(
inner_boundaries: List[Dict[str, Any]] = [] inner_boundaries: List[Dict[str, Any]] = []
loops = face.GetLoops() 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): for loop_index, loop in enumerate(loops):
# Collect all edge points for this loop
loop_pts3d: List[Point3D] = [] loop_pts3d: List[Point3D] = []
edges = loop.GetEdges() edges = loop.GetEdges()
for edge in edges: for edge in edges:
pts = _sample_edge_polyline(edge, chord_tol_mm) pts = _sample_edge_polyline(edge, chord_tol_mm)
if loop_pts3d and pts: if loop_pts3d and pts:
pts = pts[1:] # avoid duplicate at junction pts = pts[1:]
loop_pts3d.extend(pts) loop_pts3d.extend(pts)
loop_pts3d = _close_polyline(loop_pts3d) loop_pts3d = _close_polyline(loop_pts3d)
@@ -370,7 +337,7 @@ def extract_sandbox_geometry(
if is_outer: if is_outer:
outer_2d = [[round(x, 6), round(y, 6)] for x, y in loop_pts2d] 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: else:
boundary = [[round(x, 6), round(y, 6)] for x, y in loop_pts2d] boundary = [[round(x, 6), round(y, 6)] for x, y in loop_pts2d]
inner_boundaries.append({ inner_boundaries.append({
@@ -378,18 +345,16 @@ def extract_sandbox_geometry(
"boundary": boundary, "boundary": boundary,
"num_points": len(boundary), "num_points": len(boundary),
}) })
print(f"[extract_sandbox] inner loop {len(inner_boundaries)}: " lister.WriteLine(f"[extract_sandbox] inner loop {len(inner_boundaries)}: {len(boundary)} pts")
f"{len(boundary)} points")
# Try to get thickness from body or expression # Try thickness
thickness = None thickness = None
try: try:
# NX sheet bodies may expose thickness
thickness = float(body.GetThickness()) thickness = float(body.GetThickness())
except Exception: except Exception:
pass pass
geom: Dict[str, Any] = { return {
"schema_version": "1.0", "schema_version": "1.0",
"units": "mm", "units": "mm",
"sandbox_id": sandbox_id, "sandbox_id": sandbox_id,
@@ -405,85 +370,69 @@ def extract_sandbox_geometry(
}, },
} }
return geom
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Export # Main — NX Journal entry point
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def export_sandbox_geometries( def main():
output_dir: Path, import NXOpen
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
session = NXOpen.Session.GetSession() 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) idealized_part = _navigate_sim_to_idealized(session)
# Find sandbox bodies # Find sandboxes
sandbox_entries = find_sandbox_bodies(idealized_part) sandbox_entries = find_sandbox_bodies(idealized_part, lister)
if not sandbox_entries: if not sandbox_entries:
raise RuntimeError( lister.WriteLine("[extract_sandbox] ERROR: No sandbox bodies found!")
"No sandbox bodies found. Ensure sheet bodies have " lister.WriteLine("Ensure bodies have ISOGRID_SANDBOX attribute set.")
"the ISOGRID_SANDBOX attribute set (body or face level)." 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 # Output directory: next to the .sim file (or idealized part)
payloads: Dict[str, Dict[str, Any]] = {} 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: 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, face=face,
body=body, body=body,
sandbox_id=sandbox_id, sandbox_id=sandbox_id,
chord_tol_mm=chord_tol_mm, lister=lister,
chord_tol_mm=0.1,
) )
# Export out_path = os.path.join(output_dir, f"geometry_{sandbox_id}.json")
return export_sandbox_geometries(output_dir=output_dir, geometries=payloads) 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: main()
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())