|
| 1 | +"""ASTERION FCTA-1 native Siemens NX builder. |
| 2 | +
|
| 3 | +Run INSIDE Siemens NX through Developer/Tools > Journal > Play. |
| 4 | +
|
| 5 | +Outputs native NX .prt files for: |
| 6 | + * individual imported facet-body parts; |
| 7 | + * the top-level assembly (NX assemblies also use .prt); |
| 8 | + * separate master-model drawing parts with A3 sheets and base views. |
| 9 | +
|
| 10 | +The source files are STL meshes. The resulting NX parts contain convergent/facet |
| 11 | +bodies, not recovered parametric feature history. Rebuild critical parts from the |
| 12 | +provided dimensions and tutorials when editable design intent is required. |
| 13 | +
|
| 14 | +Tested here only by static validation because Siemens NX is not installed in the |
| 15 | +artifact-generation environment. The script uses long-established NXOpen APIs |
| 16 | +and writes a detailed build log. API availability and licences can vary by NX |
| 17 | +release and installation. |
| 18 | +""" |
| 19 | +from __future__ import annotations |
| 20 | + |
| 21 | +import csv |
| 22 | +import os |
| 23 | +import sys |
| 24 | +import subprocess |
| 25 | +import traceback |
| 26 | +from datetime import datetime |
| 27 | +from pathlib import Path |
| 28 | + |
| 29 | + |
| 30 | +def _package_root() -> Path: |
| 31 | + return Path(__file__).resolve().parents[1] |
| 32 | + |
| 33 | + |
| 34 | +PACKAGE_ROOT = Path(os.environ.get("ASTERION_BUILDER_ROOT", str(_package_root()))).resolve() |
| 35 | +SOURCE_ROOT = PACKAGE_ROOT |
| 36 | +OUTPUT_ROOT = Path( |
| 37 | + os.environ.get("ASTERION_NX_OUTPUT", str(PACKAGE_ROOT / "native_output" / "NX_NATIVE")) |
| 38 | +).resolve() |
| 39 | +OVERWRITE = os.environ.get("ASTERION_OVERWRITE", "0").strip() in {"1", "true", "TRUE", "yes", "YES"} |
| 40 | + |
| 41 | +COMPONENT_MANIFEST = PACKAGE_ROOT / "config" / "nx_component_manifest.csv" |
| 42 | +DRAWING_MANIFEST = PACKAGE_ROOT / "config" / "drawing_manifest.csv" |
| 43 | +LOG_PATH = OUTPUT_ROOT / "ASTERION_NX_BUILD_LOG.csv" |
| 44 | + |
| 45 | + |
| 46 | +def _outside_nx_help() -> str: |
| 47 | + return ( |
| 48 | + "NXOpen is provided by Siemens NX and is not available in ordinary Python.\n" |
| 49 | + "Do not run this file with python.exe, VS Code Run Python File, or IDLE.\n\n" |
| 50 | + "Use one of these supported launch methods:\n" |
| 51 | + " 1. Double-click RUN_ASTERION_BUILDER.bat in the package root.\n" |
| 52 | + " 2. In Siemens NX: Developer/Tools > Journal > Play, then select this file.\n" |
| 53 | + " 3. From an NX Command Prompt: run_journal.exe <this-script>.\n\n" |
| 54 | + "Do not install the unrelated PyPI package named nxopen; it is not the Siemens NX API." |
| 55 | + ) |
| 56 | + |
| 57 | + |
| 58 | +def _try_relaunch_through_nx() -> bool: |
| 59 | + """Relaunch this journal through Siemens NX when started by normal Python. |
| 60 | +
|
| 61 | + Returns True after a launcher was invoked. The launcher performs NX discovery, |
| 62 | + sets ASTERION paths, and calls Siemens run_journal.exe. |
| 63 | + """ |
| 64 | + if os.name != "nt" or os.environ.get("ASTERION_NX_RELAUNCHED") == "1": |
| 65 | + return False |
| 66 | + |
| 67 | + launcher = PACKAGE_ROOT / "nxopen" / "run_asterion_builder.ps1" |
| 68 | + if not launcher.is_file(): |
| 69 | + return False |
| 70 | + |
| 71 | + powershell = os.environ.get("SystemRoot", r"C:\Windows") + r"\System32\WindowsPowerShell\v1.0\powershell.exe" |
| 72 | + if not Path(powershell).is_file(): |
| 73 | + powershell = "powershell.exe" |
| 74 | + |
| 75 | + command = [ |
| 76 | + powershell, |
| 77 | + "-NoProfile", |
| 78 | + "-ExecutionPolicy", |
| 79 | + "Bypass", |
| 80 | + "-File", |
| 81 | + str(launcher), |
| 82 | + ] |
| 83 | + if OVERWRITE: |
| 84 | + command.append("-Overwrite") |
| 85 | + |
| 86 | + print("NXOpen was not found in this Python interpreter.") |
| 87 | + print("Attempting to relaunch the builder through Siemens NX...") |
| 88 | + completed = subprocess.run(command, cwd=str(PACKAGE_ROOT), check=False) |
| 89 | + if completed.returncode != 0: |
| 90 | + raise SystemExit( |
| 91 | + f"The Siemens NX launcher returned exit code {completed.returncode}.\n\n" |
| 92 | + + _outside_nx_help() |
| 93 | + ) |
| 94 | + return True |
| 95 | + |
| 96 | + |
| 97 | +class BuildFailure(RuntimeError): |
| 98 | + pass |
| 99 | + |
| 100 | + |
| 101 | +def _read_csv(path: Path) -> list[dict[str, str]]: |
| 102 | + with path.open("r", newline="", encoding="utf-8-sig") as handle: |
| 103 | + return list(csv.DictReader(handle)) |
| 104 | + |
| 105 | + |
| 106 | +def _safe_remove(path: Path) -> None: |
| 107 | + if path.exists(): |
| 108 | + if not OVERWRITE: |
| 109 | + raise BuildFailure(f"Output already exists: {path}. Set ASTERION_OVERWRITE=1 to replace it.") |
| 110 | + path.unlink() |
| 111 | + |
| 112 | + |
| 113 | +def _identity_matrix(NXOpen): |
| 114 | + matrix = NXOpen.Matrix3x3() |
| 115 | + matrix.Xx, matrix.Xy, matrix.Xz = 1.0, 0.0, 0.0 |
| 116 | + matrix.Yx, matrix.Yy, matrix.Yz = 0.0, 1.0, 0.0 |
| 117 | + matrix.Zx, matrix.Zy, matrix.Zz = 0.0, 0.0, 1.0 |
| 118 | + return matrix |
| 119 | + |
| 120 | + |
| 121 | +def _write_log(rows: list[dict[str, str]]) -> None: |
| 122 | + OUTPUT_ROOT.mkdir(parents=True, exist_ok=True) |
| 123 | + fields = ["timestamp", "stage", "item", "status", "message"] |
| 124 | + with LOG_PATH.open("w", newline="", encoding="utf-8") as handle: |
| 125 | + writer = csv.DictWriter(handle, fieldnames=fields) |
| 126 | + writer.writeheader() |
| 127 | + writer.writerows(rows) |
| 128 | + |
| 129 | + |
| 130 | +def _log(rows, stage, item, status, message=""): |
| 131 | + rows.append({ |
| 132 | + "timestamp": datetime.now().isoformat(timespec="seconds"), |
| 133 | + "stage": stage, |
| 134 | + "item": item, |
| 135 | + "status": status, |
| 136 | + "message": str(message).replace("\n", " | ")[:1500], |
| 137 | + }) |
| 138 | + _write_log(rows) |
| 139 | + |
| 140 | + |
| 141 | +def _import_stl_into_part(NXOpen, part, source_stl: Path): |
| 142 | + """Import an STL using the standard NXOpen STLImporter.""" |
| 143 | + importer = part.ImportManager.CreateStlImporter() |
| 144 | + try: |
| 145 | + importer.FileName = str(source_stl) |
| 146 | + importer.FileUnits = NXOpen.STLImporter.FileUnitsType.Millimeters |
| 147 | + importer.AngularTolerance = NXOpen.STLImporter.AngularToleranceType.Fine |
| 148 | + importer.HideSmoothEdges = True |
| 149 | + importer.DisplayInformation = False |
| 150 | + importer.Commit() |
| 151 | + finally: |
| 152 | + importer.Destroy() |
| 153 | + |
| 154 | + |
| 155 | +def _new_metric_part(NXOpen, session, output_path: Path): |
| 156 | + _safe_remove(output_path) |
| 157 | + output_path.parent.mkdir(parents=True, exist_ok=True) |
| 158 | + return session.Parts.NewDisplay(str(output_path), NXOpen.Part.Units.Millimeters) |
| 159 | + |
| 160 | + |
| 161 | +def _save_part(part, output_path: Path): |
| 162 | + status = part.SaveAs(str(output_path)) |
| 163 | + try: |
| 164 | + status.Dispose() |
| 165 | + except Exception: |
| 166 | + pass |
| 167 | + |
| 168 | + |
| 169 | +def build_native_parts(NXOpen, session, rows, manifest): |
| 170 | + created: dict[str, Path] = {} |
| 171 | + for item in manifest: |
| 172 | + part_name = item["nx_part_name"].strip() |
| 173 | + source_path = SOURCE_ROOT / item["source_stl"].strip() |
| 174 | + output_path = OUTPUT_ROOT / "parts" / part_name |
| 175 | + try: |
| 176 | + if not source_path.is_file(): |
| 177 | + raise BuildFailure(f"Missing source STL: {source_path}") |
| 178 | + part = _new_metric_part(NXOpen, session, output_path) |
| 179 | + _import_stl_into_part(NXOpen, part, source_path) |
| 180 | + try: |
| 181 | + part.SetUserAttribute("ASTERION_ID", -1, item["id"], NXOpen.Update.Option.Now) |
| 182 | + part.SetUserAttribute("ASTERION_SOURCE_STL", -1, item["source_stl"], NXOpen.Update.Option.Now) |
| 183 | + part.SetUserAttribute("ASTERION_DESCRIPTION", -1, item["description"], NXOpen.Update.Option.Now) |
| 184 | + except Exception: |
| 185 | + # Attributes are useful but non-critical across NX releases. |
| 186 | + pass |
| 187 | + _save_part(part, output_path) |
| 188 | + created[part_name] = output_path |
| 189 | + _log(rows, "PART", part_name, "PASS", f"Imported {source_path.name}") |
| 190 | + except Exception as exc: |
| 191 | + _log(rows, "PART", part_name, "FAIL", f"{type(exc).__name__}: {exc}") |
| 192 | + raise |
| 193 | + return created |
| 194 | + |
| 195 | + |
| 196 | +def build_top_assembly(NXOpen, session, rows, manifest, created_parts): |
| 197 | + assembly_name = "AST-0000-ASTERION-FCTA-1-ASSY.prt" |
| 198 | + output_path = OUTPUT_ROOT / "assemblies" / assembly_name |
| 199 | + assembly = _new_metric_part(NXOpen, session, output_path) |
| 200 | + component_assembly = assembly.ComponentAssembly |
| 201 | + origin = NXOpen.Point3d(0.0, 0.0, 0.0) |
| 202 | + orientation = _identity_matrix(NXOpen) |
| 203 | + |
| 204 | + included = [m for m in manifest if m["include_in_top_assembly"].strip() == "1"] |
| 205 | + for item in included: |
| 206 | + part_path = created_parts[item["nx_part_name"].strip()] |
| 207 | + component_name = item["component_name"].strip() |
| 208 | + try: |
| 209 | + result = component_assembly.AddComponent( |
| 210 | + str(part_path), "Entire Part", component_name, origin, orientation, -1 |
| 211 | + ) |
| 212 | + # Python wrappers commonly return (component, PartLoadStatus). |
| 213 | + if isinstance(result, tuple) and len(result) > 1: |
| 214 | + load_status = result[1] |
| 215 | + try: |
| 216 | + load_status.Dispose() |
| 217 | + except Exception: |
| 218 | + pass |
| 219 | + _log(rows, "ASSEMBLY_COMPONENT", component_name, "PASS", part_path.name) |
| 220 | + except Exception as exc: |
| 221 | + _log(rows, "ASSEMBLY_COMPONENT", component_name, "FAIL", f"{type(exc).__name__}: {exc}") |
| 222 | + raise |
| 223 | + |
| 224 | + try: |
| 225 | + assembly.SetUserAttribute("ASTERION_CONFIGURATION", -1, "FCTA-1 V1.0", NXOpen.Update.Option.Now) |
| 226 | + except Exception: |
| 227 | + pass |
| 228 | + _save_part(assembly, output_path) |
| 229 | + _log(rows, "ASSEMBLY", assembly_name, "PASS", f"{len(included)} components at absolute origin") |
| 230 | + return output_path |
| 231 | + |
| 232 | + |
| 233 | +def _find_model_view(part, wanted: str): |
| 234 | + wanted_upper = wanted.upper() |
| 235 | + candidates = [] |
| 236 | + try: |
| 237 | + candidates = list(part.ModelingViews.ToArray()) |
| 238 | + except Exception: |
| 239 | + candidates = list(part.ModelingViews) |
| 240 | + for view in candidates: |
| 241 | + name = getattr(view, "Name", "") |
| 242 | + if str(name).upper() == wanted_upper: |
| 243 | + return view |
| 244 | + # Fallback to FindObject for installations using standard names. |
| 245 | + for name in (wanted, wanted.upper(), wanted.capitalize()): |
| 246 | + try: |
| 247 | + return part.ModelingViews.FindObject(name) |
| 248 | + except Exception: |
| 249 | + pass |
| 250 | + raise BuildFailure(f"Could not find standard modeling view: {wanted}") |
| 251 | + |
| 252 | + |
| 253 | +def _insert_a3_sheet(NXOpen, drawing_part, sheet_name: str, scale_denominator: float): |
| 254 | + return drawing_part.DrawingSheets.InsertSheet( |
| 255 | + sheet_name, |
| 256 | + NXOpen.Drawings.DrawingSheet.StandardSheetSize.A3, |
| 257 | + 1.0, |
| 258 | + float(scale_denominator), |
| 259 | + NXOpen.Drawings.DrawingSheet.ProjectionAngleType.ThirdAngle, |
| 260 | + ) |
| 261 | + |
| 262 | + |
| 263 | +def _add_standard_views(NXOpen, drawing_part, sheet, denominator: float): |
| 264 | + scale = 1.0 / float(denominator) |
| 265 | + views = sheet.SheetDraftingViews |
| 266 | + front = _find_model_view(drawing_part, "Front") |
| 267 | + top = _find_model_view(drawing_part, "Top") |
| 268 | + right = _find_model_view(drawing_part, "Right") |
| 269 | + iso = None |
| 270 | + for iso_name in ("Trimetric", "Isometric", "TFR-ISO"): |
| 271 | + try: |
| 272 | + iso = _find_model_view(drawing_part, iso_name) |
| 273 | + break |
| 274 | + except Exception: |
| 275 | + continue |
| 276 | + |
| 277 | + views.CreateBaseView(front, NXOpen.Point3d(115.0, 105.0, 0.0), scale, False) |
| 278 | + views.CreateBaseView(top, NXOpen.Point3d(115.0, 205.0, 0.0), scale, False) |
| 279 | + views.CreateBaseView(right, NXOpen.Point3d(265.0, 105.0, 0.0), scale, False) |
| 280 | + if iso is not None: |
| 281 | + views.CreateBaseView(iso, NXOpen.Point3d(270.0, 205.0, 0.0), scale * 0.75, False) |
| 282 | + |
| 283 | + |
| 284 | +def build_drawing_parts(NXOpen, session, rows, drawing_manifest, model_paths): |
| 285 | + drawing_dir = OUTPUT_ROOT / "drawings" |
| 286 | + drawing_dir.mkdir(parents=True, exist_ok=True) |
| 287 | + for item in drawing_manifest: |
| 288 | + model_name = item["model_prt"].strip() |
| 289 | + drawing_name = item["drawing_prt"].strip() |
| 290 | + output_path = drawing_dir / drawing_name |
| 291 | + model_path = model_paths.get(model_name) |
| 292 | + if model_path is None: |
| 293 | + _log(rows, "DRAWING", drawing_name, "FAIL", f"Model not built: {model_name}") |
| 294 | + continue |
| 295 | + try: |
| 296 | + drawing_part = _new_metric_part(NXOpen, session, output_path) |
| 297 | + origin = NXOpen.Point3d(0.0, 0.0, 0.0) |
| 298 | + orientation = _identity_matrix(NXOpen) |
| 299 | + result = drawing_part.ComponentAssembly.AddComponent( |
| 300 | + str(model_path), "Entire Part", "MASTER_MODEL", origin, orientation, -1 |
| 301 | + ) |
| 302 | + if isinstance(result, tuple) and len(result) > 1: |
| 303 | + try: |
| 304 | + result[1].Dispose() |
| 305 | + except Exception: |
| 306 | + pass |
| 307 | + |
| 308 | + sheet = _insert_a3_sheet( |
| 309 | + NXOpen, drawing_part, item["sheet_name"].strip(), float(item["scale_denominator"]) |
| 310 | + ) |
| 311 | + _add_standard_views(NXOpen, drawing_part, sheet, float(item["scale_denominator"])) |
| 312 | + try: |
| 313 | + drawing_part.SetUserAttribute("ASTERION_DRAWING_TITLE", -1, item["title"], NXOpen.Update.Option.Now) |
| 314 | + drawing_part.SetUserAttribute("ASTERION_MASTER_MODEL", -1, model_name, NXOpen.Update.Option.Now) |
| 315 | + except Exception: |
| 316 | + pass |
| 317 | + _save_part(drawing_part, output_path) |
| 318 | + _log(rows, "DRAWING", drawing_name, "PASS", f"A3 sheet linked to {model_name}") |
| 319 | + except Exception as exc: |
| 320 | + # Save a linked drawing/master-model part where possible, then continue. |
| 321 | + try: |
| 322 | + _save_part(drawing_part, output_path) |
| 323 | + except Exception: |
| 324 | + pass |
| 325 | + _log( |
| 326 | + rows, |
| 327 | + "DRAWING", |
| 328 | + drawing_name, |
| 329 | + "PARTIAL", |
| 330 | + f"Linked model saved, but sheet/view creation needs manual completion: {type(exc).__name__}: {exc}", |
| 331 | + ) |
| 332 | + |
| 333 | + |
| 334 | +def main(): |
| 335 | + try: |
| 336 | + import NXOpen |
| 337 | + import NXOpen.Drawings |
| 338 | + except ImportError: |
| 339 | + if _try_relaunch_through_nx(): |
| 340 | + return |
| 341 | + raise SystemExit(_outside_nx_help()) |
| 342 | + |
| 343 | + OUTPUT_ROOT.mkdir(parents=True, exist_ok=True) |
| 344 | + rows: list[dict[str, str]] = [] |
| 345 | + _log(rows, "BUILD", "ASTERION", "START", f"Output={OUTPUT_ROOT}; overwrite={OVERWRITE}") |
| 346 | + |
| 347 | + component_manifest = _read_csv(COMPONENT_MANIFEST) |
| 348 | + drawing_manifest = _read_csv(DRAWING_MANIFEST) |
| 349 | + session = NXOpen.Session.GetSession() |
| 350 | + listing = session.ListingWindow |
| 351 | + listing.Open() |
| 352 | + listing.WriteFullline("ASTERION native NX build started") |
| 353 | + listing.WriteFullline(f"Output folder: {OUTPUT_ROOT}") |
| 354 | + |
| 355 | + try: |
| 356 | + created_parts = build_native_parts(NXOpen, session, rows, component_manifest) |
| 357 | + assembly_path = build_top_assembly(NXOpen, session, rows, component_manifest, created_parts) |
| 358 | + model_paths = dict(created_parts) |
| 359 | + model_paths[assembly_path.name] = assembly_path |
| 360 | + build_drawing_parts(NXOpen, session, rows, drawing_manifest, model_paths) |
| 361 | + _log(rows, "BUILD", "ASTERION", "COMPLETE", "Review build log and native files.") |
| 362 | + listing.WriteFullline("ASTERION native NX build completed. Review ASTERION_NX_BUILD_LOG.csv") |
| 363 | + except Exception as exc: |
| 364 | + _log(rows, "BUILD", "ASTERION", "FAIL", traceback.format_exc()) |
| 365 | + listing.WriteFullline(f"ASTERION build failed: {type(exc).__name__}: {exc}") |
| 366 | + raise |
| 367 | + finally: |
| 368 | + listing.Close() |
| 369 | + |
| 370 | + |
| 371 | +if __name__ == "__main__": |
| 372 | + main() |
0 commit comments