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:
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
- 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 <path>] [--chord-tol <mm>]
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()