gratinglab 0.1.0__py3-none-any.whl

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 (107) hide show
  1. gratinglab/__init__.py +35 -0
  2. gratinglab/checks.py +263 -0
  3. gratinglab/compare.py +233 -0
  4. gratinglab/convergence.py +455 -0
  5. gratinglab/geometry.py +483 -0
  6. gratinglab/gui/__init__.py +0 -0
  7. gratinglab/gui/app.py +80 -0
  8. gratinglab/gui/diagram.py +699 -0
  9. gratinglab/gui/diagram3d.py +609 -0
  10. gratinglab/gui/docs.py +189 -0
  11. gratinglab/gui/icon.py +101 -0
  12. gratinglab/gui/integral_options.py +219 -0
  13. gratinglab/gui/mathtext.py +209 -0
  14. gratinglab/gui/orders.py +152 -0
  15. gratinglab/gui/provenance.py +273 -0
  16. gratinglab/gui/qt/__init__.py +20 -0
  17. gratinglab/gui/qt/geometry_panel.py +239 -0
  18. gratinglab/gui/qt/geometry_tab.py +387 -0
  19. gratinglab/gui/qt/integral_tab.py +447 -0
  20. gratinglab/gui/qt/main_window.py +514 -0
  21. gratinglab/gui/qt/profile_plot_panel.py +67 -0
  22. gratinglab/gui/qt/scalar_tab.py +498 -0
  23. gratinglab/gui/qt/scene3d_panel.py +347 -0
  24. gratinglab/gui/qt/setup_tab.py +215 -0
  25. gratinglab/gui/qt/sizing.py +25 -0
  26. gratinglab/gui/qt/theory_viewer.py +89 -0
  27. gratinglab/gui/qt/worker.py +203 -0
  28. gratinglab/gui/richtext.py +463 -0
  29. gratinglab/gui/scalar_options.py +139 -0
  30. gratinglab/gui/state.py +379 -0
  31. gratinglab/illumination.py +212 -0
  32. gratinglab/io/__init__.py +0 -0
  33. gratinglab/io/efficiency_table.py +382 -0
  34. gratinglab/io/ggp.py +248 -0
  35. gratinglab/materials/__init__.py +218 -0
  36. gratinglab/materials/data/SOURCES.md +53 -0
  37. gratinglab/materials/fresnel.py +282 -0
  38. gratinglab/materials/optical.py +319 -0
  39. gratinglab/materials/refractiveindex.py +306 -0
  40. gratinglab/metrology/__init__.py +121 -0
  41. gratinglab/metrology/analyzer.py +875 -0
  42. gratinglab/metrology/boundary/__init__.py +27 -0
  43. gratinglab/metrology/boundary/average.py +143 -0
  44. gratinglab/metrology/boundary/pipeline.py +211 -0
  45. gratinglab/metrology/cli.py +52 -0
  46. gratinglab/metrology/config.py +170 -0
  47. gratinglab/metrology/core/__init__.py +23 -0
  48. gratinglab/metrology/core/analysis.py +315 -0
  49. gratinglab/metrology/core/image_flatten.py +131 -0
  50. gratinglab/metrology/core/processing.py +344 -0
  51. gratinglab/metrology/core/tip.py +233 -0
  52. gratinglab/metrology/data/__init__.py +16 -0
  53. gratinglab/metrology/data/aggregation.py +193 -0
  54. gratinglab/metrology/gui/__init__.py +56 -0
  55. gratinglab/metrology/gui/icon.py +136 -0
  56. gratinglab/metrology/gui/qt/__init__.py +19 -0
  57. gratinglab/metrology/gui/qt/app.py +28 -0
  58. gratinglab/metrology/gui/qt/boundary_view.py +324 -0
  59. gratinglab/metrology/gui/qt/canvas.py +151 -0
  60. gratinglab/metrology/gui/qt/efficiency_dialog.py +273 -0
  61. gratinglab/metrology/gui/qt/import_view.py +380 -0
  62. gratinglab/metrology/gui/qt/main_window.py +398 -0
  63. gratinglab/metrology/gui/qt/wiki_view.py +99 -0
  64. gratinglab/metrology/gui/qt/worker.py +59 -0
  65. gratinglab/metrology/gui/state.py +168 -0
  66. gratinglab/metrology/io/__init__.py +8 -0
  67. gratinglab/metrology/io/file_io.py +253 -0
  68. gratinglab/metrology/io/metrics.py +38 -0
  69. gratinglab/metrology/io/spm.py +210 -0
  70. gratinglab/metrology/settings.py +216 -0
  71. gratinglab/metrology/stats/__init__.py +16 -0
  72. gratinglab/metrology/stats/analysis.py +329 -0
  73. gratinglab/metrology/stats/icc.py +128 -0
  74. gratinglab/metrology/visualization/__init__.py +16 -0
  75. gratinglab/metrology/visualization/diagnostics.py +111 -0
  76. gratinglab/metrology/visualization/profiles.py +234 -0
  77. gratinglab/metrology/visualization/statistics.py +412 -0
  78. gratinglab/metrology/wiki/__init__.py +91 -0
  79. gratinglab/metrology/wiki/facet-fitting.md +95 -0
  80. gratinglab/metrology/wiki/file-formats.md +116 -0
  81. gratinglab/metrology/wiki/flattening.md +105 -0
  82. gratinglab/metrology/wiki/icc-correction.md +111 -0
  83. gratinglab/metrology/wiki/overview.md +51 -0
  84. gratinglab/metrology/wiki/reading-outputs.md +154 -0
  85. gratinglab/metrology/wiki/row-groups.md +75 -0
  86. gratinglab/metrology/wiki/tip-correction.md +101 -0
  87. gratinglab/metrology/workflows.py +383 -0
  88. gratinglab/problem.py +79 -0
  89. gratinglab/profiles.py +531 -0
  90. gratinglab/resolution.py +198 -0
  91. gratinglab/result.py +268 -0
  92. gratinglab/solvers/__init__.py +34 -0
  93. gratinglab/solvers/base.py +207 -0
  94. gratinglab/solvers/integral/__init__.py +406 -0
  95. gratinglab/solvers/integral/_boundary.py +50 -0
  96. gratinglab/solvers/integral/_core.py +126 -0
  97. gratinglab/solvers/integral/_finite.py +396 -0
  98. gratinglab/solvers/integral/_greens.py +234 -0
  99. gratinglab/solvers/integral/_kernels.py +308 -0
  100. gratinglab/solvers/integral/_nystrom.py +474 -0
  101. gratinglab/solvers/scalar.py +1144 -0
  102. gratinglab-0.1.0.dist-info/METADATA +319 -0
  103. gratinglab-0.1.0.dist-info/RECORD +107 -0
  104. gratinglab-0.1.0.dist-info/WHEEL +5 -0
  105. gratinglab-0.1.0.dist-info/entry_points.txt +4 -0
  106. gratinglab-0.1.0.dist-info/licenses/LICENSE +28 -0
  107. gratinglab-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,239 @@
1
+ """The grating geometry: period, profile, mount, wavelengths.
2
+
3
+ Extracted from `main_window.py`'s original `_build_inputs`, minus scalar's own
4
+ "Scalar solver" groupbox and its Solve/Cancel/Export controls -- those are
5
+ solver-specific and stay wherever a solver's own tab lives (`ScalarTab`,
6
+ eventually alongside whatever RCWA's tab turns out to need). This panel is
7
+ shared: it is built once, not once per solver, because a `Problem` and an
8
+ `Illumination` mean the same thing regardless of which solver is about to
9
+ consume them.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import dataclasses
15
+ from pathlib import Path
16
+
17
+ from PySide6.QtCore import Signal
18
+ from PySide6.QtWidgets import (
19
+ QComboBox,
20
+ QFileDialog,
21
+ QFormLayout,
22
+ QGroupBox,
23
+ QLabel,
24
+ QLineEdit,
25
+ QPushButton,
26
+ QVBoxLayout,
27
+ QWidget,
28
+ )
29
+
30
+ from ..state import ANGLE_LABELS, MOUNTS, PROFILE_FIELDS, PROFILE_KINDS, FormState
31
+
32
+ __all__ = ["GeometryPanel"]
33
+
34
+
35
+ class GeometryPanel(QWidget):
36
+ """Grating, mount and wavelength inputs -- the persistent left sidebar.
37
+
38
+ Not wrapped in its own scroll area: how this panel is embedded (today,
39
+ stacked above a solver's own controls under one shared scroller) is the
40
+ caller's decision, not this widget's.
41
+ """
42
+
43
+ #: Pressing Enter in a field, or finishing a "Load profile…" pick. This
44
+ #: panel does not know what a solve is, only that something changed
45
+ #: enough that one might be wanted; `MainWindow` connects it to `solve()`.
46
+ solve_requested = Signal()
47
+
48
+ #: The form was edited *by a user*. Deliberately distinct from
49
+ #: `solve_requested`: Enter still means solve, and this means only that
50
+ #: the geometry drawing is now stale. `MainWindow` debounces it and
51
+ #: redraws; nothing here reaches a solver.
52
+ #:
53
+ #: Wired to `textEdited`/`activated`, never `textChanged`/
54
+ #: `currentTextChanged`, because those also fire on programmatic
55
+ #: `setText`/`setCurrentText` -- which is how every test and
56
+ #: `_on_mount_change` set fields. Only a real edit should schedule work.
57
+ changed = Signal()
58
+
59
+ def __init__(self, parent: QWidget | None = None) -> None:
60
+ super().__init__(parent)
61
+ self._fields: dict[str, QWidget] = {}
62
+ self._build()
63
+ self._on_mount_change()
64
+ self._on_profile_change()
65
+ self._check_fields_match_formstate()
66
+
67
+ def _build(self) -> None:
68
+ defaults = FormState()
69
+ column = QVBoxLayout(self)
70
+ column.setContentsMargins(0, 0, 0, 0)
71
+
72
+ def entry(form: QFormLayout, label: str, key: str) -> QLineEdit:
73
+ widget = QLineEdit(getattr(defaults, key))
74
+ widget.returnPressed.connect(self.solve_requested)
75
+ widget.textEdited.connect(self.changed)
76
+ self._fields[key] = widget
77
+ form.addRow(label, widget)
78
+ return widget
79
+
80
+ def combo(form: QFormLayout, label: str, key: str, values, on_change) -> QComboBox:
81
+ widget = QComboBox()
82
+ widget.addItems(list(values))
83
+ widget.setCurrentText(getattr(defaults, key))
84
+ widget.currentTextChanged.connect(lambda _t: on_change())
85
+ widget.activated.connect(lambda _i: self.changed.emit())
86
+ self._fields[key] = widget
87
+ form.addRow(label, widget)
88
+ return widget
89
+
90
+ grating = QGroupBox("Grating")
91
+ grating_form = self._grating_form = QFormLayout(grating)
92
+ entry(grating_form, "Period (nm)", "period")
93
+ combo(grating_form, "Profile", "profile_kind", PROFILE_KINDS,
94
+ self._on_profile_change)
95
+
96
+ self._profile_rows: dict[str, QWidget] = {}
97
+ for key, label in (
98
+ ("blaze_angle", "Blaze δ (deg)"),
99
+ ("antiblaze_angle", "Anti-blaze (deg)"),
100
+ ("depth_fraction", "Depth / period"),
101
+ ("duty_cycle", "Duty cycle"),
102
+ ):
103
+ self._profile_rows[key] = entry(grating_form, label, key)
104
+
105
+ # Not a visible row: the path is set by the file dialog, and shown by
106
+ # the label beneath it. It still has to be a field, because
107
+ # read_form() builds FormState from exactly this dict.
108
+ path_field = QLineEdit("")
109
+ path_field.hide()
110
+ self._fields["profile_path"] = path_field
111
+
112
+ self._path_label = QLabel("")
113
+ self._path_label.setWordWrap(True)
114
+ self._path_label.setStyleSheet("color: #555")
115
+ self._load_button = QPushButton("Load profile…")
116
+ self._load_button.clicked.connect(self.load_profile)
117
+ grating_form.addRow(self._load_button)
118
+ grating_form.addRow(self._path_label)
119
+
120
+ mount = QGroupBox("Mount")
121
+ mount_form = QFormLayout(mount)
122
+ combo(mount_form, "Geometry", "mount", MOUNTS, self._on_mount_change)
123
+ self._angle_labels: dict[str, QLabel] = {}
124
+ for key in ("alpha", "gamma"):
125
+ widget = QLineEdit(getattr(defaults, key))
126
+ widget.returnPressed.connect(self.solve_requested)
127
+ widget.textEdited.connect(self.changed)
128
+ self._fields[key] = widget
129
+ label = QLabel("")
130
+ mount_form.addRow(label, widget)
131
+ self._angle_labels[key] = label
132
+
133
+ surface = QGroupBox("Surface")
134
+ surface_form = QFormLayout(surface)
135
+ # A dropdown, not a text field: the set of materials is finite and
136
+ # known, and a free-form string is what let `coating` mean nothing for
137
+ # four milestones. Empty entry first, because no coating is the
138
+ # ordinary default and gives relative efficiency -- a correct answer to
139
+ # a different question, not a missing input.
140
+ from ...materials import available
141
+
142
+ coating = QComboBox()
143
+ coating.addItem("(none)", userData="")
144
+ coating.setToolTip(
145
+ "No coating gives relative efficiency -- a correct answer to a "
146
+ "different question, not a missing input. Naming a material "
147
+ "makes the result absolute: the Scalar tab applies its Fresnel "
148
+ "reflectivity, and the Integral tab reads its complex index when "
149
+ 'Conductivity is "tabulated" (and ignores it when "perfect"). '
150
+ "The provenance panel says which you got."
151
+ )
152
+ for name in available():
153
+ coating.addItem(name, userData=name)
154
+ coating.activated.connect(lambda _i: self.changed.emit())
155
+ self._fields["coating"] = coating
156
+ surface_form.addRow("Coating", coating)
157
+
158
+ entry(surface_form, "Roughness σ (nm)", "roughness")
159
+
160
+ scan = QGroupBox("Wavelengths (nm)")
161
+ scan_form = QFormLayout(scan)
162
+ entry(scan_form, "Start", "wavelength_start")
163
+ entry(scan_form, "Stop", "wavelength_stop")
164
+ entry(scan_form, "Points", "wavelength_count")
165
+
166
+ for group in (grating, mount, surface, scan):
167
+ column.addWidget(group)
168
+ column.addStretch(1)
169
+
170
+ def _check_fields_match_formstate(self) -> None:
171
+ """Refuse to open rather than fail later on one code path.
172
+
173
+ `read_form` builds a `FormState` by keyword from this dict, so a
174
+ renamed or forgotten field is a TypeError at solve time -- visible
175
+ only to whoever presses Solve. Checking here makes it immediate and
176
+ says which field.
177
+ """
178
+ declared = {f.name for f in dataclasses.fields(FormState)}
179
+ if self._fields.keys() != declared:
180
+ raise AssertionError(
181
+ "geometry fields do not match FormState: "
182
+ f"missing {sorted(declared - self._fields.keys())}, "
183
+ f"extra {sorted(self._fields.keys() - declared)}"
184
+ )
185
+
186
+ def _on_profile_change(self) -> None:
187
+ """Show only the parameters the selected profile actually uses."""
188
+ needed = PROFILE_FIELDS.get(
189
+ self._fields["profile_kind"].currentText(), frozenset()
190
+ )
191
+ for key, widget in self._profile_rows.items():
192
+ # setRowVisible hides the label with the field. Hiding the field
193
+ # alone would leave a caption for a control that is not there.
194
+ self._grating_form.setRowVisible(widget, key in needed)
195
+ from_file = "profile_path" in needed
196
+ self._load_button.setVisible(from_file)
197
+ self._path_label.setVisible(from_file)
198
+
199
+ def _on_mount_change(self) -> None:
200
+ """Relabel the two angle fields; the mount decides what they mean."""
201
+ primary, secondary = ANGLE_LABELS[self._fields["mount"].currentText()]
202
+ self._angle_labels["alpha"].setText(primary)
203
+ self._angle_labels["alpha"].show()
204
+ self._fields["alpha"].show()
205
+
206
+ if secondary is None:
207
+ self._angle_labels["gamma"].hide()
208
+ self._fields["gamma"].hide()
209
+ else:
210
+ self._angle_labels["gamma"].setText(secondary)
211
+ self._angle_labels["gamma"].show()
212
+ self._fields["gamma"].show()
213
+
214
+ def read_form(self) -> FormState:
215
+ return FormState(**{k: _value(w) for k, w in self._fields.items()})
216
+
217
+ def load_profile(self) -> None:
218
+ path, _ = QFileDialog.getOpenFileName(
219
+ self, "Load boundary profile", "", "PCGrate boundary (*.ggp);;All files (*)"
220
+ )
221
+ if path:
222
+ self._fields["profile_path"].setText(path)
223
+ self._path_label.setText(Path(path).name)
224
+ self.changed.emit()
225
+ self.solve_requested.emit()
226
+
227
+
228
+ def _value(widget: QWidget) -> str:
229
+ """The *value* of an input, whichever kind it is.
230
+
231
+ A combo's value is its `userData` when it has one, not its label. The
232
+ coating combo shows "(none — relative efficiency)" for the empty choice,
233
+ and `FormState` wants "" -- putting the label in would make the empty
234
+ coating an unknown material name.
235
+ """
236
+ if isinstance(widget, QComboBox):
237
+ data = widget.currentData()
238
+ return widget.currentText() if data is None else str(data)
239
+ return widget.text()
@@ -0,0 +1,387 @@
1
+ """The Grating Geometry tab: what the grating and the beams actually look like.
2
+
3
+ Thin, like every widget here. :mod:`gratinglab.gui.diagram` decides what to
4
+ draw -- every ray direction, every cutoff, every colour tag, every caption --
5
+ and this class only positions the result. It computes no angle, makes no
6
+ comparison against 1.0, and picks no colour, so a bug in this file misplaces a
7
+ line rather than drawing wrong physics.
8
+
9
+ Not a solver tab: no ``name`` attribute, no ``solve_requested``, no
10
+ ``build_options``. `MainWindow._solve_active_tab` reads ``getattr(widget,
11
+ "name", None)`` and therefore skips it, the same way it skips
12
+ :class:`~.setup_tab.SetupTab`. Nothing here needs a solver -- a groove's shape
13
+ and the directions light leaves in are geometry, and geometry is already known
14
+ the moment the form parses.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from PySide6.QtCore import Qt
20
+ from PySide6.QtWidgets import (
21
+ QGroupBox,
22
+ QHBoxLayout,
23
+ QLabel,
24
+ QPushButton,
25
+ QSlider,
26
+ QSplitter,
27
+ QTextEdit,
28
+ QVBoxLayout,
29
+ QWidget,
30
+ )
31
+
32
+ from .. import diagram as diagram_module
33
+ from .. import diagram3d as diagram3d_module
34
+ from .. import provenance
35
+ from .profile_plot_panel import ProfilePlotPanel
36
+ from .scene3d_panel import Scene3DPanel
37
+
38
+ __all__ = ["GeometryTab"]
39
+
40
+ #: Named glyph -> matplotlib marker. The diagram module names what a point
41
+ #: *is*; this is the only place that decides what it looks like.
42
+ _GLYPHS = {
43
+ "out-of-page": "o",
44
+ "strike": "o",
45
+ }
46
+
47
+ _PANEL_TITLES = {
48
+ "main": "Down the groove axis ĝ",
49
+ "ladder": "sin β — every order, propagating or not",
50
+ }
51
+
52
+
53
+ class GeometryTab(QWidget):
54
+ """The grating, the beams, and what the drawing cannot show."""
55
+
56
+ def __init__(self, parent: QWidget | None = None) -> None:
57
+ super().__init__(parent)
58
+ self._parsed = None
59
+ self._diagram = None
60
+ self._scene = None
61
+ self._needs_paint = False
62
+ self._build()
63
+
64
+ def showEvent(self, event) -> None: # noqa: N802 - Qt's spelling
65
+ """Paint on becoming visible, if a redraw happened while hidden.
66
+
67
+ See `_redraw`: the figure is the expensive part and there is no point
68
+ rendering it into a tab nobody is looking at.
69
+ """
70
+ super().showEvent(event)
71
+ if self._needs_paint and self._diagram is not None:
72
+ self._paint(self._diagram)
73
+ self._needs_paint = False
74
+
75
+ # -- construction ------------------------------------------------
76
+
77
+ def _build(self) -> None:
78
+ """Hero on the left, supporting panels on the right, controls beneath.
79
+
80
+ Controls sit in a bottom *strip* rather than the right *column* they
81
+ had before. Once the profile plot moved in here the tab became
82
+ width-constrained rather than height-constrained, and a 320 px column
83
+ was spending the scarce dimension; a ~170 px strip spends the one we
84
+ now have to spare, handing that width back to the plots.
85
+ """
86
+ hero = self._build_hero()
87
+ self.profile_panel = ProfilePlotPanel()
88
+
89
+ side = QSplitter(Qt.Orientation.Vertical)
90
+ side.addWidget(self._build_plots())
91
+ side.addWidget(self.profile_panel)
92
+ side.setStretchFactor(0, 1)
93
+ side.setStretchFactor(1, 1)
94
+ side.setSizes([360, 229])
95
+ side.setMinimumWidth(280)
96
+ self.profile_panel.setMinimumHeight(150)
97
+
98
+ body = QSplitter(Qt.Orientation.Horizontal)
99
+ body.addWidget(hero)
100
+ body.addWidget(side)
101
+ body.setStretchFactor(0, 2)
102
+ body.setStretchFactor(1, 1)
103
+ body.setSizes([578, 290])
104
+
105
+ outer = QSplitter(Qt.Orientation.Vertical)
106
+ outer.addWidget(body)
107
+ outer.addWidget(self._build_controls())
108
+ outer.setStretchFactor(0, 1)
109
+ outer.setStretchFactor(1, 0)
110
+ outer.setSizes([593, 170])
111
+
112
+ layout = QVBoxLayout(self)
113
+ layout.setContentsMargins(0, 0, 0, 0)
114
+ layout.addWidget(outer)
115
+
116
+ def _build_hero(self) -> QWidget:
117
+ """The 3D conical view -- the one thing a dispersion-plane drawing
118
+ cannot show."""
119
+ self._scene3d = Scene3DPanel()
120
+ self._scene3d.setMinimumWidth(380)
121
+ self._scene3d.setMinimumHeight(320)
122
+ return self._scene3d
123
+
124
+ def _build_plots(self) -> QWidget:
125
+ """The 2D panels, beside the 3D one: the dispersion-plane
126
+ cross-section (where the groove facets are legible) and the sin β
127
+ ladder (where an evanescent order still appears)."""
128
+ from matplotlib.backends.backend_qtagg import (
129
+ FigureCanvasQTAgg,
130
+ NavigationToolbar2QT,
131
+ )
132
+ from matplotlib.figure import Figure
133
+
134
+ # Two panels since M13-I: the γ sliver retired when the 3D view began
135
+ # drawing the cone itself. Its row goes to the cross-section, which is
136
+ # the one that has angles to be read.
137
+ self._figure = Figure(figsize=(4, 6), layout="constrained")
138
+ grid = self._figure.add_gridspec(2, 1, height_ratios=[3.0, 1.0])
139
+ self._axes = {
140
+ "main": self._figure.add_subplot(grid[0, 0]),
141
+ "ladder": self._figure.add_subplot(grid[1, 0]),
142
+ }
143
+ self._canvas = FigureCanvasQTAgg(self._figure)
144
+
145
+ panel = QWidget()
146
+ panel.setMinimumHeight(200)
147
+ layout = QVBoxLayout(panel)
148
+ layout.setContentsMargins(0, 0, 0, 0)
149
+ layout.addWidget(NavigationToolbar2QT(self._canvas, panel))
150
+ layout.addWidget(self._canvas)
151
+ return panel
152
+
153
+ def _build_controls(self) -> QWidget:
154
+ strip = QWidget()
155
+ strip.setMinimumHeight(150)
156
+ strip.setMaximumHeight(240)
157
+ row = QHBoxLayout(strip)
158
+ row.setContentsMargins(0, 0, 0, 0)
159
+ row.addWidget(self._build_wavelength_group(), 2)
160
+ row.addWidget(self._build_caption_group(), 3)
161
+ return strip
162
+
163
+ def _build_wavelength_group(self) -> QWidget:
164
+ group = QGroupBox("Wavelength")
165
+ layout = QVBoxLayout(group)
166
+
167
+ self._wavelength_label = QLabel("—")
168
+ layout.addWidget(self._wavelength_label)
169
+
170
+ self._slider = QSlider(Qt.Orientation.Horizontal)
171
+ self._slider.setMinimum(0)
172
+ self._slider.setMaximum(0)
173
+ self._slider.valueChanged.connect(self._on_slider_moved)
174
+ layout.addWidget(self._slider)
175
+
176
+ self._blaze_button = QPushButton("Jump to blaze λ")
177
+ self._blaze_button.clicked.connect(self._jump_to_blaze)
178
+ row = QHBoxLayout()
179
+ row.addWidget(self._blaze_button)
180
+ layout.addLayout(row)
181
+ return group
182
+
183
+ def _build_caption_group(self) -> QWidget:
184
+ group = QGroupBox("What this shows")
185
+ layout = QVBoxLayout(group)
186
+ self._captions = QTextEdit()
187
+ self._captions.setReadOnly(True)
188
+ layout.addWidget(self._captions)
189
+ return group
190
+
191
+ # -- the contract MainWindow uses --------------------------------
192
+
193
+ def show_geometry(self, parsed) -> None:
194
+ """Draw a parsed geometry. Needs no solver and no solve."""
195
+ self._parsed = parsed
196
+ count = len(parsed.wavelengths)
197
+
198
+ # Keep the slider's position across a redraw where the grid is the
199
+ # same size; a wavelength that jumped on every keystroke would make
200
+ # the picture unreadable while typing.
201
+ index = self._slider.value() if self._slider.maximum() == count - 1 else count // 2
202
+
203
+ self._slider.blockSignals(True)
204
+ self._slider.setMaximum(max(0, count - 1))
205
+ self._slider.setValue(min(index, count - 1))
206
+ self._slider.blockSignals(False)
207
+
208
+ self._sync_blaze_button()
209
+ self.profile_panel.draw(parsed)
210
+ self._redraw()
211
+
212
+ def show_pending(self, errors) -> None:
213
+ """The form does not parse yet. Keep the last good drawing.
214
+
215
+ A form mid-edit is not a form with an error: ``"2."`` and ``"-"`` each
216
+ fail to parse for exactly one keystroke, and blanking the canvas or
217
+ reddening a label for them would style ordinary typing as a problem.
218
+ So the picture stays, and a `dim` line says plainly that it is one
219
+ edit behind -- never `warn`, never `bad`, which are what the panel
220
+ reserves for something actually wrong.
221
+
222
+ Errors are counted, not listed: naming a half-typed field would be the
223
+ same accusation in smaller type.
224
+ """
225
+ if self._parsed is None:
226
+ return # nothing good to keep; the tab has never drawn
227
+ count = len(errors)
228
+ note = provenance.Line(
229
+ # Trailing newline: `to_html` concatenates runs, so a line that
230
+ # does not end one runs into the caption beneath it.
231
+ f"waiting for {count} field{'' if count == 1 else 's'} to finish — "
232
+ "the drawing below is from the last complete geometry\n",
233
+ "dim",
234
+ )
235
+ self._captions.setHtml(
236
+ provenance.to_html(
237
+ (note,) + self._scene.captions + self._diagram.captions
238
+ )
239
+ )
240
+
241
+ # -- reactions ----------------------------------------------------
242
+
243
+ def _on_slider_moved(self, _value: int) -> None:
244
+ """Moving the slider redraws. It never solves -- the geometry is
245
+ already known, and nothing here depends on a solver."""
246
+ self._redraw()
247
+
248
+ def _jump_to_blaze(self) -> None:
249
+ if self._parsed is None:
250
+ return
251
+ index, _ = diagram_module.blaze_jump(
252
+ self._parsed.wavelengths, self._parsed.problem, self._parsed.illumination
253
+ )
254
+ if index is not None:
255
+ self._slider.setValue(index)
256
+
257
+ def _sync_blaze_button(self) -> None:
258
+ """Enabled when it can act; disabled *with its reason* when it cannot.
259
+
260
+ A disabled control that explains itself is honest. An enabled one that
261
+ silently does nothing is the mistake this project already made once
262
+ and named -- see `docs/theory/scalar.md` §5.
263
+ """
264
+ index, reason = diagram_module.blaze_jump(
265
+ self._parsed.wavelengths, self._parsed.problem, self._parsed.illumination
266
+ )
267
+ self._blaze_button.setEnabled(index is not None)
268
+ self._blaze_button.setToolTip(reason)
269
+
270
+ # -- drawing ------------------------------------------------------
271
+
272
+ def _redraw(self) -> None:
273
+ """Rebuild the diagram; paint it only if anyone can see it.
274
+
275
+ Building is ~1 ms of numpy and always runs, so `_diagram` is always
276
+ current and a caller (or a test) can read it without a window. Painting
277
+ is ~50 ms of matplotlib, so it waits for `showEvent` when the tab is
278
+ hidden -- rendering a figure into a tab nobody is looking at is time
279
+ spent for no one.
280
+ """
281
+ if self._parsed is None:
282
+ return
283
+ wavelength = float(self._parsed.wavelengths[self._slider.value()])
284
+ self._wavelength_label.setText(f"λ = {wavelength:.4g} nm")
285
+
286
+ self._diagram = diagram_module.build(
287
+ self._parsed.problem, self._parsed.illumination, wavelength
288
+ )
289
+ self._scene = diagram3d_module.build_scene(
290
+ self._parsed.problem, self._parsed.illumination, wavelength
291
+ )
292
+ # Cheap, and it is the part a reader needs even before the picture
293
+ # arrives, so it is never deferred. The 3D scene's captions carry what
294
+ # the projection cannot show, so both sets appear.
295
+ self._captions.setHtml(
296
+ provenance.to_html(self._scene.captions + self._diagram.captions)
297
+ )
298
+
299
+ if self.isVisible():
300
+ self._paint(self._diagram)
301
+ self._needs_paint = False
302
+ else:
303
+ self._needs_paint = True
304
+
305
+ def _paint(self, drawing) -> None:
306
+ """Position what `diagram` decided. No trig, no cutoffs, no colours."""
307
+ colors = diagram_module.TAG_COLORS
308
+ self._scene3d.show_scene(self._scene)
309
+
310
+ for name, axes in self._axes.items():
311
+ axes.clear()
312
+ axes.set_title(_PANEL_TITLES[name], fontsize=9)
313
+
314
+ for path in drawing.paths:
315
+ axes = self._axes[path.panel]
316
+ axes.plot(
317
+ path.x, path.y,
318
+ color=colors[path.tag],
319
+ lw=1.6,
320
+ ls="--" if path.dashed else "-",
321
+ label=path.label,
322
+ )
323
+ if path.fill_to is not None:
324
+ axes.fill_between(path.x, path.fill_to, path.y,
325
+ color=colors[path.tag], alpha=0.12)
326
+
327
+ for arrow in drawing.arrows:
328
+ axes = self._axes[arrow.panel]
329
+ axes.annotate(
330
+ "",
331
+ xy=(arrow.x1, arrow.y1),
332
+ xytext=(arrow.x0, arrow.y0),
333
+ arrowprops={
334
+ "arrowstyle": "-|>",
335
+ "color": colors[arrow.tag],
336
+ "lw": 1.5,
337
+ "ls": "--" if arrow.dashed else "-",
338
+ "shrinkA": 0,
339
+ "shrinkB": 0,
340
+ },
341
+ )
342
+ if arrow.label:
343
+ axes.annotate(
344
+ arrow.label,
345
+ xy=(arrow.x1, arrow.y1),
346
+ fontsize=7,
347
+ color=colors[arrow.tag],
348
+ ha="center",
349
+ va="bottom",
350
+ )
351
+
352
+ for marker in drawing.markers:
353
+ axes = self._axes[marker.panel]
354
+ axes.plot(
355
+ marker.x, marker.y,
356
+ _GLYPHS[marker.glyph],
357
+ color=colors[marker.tag],
358
+ ms=4 if marker.glyph == "out-of-page" else 5,
359
+ mfc="none" if marker.glyph == "out-of-page" else colors[marker.tag],
360
+ )
361
+ if marker.label:
362
+ axes.annotate(
363
+ marker.label,
364
+ xy=(marker.x, marker.y),
365
+ xytext=(0, 7),
366
+ textcoords="offset points",
367
+ fontsize=6,
368
+ color=colors[marker.tag],
369
+ ha="center",
370
+ )
371
+
372
+ for name, ((x0, x1), (y0, y1)) in drawing.limits.items():
373
+ axes = self._axes[name]
374
+ axes.set_xlim(x0, x1)
375
+ axes.set_ylim(y0, y1)
376
+
377
+ # Equal aspect on the panel whose angles are meant to be true. The
378
+ # ladder is a number line and has no aspect to preserve.
379
+ self._axes["main"].set_aspect("equal", adjustable="box")
380
+ self._axes["main"].set_xlabel("position along d̂ (nm)", fontsize=8)
381
+ self._axes["main"].set_ylabel("height (nm)", fontsize=8)
382
+ self._axes["ladder"].set_yticks([])
383
+ for axes in self._axes.values():
384
+ axes.tick_params(labelsize=7)
385
+ self._axes["main"].grid(alpha=0.15)
386
+
387
+ self._canvas.draw_idle()