feat(adaptive-isogrid): extract_sandbox.py v2 - NX journal compatible, no argparse, sim→idealized navigation, listing window output

This commit is contained in:
2026-02-16 17:20:28 +00:00
parent ed6874092f
commit 26100a9624
2 changed files with 438 additions and 0 deletions

View File

@@ -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_<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
- 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()