setupEM 0.7.2__tar.gz → 0.8.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {setupem-0.7.2/src/setupEM.egg-info → setupem-0.8.1}/PKG-INFO +2 -2
  2. {setupem-0.7.2 → setupem-0.8.1}/pyproject.toml +1 -1
  3. {setupem-0.7.2 → setupem-0.8.1}/src/setupEM/__init__.py +1 -1
  4. {setupem-0.7.2 → setupem-0.8.1}/src/setupEM/field_viewer.py +305 -31
  5. setupem-0.8.1/src/setupEM/gds_hierarchy_scan.py +135 -0
  6. {setupem-0.7.2 → setupem-0.8.1}/src/setupEM/layout_preview.py +42 -0
  7. {setupem-0.7.2 → setupem-0.8.1}/src/setupEM/setupEM.py +17 -0
  8. {setupem-0.7.2 → setupem-0.8.1}/src/setupEM/setupThermal.py +8 -0
  9. {setupem-0.7.2 → setupem-0.8.1}/src/setupEM/setup_common.py +819 -63
  10. {setupem-0.7.2 → setupem-0.8.1}/src/setupEM/stackupEditor.py +309 -33
  11. {setupem-0.7.2 → setupem-0.8.1/src/setupEM.egg-info}/PKG-INFO +2 -2
  12. {setupem-0.7.2 → setupem-0.8.1}/src/setupEM.egg-info/SOURCES.txt +1 -0
  13. {setupem-0.7.2 → setupem-0.8.1}/src/setupEM.egg-info/requires.txt +1 -1
  14. {setupem-0.7.2 → setupem-0.8.1}/LICENSE +0 -0
  15. {setupem-0.7.2 → setupem-0.8.1}/README.md +0 -0
  16. {setupem-0.7.2 → setupem-0.8.1}/README_pypi.md +0 -0
  17. {setupem-0.7.2 → setupem-0.8.1}/setup.cfg +0 -0
  18. {setupem-0.7.2 → setupem-0.8.1}/src/setupEM/__main__.py +0 -0
  19. {setupem-0.7.2 → setupem-0.8.1}/src/setupEM/data/SG13G2_100um.xml +0 -0
  20. {setupem-0.7.2 → setupem-0.8.1}/src/setupEM/data/SG13G2_200um.xml +0 -0
  21. {setupem-0.7.2 → setupem-0.8.1}/src/setupEM/data/SG13G2_200um_with_parameters.xml +0 -0
  22. {setupem-0.7.2 → setupem-0.8.1}/src/setupEM/data/SG13G2_nosub.xml +0 -0
  23. {setupem-0.7.2 → setupem-0.8.1}/src/setupEM/momentum_import.py +0 -0
  24. {setupem-0.7.2 → setupem-0.8.1}/src/setupEM/palace_results.py +0 -0
  25. {setupem-0.7.2 → setupem-0.8.1}/src/setupEM/result_viewer.py +0 -0
  26. {setupem-0.7.2 → setupem-0.8.1}/src/setupEM/simplify_gds.py +0 -0
  27. {setupem-0.7.2 → setupem-0.8.1}/src/setupEM/stackup_writer.py +0 -0
  28. {setupem-0.7.2 → setupem-0.8.1}/src/setupEM/thermal_results.py +0 -0
  29. {setupem-0.7.2 → setupem-0.8.1}/src/setupEM.egg-info/dependency_links.txt +0 -0
  30. {setupem-0.7.2 → setupem-0.8.1}/src/setupEM.egg-info/entry_points.txt +0 -0
  31. {setupem-0.7.2 → setupem-0.8.1}/src/setupEM.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: setupEM
3
- Version: 0.7.2
3
+ Version: 0.8.1
4
4
  Summary: Python tool for configuration of gds2palace workflow with GUI.
5
5
  Author-email: Volker Muehlhaus <volker@muehlhaus.com>
6
6
  License-Expression: GPL-3.0-or-later
@@ -10,7 +10,7 @@ Project-URL: Changelog, https://github.com/VolkerMuehlhaus/setupEM/blob/main/doc
10
10
  Requires-Python: >=3.9
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
- Requires-Dist: gds2palace>=0.4.3
13
+ Requires-Dist: gds2palace>=0.5.2
14
14
  Requires-Dist: gds_prepare_for_EM>=1.2.0
15
15
  Requires-Dist: PySide6
16
16
  Requires-Dist: shiboken6
@@ -12,7 +12,7 @@ authors = [
12
12
  { name = "Volker Muehlhaus", email = "volker@muehlhaus.com" }
13
13
  ]
14
14
  dependencies = [
15
- "gds2palace>=0.4.3",
15
+ "gds2palace>=0.5.2",
16
16
  "gds_prepare_for_EM>=1.2.0",
17
17
  "PySide6",
18
18
  "shiboken6",
@@ -24,4 +24,4 @@ A Python tool for EM setup using gds2palace.
24
24
  from .setupEM import main
25
25
 
26
26
  __all__ = ["main"]
27
- __version__ = "0.7.2"
27
+ __version__ = "0.8.1"
@@ -52,6 +52,7 @@ import argparse
52
52
  import glob
53
53
  import os
54
54
  import sys
55
+ import time
55
56
 
56
57
  import numpy as np
57
58
  import pyvista as pv
@@ -63,6 +64,7 @@ from PySide6.QtWidgets import (
63
64
  QSlider, QComboBox, QLineEdit, QStyleFactory,
64
65
  )
65
66
  from PySide6.QtCore import Qt, QThread, Signal
67
+ from PySide6.QtGui import QShortcut, QKeySequence
66
68
 
67
69
  # __package__ is None/"" when this file is run directly rather than imported as part
68
70
  # of the setupEM package, so relative import fails - same dual-mode pattern used
@@ -98,6 +100,10 @@ _VIEW_UP = {"X": (0.0, 0.0, 1.0), "Y": (0.0, 0.0, 1.0), "Z": (0.0, 1.0, 0.0)}
98
100
  # its integer step count.
99
101
  _SLIDER_STEPS = 1_000_000
100
102
 
103
+ # Pixel size for the plain pv.Plotter used in --screenshot/headless mode (see
104
+ # FieldViewerWindow._build_ui()) - no GUI window to inherit a size from there.
105
+ _SCREENSHOT_WINDOW_SIZE = (1280, 960)
106
+
101
107
  # Sources this viewer knows a tailored default array/colormap for. Anything else
102
108
  # (not currently reachable from the app, but kept open for standalone use) falls
103
109
  # back to the generic "first available array" behavior in _pick_default_array().
@@ -133,6 +139,31 @@ _E_FIELD_COMPLEX_KEYS = {
133
139
  _ELMER_EM: ("electric field re", "electric field im"),
134
140
  }
135
141
 
142
+ # Real-part array name per source, for the CLI's --field e/b/s shorthand (see
143
+ # _resolve_field_shorthand()) - same naming mismatch as _E_FIELD_COMPLEX_KEYS
144
+ # above, just for the real-only part plus B-field and the Poynting vector.
145
+ # "s" has no Elmer-EM entry: that field dump has no Poynting-vector array
146
+ # (confirmed against a real Elmer-EM field dump - only E/B field re/im).
147
+ # "temp" isn't here at all - Elmer thermal's temperature array has no fixed
148
+ # name, resolved via the same substring match _pick_default_array() uses.
149
+ _FIELD_REAL_KEY = {
150
+ "e": {_PALACE: "E_real", _ELMER_EM: "electric field re"},
151
+ "b": {_PALACE: "B_real", _ELMER_EM: "magnetic flux density re"},
152
+ "s": {_PALACE: "S"},
153
+ }
154
+
155
+ # Arrays where a dB range means 20*log10(ratio) (field/amplitude convention)
156
+ # rather than 10*log10(ratio) (power/intensity convention) - see
157
+ # _db_range_to_clim(). Built from _FIELD_REAL_KEY's E/B entries plus their
158
+ # imaginary-part/magnitude counterparts; anything not in this set (S, U_e,
159
+ # U_m, an unrecognized --array value) uses the power convention instead.
160
+ _FIELD_AMPLITUDE_ARRAYS = {
161
+ "E_real", "E_imag", "E_magnitude", "B_real", "B_imag",
162
+ "electric field re", "electric field im",
163
+ "magnetic flux density re", "magnetic flux density im",
164
+ "magnetic field strength re", "magnetic field strength im",
165
+ }
166
+
136
167
  # Vector-arrow (glyph) overlay auto-sizing: the largest arrow is scaled to span
137
168
  # a percentage of the mesh's own bounding-box diagonal (the "Arrow size"
138
169
  # slider, in percent - see _add_vector_glyphs()), regardless of the selected
@@ -143,11 +174,13 @@ _E_FIELD_COMPLEX_KEYS = {
143
174
  # size no matter which vector field or domain scale is loaded, and the slider
144
175
  # then lets the user scale that up or down to taste.
145
176
  _VECTOR_ARROW_TARGET_FRACTION_PERCENT_DEFAULT = 8
146
- # QSlider is integer-only, so a 0.5% step is represented as an integer count
147
- # of half-percent units internally (arrow_size_slider's range/value are in
177
+ # QSlider is integer-only, so a 0.2% step is represented as an integer count
178
+ # of fifth-percent units internally (arrow_size_slider's range/value are in
148
179
  # these units) - see _on_arrow_size_changed()/_add_vector_glyphs() for the
149
180
  # conversion back to a plain percentage.
150
- _ARROW_SIZE_STEP_PERCENT = 0.5
181
+ _ARROW_SIZE_STEP_PERCENT = 0.2
182
+ _ARROW_SIZE_MIN_PERCENT = 0.2
183
+ _ARROW_SIZE_MAX_PERCENT = 10
151
184
  # Shortest arrow (smallest-magnitude point actually glyphed) is still drawn at
152
185
  # this fraction of the longest arrow's length, rather than shrinking toward
153
186
  # zero - see _add_vector_glyphs() for why a raw linear magnitude->length
@@ -293,6 +326,47 @@ def _pick_default_array(mesh, source):
293
326
  return (available[0], "viridis", False) if available else (None, "viridis", False)
294
327
 
295
328
 
329
+ def _resolve_field_shorthand(shorthand, mesh, source):
330
+ """Map a CLI --field shorthand ("e"/"b"/"s"/"temp") to an actual
331
+ point-data array name present in mesh, for this source. Returns None if
332
+ the shorthand doesn't apply to this source (e.g. "s" for Elmer-EM, which
333
+ has no Poynting-vector array) or the expected array isn't actually in
334
+ this particular file - the caller turns that into a clear CLI error
335
+ rather than silently keeping whatever array was already selected.
336
+ "temp" has no fixed name (same as _pick_default_array()'s own handling),
337
+ so it's resolved via substring match instead of _FIELD_REAL_KEY.
338
+ """
339
+ if shorthand == "temp":
340
+ key = next((k for k in mesh.point_data.keys() if "temp" in k.lower()), None)
341
+ else:
342
+ key = _FIELD_REAL_KEY.get(shorthand, {}).get(source)
343
+ return key if key is not None and key in mesh.point_data else None
344
+
345
+
346
+ def _is_field_amplitude_array(array_name):
347
+ """True for an E/B-field array (dB range uses 20*log10 - see
348
+ _db_range_to_clim()), False for anything else (S, U_e, U_m, an
349
+ unrecognized --array value), which uses 10*log10 instead."""
350
+ return array_name in _FIELD_AMPLITUDE_ARRAYS
351
+
352
+
353
+ def _db_range_to_clim(mesh, array_name, db_range):
354
+ """(floor, data_max) for a log color scale spanning db_range dB below
355
+ array_name's own maximum in mesh, using the field-amplitude convention
356
+ (20*log10) for E/B-field arrays and the power/intensity convention
357
+ (10*log10) for everything else - see _is_field_amplitude_array(). Returns
358
+ None if the array has no positive values to scale from (nothing sensible
359
+ to show on a log scale)."""
360
+ magnitudes = _array_magnitudes(mesh[array_name])
361
+ positive = magnitudes[magnitudes > 0]
362
+ if positive.size == 0:
363
+ return None
364
+ data_max = positive.max()
365
+ db_per_decade = 20.0 if _is_field_amplitude_array(array_name) else 10.0
366
+ floor = data_max / 10.0 ** (db_range / db_per_decade)
367
+ return floor, data_max
368
+
369
+
296
370
  # ------------------------------------------------------------------
297
371
  # Field Viewer window
298
372
  # ------------------------------------------------------------------
@@ -305,13 +379,19 @@ class FieldViewerWindow(QDialog):
305
379
  inherently the "right" one to default to, so the user picks) - with a single
306
380
  axis-aligned clip plane and a field/array picker."""
307
381
 
308
- def __init__(self, MainWindow, file_paths, source):
382
+ def __init__(self, MainWindow, file_paths, source, off_screen=False):
309
383
  super().__init__()
310
384
  self.setAttribute(Qt.WA_DeleteOnClose)
311
385
  self.MainWindow = MainWindow
312
386
  self.file_paths = list(file_paths)
313
387
  self.file_path = self.file_paths[0]
314
388
  self.source = source
389
+ # CLI-only (see main()'s --screenshot): render off-screen instead of
390
+ # in a real, visible window - lets field_viewer.py run headless, e.g.
391
+ # in an agent/script context with no display. The GUI (setupEM.py/
392
+ # setupThermal.py's open_field_viewer()) never passes this, so it
393
+ # defaults to the normal on-screen behavior there.
394
+ self._off_screen = off_screen
315
395
 
316
396
  self._full_mesh = None
317
397
  self._mesh_actor = None
@@ -483,10 +563,31 @@ class FieldViewerWindow(QDialog):
483
563
  self.opacity_slider.setValue(100)
484
564
  self.opacity_slider.valueChanged.connect(self._on_opacity_changed)
485
565
  display_layout.addWidget(self.opacity_slider)
566
+
567
+ self.arrow_size_label = QLabel(f"Arrow size: {_VECTOR_ARROW_TARGET_FRACTION_PERCENT_DEFAULT}%")
568
+ self.arrow_size_label.setEnabled(False)
569
+ display_layout.addWidget(self.arrow_size_label)
570
+ self.arrow_size_slider = QSlider(Qt.Horizontal)
571
+ self.arrow_size_slider.setRange(round(_ARROW_SIZE_MIN_PERCENT / _ARROW_SIZE_STEP_PERCENT),
572
+ round(_ARROW_SIZE_MAX_PERCENT / _ARROW_SIZE_STEP_PERCENT))
573
+ self.arrow_size_slider.setValue(round(_VECTOR_ARROW_TARGET_FRACTION_PERCENT_DEFAULT / _ARROW_SIZE_STEP_PERCENT))
574
+ self.arrow_size_slider.setEnabled(False)
575
+ self.arrow_size_slider.valueChanged.connect(self._on_arrow_size_changed)
576
+ display_layout.addWidget(self.arrow_size_slider)
577
+
578
+ # Only meaningful (and enabled) when the selected Field array is itself
579
+ # a vector (e.g. E_real/E_imag/B_real/B_imag/S) rather than a scalar
580
+ # (e.g. E_magnitude/U_e/temperature) - see _update_vector_checkbox_state().
581
+ self.show_vectors_cb = QCheckBox("Show arrows")
582
+ self.show_vectors_cb.setEnabled(False)
583
+ self.show_vectors_cb.toggled.connect(self._on_redraw_needed)
584
+ display_layout.addWidget(self.show_vectors_cb)
585
+
486
586
  self.show_edges_cb = QCheckBox("Overlay mesh")
487
587
  self.show_edges_cb.setChecked(False)
488
588
  self.show_edges_cb.toggled.connect(self._on_redraw_needed)
489
589
  display_layout.addWidget(self.show_edges_cb)
590
+
490
591
  display_layout.addStretch()
491
592
  display_group.setLayout(display_layout)
492
593
  controls_layout.addWidget(display_group, 1)
@@ -503,16 +604,22 @@ class FieldViewerWindow(QDialog):
503
604
  # Color range (Min/Max/Reset) directly below the array/log-scale
504
605
  # controls it applies to - vector-arrow controls (a separate concern)
505
606
  # follow below, rather than interleaving the two.
506
- clim_layout = QHBoxLayout()
507
- clim_layout.addWidget(QLabel("Min:"))
607
+ # Min/Max stacked one per row (not side by side) - two QLineEdits
608
+ # sharing one row left each too narrow to read its own value
609
+ # comfortably, given the whole Field group's fixed column width.
610
+ min_layout = QHBoxLayout()
611
+ min_layout.addWidget(QLabel("Min:"))
508
612
  self.clim_min_edit = QLineEdit()
509
613
  self.clim_min_edit.editingFinished.connect(self._on_redraw_needed)
510
- clim_layout.addWidget(self.clim_min_edit)
511
- clim_layout.addWidget(QLabel("Max:"))
614
+ min_layout.addWidget(self.clim_min_edit)
615
+ field_layout.addLayout(min_layout)
616
+
617
+ max_layout = QHBoxLayout()
618
+ max_layout.addWidget(QLabel("Max:"))
512
619
  self.clim_max_edit = QLineEdit()
513
620
  self.clim_max_edit.editingFinished.connect(self._on_redraw_needed)
514
- clim_layout.addWidget(self.clim_max_edit)
515
- field_layout.addLayout(clim_layout)
621
+ max_layout.addWidget(self.clim_max_edit)
622
+ field_layout.addLayout(max_layout)
516
623
  self.clim_reset_btn = QPushButton("Reset range to data")
517
624
  # Without this, Qt treats this as the dialog's default button (the only
518
625
  # QPushButton in the window) and fires it on Enter from *any* focused
@@ -523,24 +630,6 @@ class FieldViewerWindow(QDialog):
523
630
  self.clim_reset_btn.clicked.connect(self._on_clim_reset_clicked)
524
631
  field_layout.addWidget(self.clim_reset_btn)
525
632
 
526
- # Only meaningful (and enabled) when the selected Field array is itself
527
- # a vector (e.g. E_real/E_imag/B_real/B_imag/S) rather than a scalar
528
- # (e.g. E_magnitude/U_e/temperature) - see _update_vector_checkbox_state().
529
- self.show_vectors_cb = QCheckBox("Show arrows")
530
- self.show_vectors_cb.setEnabled(False)
531
- self.show_vectors_cb.toggled.connect(self._on_redraw_needed)
532
- field_layout.addWidget(self.show_vectors_cb)
533
-
534
- self.arrow_size_label = QLabel(f"Arrow size: {_VECTOR_ARROW_TARGET_FRACTION_PERCENT_DEFAULT}%")
535
- self.arrow_size_label.setEnabled(False)
536
- field_layout.addWidget(self.arrow_size_label)
537
- self.arrow_size_slider = QSlider(Qt.Horizontal)
538
- self.arrow_size_slider.setRange(round(1 / _ARROW_SIZE_STEP_PERCENT), round(10 / _ARROW_SIZE_STEP_PERCENT))
539
- self.arrow_size_slider.setValue(round(_VECTOR_ARROW_TARGET_FRACTION_PERCENT_DEFAULT / _ARROW_SIZE_STEP_PERCENT))
540
- self.arrow_size_slider.setEnabled(False)
541
- self.arrow_size_slider.valueChanged.connect(self._on_arrow_size_changed)
542
- field_layout.addWidget(self.arrow_size_slider)
543
-
544
633
  field_layout.addStretch()
545
634
  field_group.setLayout(field_layout)
546
635
  controls_layout.addWidget(field_group, 1)
@@ -577,8 +666,32 @@ class FieldViewerWindow(QDialog):
577
666
  self.warning_label.setStyleSheet("color: #b00000;")
578
667
  main_layout.addWidget(self.warning_label)
579
668
 
580
- self.plotter = QtInteractor(self)
581
- main_layout.addWidget(self.plotter, 1)
669
+ if self._off_screen:
670
+ # QtInteractor's off_screen mode still needs a real OpenGL context,
671
+ # which Qt only creates via a widget's paint/show machinery - never
672
+ # firing for a widget that's added to a layout but never actually
673
+ # shown (confirmed empirically: the render window gets its
674
+ # requested size but stays solid black, nothing ever drawn into
675
+ # it). A plain pv.Plotter(off_screen=True) has no such dependency
676
+ # on Qt's window system at all - real off-screen VTK rendering,
677
+ # confirmed working - so use that instead for CLI/headless use
678
+ # (--screenshot). It exposes the same add_mesh()/remove_actor()/
679
+ # render()/reset_camera()/camera_position/view_isometric()/
680
+ # screenshot()/close() API the rest of this class already calls
681
+ # generically on self.plotter, so no other method needs to
682
+ # branch on which one it is - it's just never added to
683
+ # main_layout since it isn't a QWidget.
684
+ self.plotter = pv.Plotter(off_screen=True, window_size=_SCREENSHOT_WINDOW_SIZE)
685
+ else:
686
+ self.plotter = QtInteractor(self)
687
+ main_layout.addWidget(self.plotter, 1)
688
+
689
+ # Ctrl+C copies the 3D view itself (not the control panels) to the
690
+ # clipboard as an image - window-scoped (default QShortcut context) so
691
+ # it fires regardless of which child widget currently has focus, same
692
+ # convention as layout_preview.py/result_viewer.py/stackupEditor.py.
693
+ QShortcut(QKeySequence.Copy, self).activated.connect(
694
+ lambda: QApplication.clipboard().setPixmap(self.plotter.grab()))
582
695
 
583
696
  # ---------- Result file picker ----------
584
697
 
@@ -680,6 +793,23 @@ class FieldViewerWindow(QDialog):
680
793
  fraction = self.clip_slider.value() / _SLIDER_STEPS
681
794
  return lo + fraction * (hi - lo)
682
795
 
796
+ def _position_um_to_slider_value(self, axis, position_um):
797
+ """Exact inverse of _slider_value_to_position(), for the given axis -
798
+ convert a desired position in um (matching the GUI's own display
799
+ units, see _POSITION_SCALE_TO_UM) into the clip_slider's integer step
800
+ value. Used by the CLI's --clip-position; clamped to [0, 1] the same
801
+ way _move_slider_to_max() already clamps its own fraction, so an
802
+ out-of-bounds position lands at the nearest edge instead of erroring.
803
+ """
804
+ if self._full_mesh is None:
805
+ return _SLIDER_STEPS // 2
806
+ position_native = position_um / _POSITION_SCALE_TO_UM.get(self.source, 1.0)
807
+ lo_idx, hi_idx = _AXIS_BOUNDS_INDEX[axis]
808
+ lo, hi = self._full_mesh.bounds[lo_idx], self._full_mesh.bounds[hi_idx]
809
+ fraction = (position_native - lo) / (hi - lo) if hi > lo else 0.5
810
+ fraction = min(max(fraction, 0.0), 1.0)
811
+ return round(fraction * _SLIDER_STEPS)
812
+
683
813
  def _on_redraw_needed(self, _value=None):
684
814
  self._redraw()
685
815
 
@@ -1123,8 +1253,58 @@ def main():
1123
1253
  "in, instead of passing file_path directly")
1124
1254
  parser.add_argument("--source", choices=[_PALACE, _ELMER_EM, _ELMER_THERMAL], default=_PALACE,
1125
1255
  help="which default array/colormap preset to use (default: palace)")
1256
+
1257
+ # --- Scripted/agentic use: everything below sets up the view without a
1258
+ # human touching the GUI, by driving the same widgets a click would - see
1259
+ # _redraw()'s docstring for why that's safe/correct to do programmatically.
1260
+ parser.add_argument("--field", choices=["e", "b", "s", "temp"],
1261
+ help="shorthand array selection: e=E-field real, b=B-field real, "
1262
+ "s=Poynting vector (Palace only), temp=temperature (Elmer "
1263
+ "thermal only). Ignored if --array is also given.")
1264
+ parser.add_argument("--array", help="exact point-data array name to color by "
1265
+ "(e.g. E_imag, U_e, B_imag) - overrides --field")
1266
+ parser.add_argument("--log-scale", action="store_true", help="use a log color scale")
1267
+ parser.add_argument("--log-range-db", type=float,
1268
+ help="set the color range's minimum this many dB below the "
1269
+ "data's own maximum (implies --log-scale) instead of using "
1270
+ "the data's own minimum - 20*log10 for E/B-field arrays, "
1271
+ "10*log10 for everything else (S, energy density, ...)")
1272
+ parser.add_argument("--clip-axis", choices=["X", "Y", "Z"],
1273
+ help="enable the clip plane on this axis")
1274
+ parser.add_argument("--clip-position", type=float,
1275
+ help="clip plane position in um along --clip-axis (requires it)")
1276
+ parser.add_argument("--clip-max", action="store_true",
1277
+ help="move the clip plane to the hotspot (largest value of the "
1278
+ "selected field) along --clip-axis, like Find max. (requires it)")
1279
+ parser.add_argument("--arrows", action="store_true",
1280
+ help="overlay vector-field direction arrows (only takes effect "
1281
+ "when the selected array is a vector)")
1282
+ parser.add_argument("--arrow-size", type=float,
1283
+ help=f"arrow size as a percent of the mesh's bounding-box diagonal "
1284
+ f"({_ARROW_SIZE_MIN_PERCENT}-{_ARROW_SIZE_MAX_PERCENT}, default "
1285
+ f"{_VECTOR_ARROW_TARGET_FRACTION_PERCENT_DEFAULT})")
1286
+ parser.add_argument("--opacity", type=float, help="mesh opacity percent, 0-100 (default 100)")
1287
+ parser.add_argument("--overlay-mesh", action="store_true",
1288
+ help="draw mesh cell edges on top of the colored surface")
1289
+ parser.add_argument("--view-axis", choices=["X+", "X-", "Y+", "Y-", "Z+", "Z-", "ISO"],
1290
+ help="camera direction: look down +/-X/Y/Z, or ISO for a default "
1291
+ "isometric view. Not the same as --clip-axis.")
1292
+ parser.add_argument("--screenshot",
1293
+ help="render off-screen and save a PNG here instead of opening an "
1294
+ "interactive window, then exit - works with no display")
1126
1295
  args = parser.parse_args()
1127
1296
 
1297
+ if (args.clip_position is not None or args.clip_max) and not args.clip_axis:
1298
+ parser.error("--clip-position/--clip-max require --clip-axis")
1299
+ if args.clip_position is not None and args.clip_max:
1300
+ parser.error("--clip-position and --clip-max are mutually exclusive")
1301
+ if args.opacity is not None and not (0 <= args.opacity <= 100):
1302
+ parser.error("--opacity must be between 0 and 100")
1303
+ if args.arrow_size is not None and not (_ARROW_SIZE_MIN_PERCENT <= args.arrow_size <= _ARROW_SIZE_MAX_PERCENT):
1304
+ parser.error(f"--arrow-size must be between {_ARROW_SIZE_MIN_PERCENT} and {_ARROW_SIZE_MAX_PERCENT}")
1305
+ if args.log_range_db is not None and args.log_range_db <= 0:
1306
+ parser.error("--log-range-db must be positive")
1307
+
1128
1308
  file_paths = [args.file_path] if args.file_path else []
1129
1309
  if not file_paths and args.run_path:
1130
1310
  if args.source == _PALACE:
@@ -1150,7 +1330,101 @@ def main():
1150
1330
  if not file_paths:
1151
1331
  parser.error("no file_path given, and none could be resolved from --run-path")
1152
1332
 
1153
- window = FieldViewerWindow(_StandaloneMainWindow(), file_paths, args.source)
1333
+ def die(message):
1334
+ print(f"Error: {message}", file=sys.stderr)
1335
+ sys.exit(1)
1336
+
1337
+ window = FieldViewerWindow(_StandaloneMainWindow(), file_paths, args.source,
1338
+ off_screen=bool(args.screenshot))
1339
+ if window._full_mesh is None:
1340
+ die(window._load_error or "failed to load the field-result file")
1341
+
1342
+ if args.array:
1343
+ if args.array not in window._full_mesh.point_data:
1344
+ available = ", ".join(window._full_mesh.point_data.keys())
1345
+ die(f"--array {args.array!r} not found in this file. Available arrays: {available}")
1346
+ window.array_combo.setCurrentText(args.array)
1347
+ elif args.field:
1348
+ resolved = _resolve_field_shorthand(args.field, window._full_mesh, args.source)
1349
+ if resolved is None:
1350
+ die(f"--field {args.field!r} is not available for --source {args.source} in this "
1351
+ f"file (e.g. 's' has no Elmer-EM equivalent, 'temp' needs a temperature-named "
1352
+ f"array actually present)")
1353
+ window.array_combo.setCurrentText(resolved)
1354
+
1355
+ if args.log_scale or args.log_range_db is not None:
1356
+ window.log_scale_cb.setChecked(True)
1357
+ if args.log_range_db is not None:
1358
+ array_name = window.array_combo.currentText()
1359
+ result = _db_range_to_clim(window._full_mesh, array_name, args.log_range_db)
1360
+ if result is None:
1361
+ die(f"--log-range-db: array {array_name!r} has no positive values to scale from")
1362
+ floor, data_max = result
1363
+ window.clim_min_edit.setText(f"{floor:.6g}")
1364
+ window.clim_max_edit.setText(f"{data_max:.6g}")
1365
+ window._redraw()
1366
+
1367
+ if args.opacity is not None:
1368
+ window.opacity_slider.setValue(round(args.opacity))
1369
+ if args.overlay_mesh:
1370
+ window.show_edges_cb.setChecked(True)
1371
+ if args.arrows:
1372
+ window.show_vectors_cb.setChecked(True)
1373
+ if not window.show_vectors_cb.isEnabled():
1374
+ print("Warning: --arrows requested but the selected array is not a vector "
1375
+ "field; no arrows will be drawn.", file=sys.stderr)
1376
+ if args.arrow_size is not None:
1377
+ window.arrow_size_slider.setValue(round(args.arrow_size / _ARROW_SIZE_STEP_PERCENT))
1378
+
1379
+ if args.view_axis:
1380
+ if args.view_axis == "ISO":
1381
+ window.plotter.view_isometric()
1382
+ else:
1383
+ window._set_view(args.view_axis[0], 1 if args.view_axis[1] == "+" else -1)
1384
+
1385
+ if args.clip_axis:
1386
+ axis_radio = {"X": window.axis_radio_x, "Y": window.axis_radio_y,
1387
+ "Z": window.axis_radio_z}[args.clip_axis]
1388
+ axis_radio.setChecked(True)
1389
+ if args.clip_max:
1390
+ window._move_slider_to_max()
1391
+ elif args.clip_position is not None:
1392
+ window.clip_slider.setValue(
1393
+ window._position_um_to_slider_value(args.clip_axis, args.clip_position))
1394
+ window.clip_enabled_cb.setChecked(True)
1395
+
1396
+ if args.screenshot:
1397
+ # _ClipWorker's succeeded/failed signals only get delivered while the
1398
+ # event loop is pumped (see field_viewer.py's async-clip design) - and
1399
+ # merely checking _clip_thread.isRunning() isn't enough proof the
1400
+ # result was actually applied: on a small/fast mesh the background
1401
+ # thread can finish (isRunning() -> False) before this loop ever
1402
+ # calls processEvents() even once, leaving its queued succeeded
1403
+ # signal undelivered and _apply_display_mesh() never called for it
1404
+ # (confirmed - a first pass at this loop shipped a screenshot of the
1405
+ # unclipped mesh because of exactly this race). Call processEvents()
1406
+ # unconditionally every iteration, and only stop once the cache
1407
+ # actually reflects the currently-desired clip state (or clipping
1408
+ # isn't enabled at all).
1409
+ deadline = time.monotonic() + 300
1410
+ while True:
1411
+ app.processEvents()
1412
+ still_running = window._clip_thread is not None and window._clip_thread.isRunning()
1413
+ current_key = window._current_clip_key()
1414
+ settled = current_key is None or window._clipped_mesh_cache_key == current_key
1415
+ if not still_running and settled:
1416
+ break
1417
+ time.sleep(0.02)
1418
+ if time.monotonic() > deadline:
1419
+ print("Warning: timed out waiting for the clip plane to finish computing - "
1420
+ "the screenshot may not reflect the requested clip position.",
1421
+ file=sys.stderr)
1422
+ break
1423
+ window.plotter.screenshot(args.screenshot)
1424
+ print(f"Saved screenshot to {args.screenshot}")
1425
+ window.close()
1426
+ sys.exit(0)
1427
+
1154
1428
  window.show()
1155
1429
  sys.exit(app.exec())
1156
1430
 
@@ -0,0 +1,135 @@
1
+ ########################################################################
2
+ #
3
+ # Copyright 2025-2026 Volker Muehlhaus and IHP PDK Authors
4
+ #
5
+ # Licensed under the GNU General Public License, Version 3.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://www.gnu.org/licenses/gpl-3.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ ########################################################################
18
+
19
+ """
20
+ gds_hierarchy_scan.py
21
+
22
+ Cheap, pure-gdspy GDS cell-hierarchy walkers that never call cell.flatten().
23
+ gds2palace's own gds_reader.read_gds() flattens the whole chosen cell (every
24
+ array/reference expanded into real, individual polygon objects) before doing
25
+ anything else - fine for actually building a model, but for a densely-arrayed
26
+ layout (fill patterns, via arrays) that flatten alone can take tens of
27
+ seconds, which is far too slow for something that only needs a yes/no or a
28
+ count. The functions here answer those questions by walking the
29
+ cell/reference tree directly instead, recursing into referenced cells
30
+ without expanding them, so cost scales with the (small) number of distinct
31
+ cells rather than the (potentially huge) number of instantiated copies.
32
+
33
+ No PySide6/setup_common/layout_preview dependency here - keeps this a leaf
34
+ module importable from both without any circular-import concern (setup_common.py
35
+ and layout_preview.py already need a deferred import between each other).
36
+ """
37
+
38
+ import gdspy
39
+
40
+
41
+ def open_gds_cell(gds_path, cellname):
42
+ """Open gds_path and resolve the target cell: cellname if given and
43
+ found, else the file's first top-level cell. Shared first step for
44
+ every walker below. Raises on a bad file or a library with no top-level
45
+ cell, same as gdspy itself would - callers already wrap their own call
46
+ sites in a broad try/except.
47
+ """
48
+ library = gdspy.GdsLibrary(infile=gds_path)
49
+ top_level = library.top_level()
50
+ if not top_level:
51
+ raise ValueError(f"no top-level cell in {gds_path}")
52
+ cell = library.cells.get(cellname, top_level[0])
53
+ return library, cell
54
+
55
+
56
+ def estimate_polygon_count(gds_path, cellname, layernumbers, purposelist):
57
+ """Fast pre-check of how many polygons a full read_gds()-based load
58
+ would end up processing, without flattening the GDS hierarchy. Walks
59
+ the cell/reference tree directly: a plain CellReference contributes its
60
+ referenced cell's own (memoized) count once, a CellArray contributes it
61
+ columns*rows times - the same arithmetic flatten() would otherwise do
62
+ by actually generating that many polygon objects. Returns None if the
63
+ file can't even be opened here (the real read_gds() call is expected to
64
+ surface that error properly instead).
65
+ """
66
+ try:
67
+ library, cell = open_gds_cell(gds_path, cellname)
68
+ except Exception:
69
+ return None
70
+
71
+ layernumbers = set(int(n) for n in layernumbers)
72
+ purposeset = set(int(p) for p in purposelist) if purposelist else None
73
+
74
+ memo = {}
75
+
76
+ def count(c):
77
+ if c.name in memo:
78
+ return memo[c.name]
79
+ total = 0
80
+ for polygonset in c.polygons:
81
+ for layer, datatype in zip(polygonset.layers, polygonset.datatypes):
82
+ if int(layer) in layernumbers and (purposeset is None or int(datatype) in purposeset):
83
+ total += 1
84
+ for ref in c.references:
85
+ ref_cell = ref.ref_cell
86
+ if isinstance(ref_cell, str):
87
+ ref_cell = library.cells.get(ref_cell)
88
+ if ref_cell is None:
89
+ continue
90
+ sub = count(ref_cell)
91
+ total += sub * ref.columns * ref.rows if isinstance(ref, gdspy.CellArray) else sub
92
+ memo[c.name] = total
93
+ return total
94
+
95
+ return count(cell)
96
+
97
+
98
+ def layers_present_in_range(gds_path, cellname, layer_min, layer_max, purposelist):
99
+ """Set of GDS layer numbers in [layer_min, layer_max] with at least one
100
+ polygon whose datatype is in purposelist (any datatype if purposelist is
101
+ falsy), anywhere in the cell hierarchy - without flattening. Recursive,
102
+ memoized-per-cell-name, set-union based: unlike estimate_polygon_count,
103
+ presence needs no CellArray columns*rows multiplication (N copies of a
104
+ present layer is still just "present"), so CellReference and CellArray
105
+ are treated identically here. Raises on open/resolve failure, same as
106
+ open_gds_cell(); callers wrap in their own try/except.
107
+ """
108
+ library, cell = open_gds_cell(gds_path, cellname)
109
+ wanted = set(range(layer_min, layer_max + 1))
110
+ purposeset = set(int(p) for p in purposelist) if purposelist else None
111
+
112
+ memo = {}
113
+
114
+ def scan(c):
115
+ if c.name in memo:
116
+ return memo[c.name]
117
+ found = set()
118
+ for polygonset in c.polygons:
119
+ for layer, datatype in zip(polygonset.layers, polygonset.datatypes):
120
+ layer = int(layer)
121
+ if layer in wanted and (purposeset is None or int(datatype) in purposeset):
122
+ found.add(layer)
123
+ for ref in c.references:
124
+ if found >= wanted:
125
+ break # already covers every requested layer - nothing left to gain
126
+ ref_cell = ref.ref_cell
127
+ if isinstance(ref_cell, str):
128
+ ref_cell = library.cells.get(ref_cell)
129
+ if ref_cell is None:
130
+ continue
131
+ found |= scan(ref_cell)
132
+ memo[c.name] = found
133
+ return found
134
+
135
+ return scan(cell)