feat(adaptive-isogrid): rewrite extract_sandbox.py - start from .sim, navigate to idealized part, find sandbox solid bodies by ISOGRID_SANDBOX attribute, inner loops as boundary constraints

This commit is contained in:
2026-02-16 17:07:26 +00:00
parent fa9193b809
commit bb83bb9cab

View File

@@ -1,11 +1,23 @@
"""
NXOpen script — extract sandbox face geometry for Adaptive Isogrid.
NXOpen script — Extract sandbox sheet geometry for Adaptive Isogrid.
Finds faces tagged with user attribute:
ISOGRID_SANDBOX = sandbox_1, sandbox_2, ...
Runs from the .sim file context. Navigates:
SIM → FEM → Idealized Part → find sheet bodies with ISOGRID_SANDBOX attribute
For each sandbox face, exports `geometry_<sandbox_id>.json` in the same schema
expected by the Python Brain (`outer_boundary`, `holes`, transform metadata, etc.).
For each sandbox sheet 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):
run_journal.exe extract_sandbox.py [--output-dir <path>] [--chord-tol <mm>]
Author: Atomizer / Adaptive Isogrid
Created: 2026-02-16
"""
from __future__ import annotations
@@ -15,8 +27,11 @@ import json
import math
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, List, Sequence, Tuple
from typing import Any, Dict, List, Sequence, Tuple
# ---------------------------------------------------------------------------
# Geometry helpers (pure math, no NX dependency)
# ---------------------------------------------------------------------------
Point3D = Tuple[float, float, float]
Point2D = Tuple[float, float]
@@ -57,60 +72,6 @@ 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 fit_circle(points: Sequence[Point2D]) -> Tuple[Point2D, float, float]:
"""
Least-squares circle fit.
Returns (center, diameter, rms_error).
"""
if len(points) < 3:
return ((0.0, 0.0), 0.0, float("inf"))
sx = sy = sxx = syy = sxy = 0.0
sxxx = syyy = sxxy = sxyy = 0.0
for x, y in points:
xx = x * x
yy = y * y
sx += x
sy += y
sxx += xx
syy += yy
sxy += x * y
sxxx += xx * x
syyy += yy * y
sxxy += xx * y
sxyy += x * yy
n = float(len(points))
c = n * sxx - sx * sx
d = n * sxy - sx * sy
e = n * (sxxx + sxyy) - (sxx + syy) * sx
g = n * syy - sy * sy
h = n * (sxxy + syyy) - (sxx + syy) * sy
denom = (c * g - d * d)
if abs(denom) < 1e-12:
return ((0.0, 0.0), 0.0, float("inf"))
a = (h * d - e * g) / denom
b = (h * c - e * d) / (d * d - g * c)
cx = -a / 2.0
cy = -b / 2.0
r = math.sqrt(max((a * a + b * b) / 4.0 - (sx * sx + sy * sy - n * (sxx + syy)) / n, 0.0))
errs = []
for x, y in points:
errs.append(abs(math.hypot(x - cx, y - cy) - r))
rms = math.sqrt(sum(e * e for e in errs) / len(errs)) if errs else float("inf")
return ((cx, cy), 2.0 * r, rms)
def is_loop_circular(points2d: Sequence[Point2D], tol_mm: float = 0.5) -> Tuple[bool, Point2D, float]:
center, dia, rms = fit_circle(points2d)
return (rms <= tol_mm, center, dia)
def project_to_2d(points3d: Sequence[Point3D], frame: LocalFrame) -> List[Point2D]:
out: List[Point2D] = []
for p in points3d:
@@ -119,20 +80,27 @@ def project_to_2d(points3d: Sequence[Point3D], frame: LocalFrame) -> List[Point2
return out
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
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.
NOTE: NX APIs vary by curve type; this helper intentionally keeps fallback logic.
Sample an NX edge as a polyline with adaptive point density.
Falls back to vertex extraction if evaluator is unavailable.
"""
# Preferred path: use evaluator where available.
# Preferred: parametric evaluator
try:
evaluator = edge.CreateEvaluator()
t0, t1 = evaluator.GetLimits()
@@ -147,80 +115,253 @@ def _sample_edge_polyline(edge: Any, chord_tol_mm: float) -> List[Point3D]:
except Exception:
pass
# Fallback: edge vertices only (less accurate, but safe fallback).
# Fallback: edge vertices only
try:
verts = edge.GetVertices()
pts = []
for v in verts:
p = v.Coordinates
pts.append((float(p.X), float(p.Y), float(p.Z)))
return pts
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 face:
Build a stable local frame on a planar face:
- origin: first loop first point
- normal: face normal near origin
- x/y axes: orthonormal basis on tangent plane
- normal: face normal at origin
- x/y: orthonormal tangent basis
"""
loops = face.GetLoops()
first_edge = loops[0].GetEdges()[0]
sample = _sample_edge_polyline(first_edge, chord_tol_mm=1.0)[0]
# Try direct normal from face API.
# Get face normal
normal = (0.0, 0.0, 1.0)
try:
n = face.GetFaceNormal(sample[0], sample[1], sample[2])
import NXOpen # noqa
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:
pass
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:
return obj.GetStringUserAttribute(title, -1)
except Exception:
pass
try:
return obj.GetUserAttributeAsString(title, -1)
except Exception:
return 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
def find_sandbox_faces(work_part: Any, attr_name: str = "ISOGRID_SANDBOX") -> List[Tuple[str, Any]]:
tagged: List[Tuple[str, Any]] = []
for body in getattr(work_part.Bodies, "ToArray", lambda: work_part.Bodies)():
# ---------------------------------------------------------------------------
# SIM → FEM → 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.
"""
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}")
# 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.")
return work_part
# If in .sim, navigate to FEM then to idealized part
# Strategy: iterate through loaded parts to find the _i.prt
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
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
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."
)
# Set idealized part as work part
try:
session.Parts.SetWork(idealized_part)
print(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...")
return idealized_part
# ---------------------------------------------------------------------------
# Sandbox discovery — find sheet bodies with ISOGRID_SANDBOX attribute
# ---------------------------------------------------------------------------
def find_sandbox_bodies(
part: 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)
Returns list of (sandbox_id, body, face) tuples.
The face is the primary face of the sheet body.
"""
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)
for body in bodies:
# Check body-level attribute first
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))")
continue
# Fallback: check face-level attributes
for face in body.GetFaces():
sandbox_id = _get_string_attribute(face, attr_name)
if sandbox_id and sandbox_id.startswith("sandbox_"):
tagged.append((sandbox_id, face))
if sandbox_id:
tagged.append((sandbox_id, body, face))
print(f"[extract_sandbox] Found sandbox face: {sandbox_id} (face attr)")
if not tagged:
# Last resort: try matching body names
print(f"[extract_sandbox] No attributes found, trying body 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:
sandbox_id = bname.lower().replace(" ", "_")
tagged.append((sandbox_id, body, faces[0]))
print(f"[extract_sandbox] Found sandbox by name: {sandbox_id}")
return tagged
def _extract_face_loops(face: Any, chord_tol_mm: float, frame: LocalFrame) -> Tuple[List[Point2D], List[Dict[str, Any]]]:
outer_2d: List[Point2D] = []
holes: List[Dict[str, Any]] = []
# ---------------------------------------------------------------------------
# Core extraction
# ---------------------------------------------------------------------------
def extract_sandbox_geometry(
face: Any,
body: Any,
sandbox_id: str,
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
"""
frame = _face_local_frame(face)
outer_2d: List[List[float]] = []
inner_boundaries: List[Dict[str, Any]] = []
loops = face.GetLoops()
print(f"[extract_sandbox] {sandbox_id}: {len(loops)} loop(s) on face")
for loop_index, loop in enumerate(loops):
# Collect all edge points for this loop
loop_pts3d: List[Point3D] = []
for edge in loop.GetEdges():
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 joining point
pts = pts[1:] # avoid duplicate at junction
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()
@@ -228,89 +369,119 @@ def _extract_face_loops(face: Any, chord_tol_mm: float, frame: LocalFrame) -> Tu
is_outer = (loop_index == 0)
if is_outer:
outer_2d = loop_pts2d
continue
outer_2d = [[round(x, 6), round(y, 6)] for x, y in loop_pts2d]
print(f"[extract_sandbox] outer loop: {len(outer_2d)} points")
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),
})
print(f"[extract_sandbox] inner loop {len(inner_boundaries)}: "
f"{len(boundary)} points")
is_circ, center, diameter = is_loop_circular(loop_pts2d)
holes.append(
{
"index": len(holes),
"boundary": [[x, y] for x, y in loop_pts2d],
"center": [center[0], center[1]] if is_circ else None,
"diameter": diameter if is_circ else None,
"is_circular": bool(is_circ),
"weight": 0.0,
}
)
return outer_2d, holes
def extract_sandbox_geometry(face: Any, sandbox_id: str, chord_tol_mm: float = 0.1) -> Dict[str, Any]:
frame = _face_local_frame(face)
outer, holes = _extract_face_loops(face, chord_tol_mm=chord_tol_mm, frame=frame)
geom = {
"units": "mm",
"sandbox_id": sandbox_id,
"outer_boundary": [[x, y] for x, y in outer],
"holes": holes,
"transform": {
"origin": list(frame.origin),
"x_axis": list(frame.x_axis),
"y_axis": list(frame.y_axis),
"normal": list(frame.normal),
},
}
# Optional thickness hint if available.
# Try to get thickness from body or expression
thickness = None
try:
geom["thickness"] = float(face.GetBody().GetThickness())
# NX sheet bodies may expose thickness
thickness = float(body.GetThickness())
except Exception:
pass
geom: Dict[str, Any] = {
"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],
},
}
return geom
def export_sandbox_geometries(output_dir: Path, geometries: Dict[str, Dict[str, Any]]) -> List[Path]:
# ---------------------------------------------------------------------------
# Export
# ---------------------------------------------------------------------------
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
session = NXOpen.Session.GetSession()
work_part = session.Parts.Work
if work_part is None:
raise RuntimeError("No active NX work part.")
sandbox_faces = find_sandbox_faces(work_part)
if not sandbox_faces:
raise RuntimeError("No faces found with ISOGRID_SANDBOX attribute.")
# Navigate from .sim to idealized part
idealized_part = _navigate_sim_to_idealized(session)
# Find sandbox bodies
sandbox_entries = find_sandbox_bodies(idealized_part)
if not sandbox_entries:
raise RuntimeError(
"No sandbox bodies found. Ensure sheet bodies have "
"the ISOGRID_SANDBOX attribute set (body or face level)."
)
print(f"[extract_sandbox] Found {len(sandbox_entries)} sandbox(es)")
# Extract geometry for each sandbox
payloads: Dict[str, Dict[str, Any]] = {}
for sandbox_id, face in sandbox_faces:
payloads[sandbox_id] = extract_sandbox_geometry(face, sandbox_id, chord_tol_mm=chord_tol_mm)
for sandbox_id, body, face in sandbox_entries:
payloads[sandbox_id] = extract_sandbox_geometry(
face=face,
body=body,
sandbox_id=sandbox_id,
chord_tol_mm=chord_tol_mm,
)
# Export
return export_sandbox_geometries(output_dir=output_dir, geometries=payloads)
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Extract NX sandbox face geometry to JSON")
parser.add_argument("--output-dir", default=".", help="Directory for geometry_sandbox_*.json")
parser.add_argument("--chord-tol", type=float, default=0.1, help="Edge sampling chord tolerance (mm)")
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"[extract_sandbox] wrote: {p}")
print(f" {p}")
return 0