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,609 @@
1
+ r"""The conical diffraction geometry in three dimensions, decided without a
2
+ toolkit.
3
+
4
+ The 2D view in :mod:`gratinglab.gui.diagram` is the projection down the groove
5
+ axis. It draws every azimuth truly and hides one thing completely: the
6
+ out-of-plane component :math:`k_z = k\cos\gamma`, which at
7
+ :math:`\gamma = 1.5°` is 99.97% of :math:`|k|`. This module is the view that
8
+ shows it.
9
+
10
+ Why a narrow cone still makes a good picture
11
+ ============================================
12
+
13
+ At the reference geometry (315.15 nm, :math:`\alpha = 25°`,
14
+ :math:`\gamma = 1.5°`, :math:`\lambda = 3` nm) the propagating orders are:
15
+
16
+ === ========== ============================================
17
+ m :math:`\beta_m` :math:`\hat{k}_m`
18
+ === ========== ============================================
19
+ -1 −51.84° (−0.02058, +0.01617, **0.99966**)
20
+ 0 −25.00° (−0.01106, +0.02372, **0.99966**)
21
+ +1 −3.38° (−0.00154, +0.02613, **0.99966**)
22
+ +2 +17.74° (+0.00798, +0.02493, **0.99966**)
23
+ +3 +41.94° (+0.01749, +0.01947, **0.99966**)
24
+ === ========== ============================================
25
+
26
+ **Polar spread: exactly 0.0°. Azimuth spread: 93.8°.** The cone is a needle
27
+ *and* the fan around it is nearly a right angle. Both at once, and that
28
+ combination is precisely what a dispersion-plane drawing cannot convey.
29
+
30
+ So the default oblique view is not a degenerate picture. The grooves run along
31
+ :math:`\hat{z}` and the rays run at 1.5° to them: a beam *skimming along the
32
+ groove direction* at grazing incidence, which is exactly what distinguishes an
33
+ off-plane mount from a classical one. Looking down the cone axis instead, and
34
+ zooming uniformly to the rim, makes the 93.8° fan fully readable -- at true
35
+ scale, since uniform zoom is a similarity transform and preserves every angle.
36
+
37
+ **Nothing here exaggerates γ.** Legibility comes from viewpoint and
38
+ magnification only.
39
+
40
+ Two scales in one box
41
+ =====================
42
+
43
+ The grating patch is in nm (~630 nm across); the rays are unit vectors. There
44
+ is no physical ratio between them, so :attr:`Scene.scale_nm` records the one
45
+ chosen and a caption states it. The groove *cross-section* is exact and every
46
+ ray *direction* is exact; only the patch's size relative to the rays is a
47
+ drawing choice -- the same disclaimer the 2D view already carries for ray
48
+ length, narrowed to the one thing still arbitrary.
49
+
50
+ Why a separate module
51
+ =====================
52
+
53
+ :class:`~gratinglab.gui.diagram.Diagram` is 2D by construction: ``limits`` is
54
+ typed as two ranges and the widget unpacks it as such, and
55
+ :attr:`Arrow.azimuth` would silently keep returning a projection angle if a
56
+ ``z`` were defaulted onto it. Adding a third dimension there would make the
57
+ widget branch on shape, which is what the pure/impure split exists to prevent.
58
+ This module reuses :func:`~gratinglab.gui.diagram.direction`,
59
+ :func:`~gratinglab.gui.diagram.order_marks`,
60
+ :func:`~gratinglab.gui.diagram.strike_point` and
61
+ :func:`~gratinglab.gui.diagram.facet_normal` rather than re-deriving anything.
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ from dataclasses import dataclass
67
+ from typing import TYPE_CHECKING, Sequence
68
+
69
+ import numpy as np
70
+ from numpy.typing import NDArray
71
+
72
+ from ..geometry import blaze_direction
73
+ from .diagram import (
74
+ TAG_COLORS,
75
+ OrderMark,
76
+ direction,
77
+ facet_normal,
78
+ order_marks,
79
+ strike_point,
80
+ x_nm,
81
+ )
82
+ from .provenance import Line
83
+
84
+ if TYPE_CHECKING: # pragma: no cover - imports for type checkers only
85
+ from ..illumination import Illumination
86
+ from ..problem import Problem
87
+
88
+ __all__ = [
89
+ "G_HAT",
90
+ "D_HAT",
91
+ "N_HAT",
92
+ "CONE_AXIS",
93
+ "TAG_COLORS",
94
+ "PRESET_VIEWS",
95
+ "RIM_PRESET",
96
+ "rim_box",
97
+ "Ray3D",
98
+ "Surface3D",
99
+ "Curve3D",
100
+ "Point3D",
101
+ "Scene",
102
+ "wave_vector",
103
+ "incident_vector",
104
+ "view_direction",
105
+ "build_scene",
106
+ "captions3d",
107
+ ]
108
+
109
+ #: `conventions.md` §3, forced by :math:`\hat{d} \times \hat{g} = \hat{n}`.
110
+ D_HAT = np.array([1.0, 0.0, 0.0])
111
+ N_HAT = np.array([0.0, 1.0, 0.0])
112
+ G_HAT = np.array([0.0, 0.0, -1.0])
113
+
114
+ #: The cone opens along :math:`-\hat{g}`, **not** along :math:`\hat{g}`. Every
115
+ #: wave vector carries :math:`k_z = +k\cos\gamma`, so at :math:`\gamma = 1.5°`
116
+ #: every ray lies 1.5° from :math:`+\hat{z}` and 178.5° from :math:`\hat{g}`.
117
+ #: Opening it the other way points every ray 180° out. See
118
+ #: ``docs/conventions.md`` §3 and ``docs/findings.md``.
119
+ CONE_AXIS = -G_HAT
120
+
121
+ #: Grating patch width, in ray-length units. The rays are unit vectors and the
122
+ #: patch is in nm; this is the ratio between those two scales, and it is a
123
+ #: drawing choice that a caption declares.
124
+ #:
125
+ #: Small on purpose. At gamma = 1.5 deg the rays lie within 1.5 deg of the
126
+ #: groove axis, so a patch as long as the rays swallows them; keeping it well
127
+ #: under a ray length lets the bundle emerge and be seen for what it is.
128
+ PATCH_SPAN: float = 0.5
129
+
130
+ #: Half-extent of the scene's bounding cube.
131
+ BOX_HALF: float = 1.15
132
+
133
+ #: How far out the rim-focus box reaches, in units of the rim radius
134
+ #: :math:`\sin\gamma`. 1.6 puts the rim circle across ~62% of the frame.
135
+ RIM_MARGIN: float = 1.6
136
+
137
+ #: Below this, focusing on the rim is not worth offering -- and at
138
+ #: :math:`\gamma = 90°` it would be a zoom *out*.
139
+ MIN_RIM_MAGNIFICATION: float = 1.5
140
+
141
+ #: The direction the camera looks **along**, in the physical
142
+ #: :math:`(\hat{d}, \hat{n}, -\hat{g})` frame of ``conventions.md`` §3.
143
+ #:
144
+ #: Physical, not matplotlib ``(elev, azim)``: the widget permutes the scene to
145
+ #: put :math:`\hat{n}` upward for display, so an ``(elev, azim)`` pair here
146
+ #: would no longer mean what its name says. The widget converts.
147
+ PRESET_VIEWS: dict[str, NDArray[np.float64]] = {
148
+ # Off-axis: the grazing bundle skimming along the grooves.
149
+ "Oblique": np.array([-0.42, -0.35, -0.84]),
150
+ # From +ẑ looking back, so every order projects onto a circle at its true
151
+ # azimuth. Paired with rim focus, this is where the fan is readable.
152
+ "Down the cone axis": G_HAT.copy(),
153
+ # The (n̂, ĝ) plane, where γ itself is the visible angle.
154
+ "Along d̂": -D_HAT.copy(),
155
+ # The grating face-on, grooves running across.
156
+ "Face n̂": -N_HAT.copy(),
157
+ }
158
+ PRESET_VIEWS = {
159
+ name: v / np.linalg.norm(v) for name, v in PRESET_VIEWS.items()
160
+ }
161
+
162
+ #: The preset whose whole point is the rim, so choosing it switches focus too.
163
+ RIM_PRESET = "Down the cone axis"
164
+
165
+ #: Mesh resolutions. Both surfaces are *ruled* -- the grating patch is the
166
+ #: cross-section extruded along ẑ, and every generator of a cone is straight --
167
+ #: so the second dimension is 2 and that is exact, not a sampling choice.
168
+ _PATCH_NX = {"fine": 241, "coarse": 61}
169
+ _CONE_NPHI = {"fine": 289, "coarse": 73}
170
+
171
+
172
+ @dataclass(frozen=True, slots=True)
173
+ class Ray3D:
174
+ """A wave vector, drawn from ``origin`` along a **unit** ``direction``.
175
+
176
+ Unit, always. The 2D view draws every ray at one arbitrary length because
177
+ only azimuths carry meaning there; here the transverse extent *is* the
178
+ physics -- it is :math:`\\sin\\gamma`, and at 1.5° that is 0.026. A
179
+ renormalised ray would erase exactly the quantity this view exists to
180
+ show.
181
+ """
182
+
183
+ origin: tuple[float, float, float]
184
+ direction: tuple[float, float, float]
185
+ length: float
186
+ tag: str
187
+ label: str | None = None
188
+ order: int | None = None
189
+ dashed: bool = False
190
+
191
+ @property
192
+ def head(self) -> tuple[float, float, float]:
193
+ o, d, s = np.asarray(self.origin), np.asarray(self.direction), self.length
194
+ return tuple(float(v) for v in o + s * d)
195
+
196
+ @property
197
+ def azimuth(self) -> float:
198
+ """Azimuth from :math:`\\hat{n}` toward :math:`\\hat{d}`, radians --
199
+ the convention every angle in §3 uses. Read back off the drawn vector,
200
+ which is what lets a test recover the grating equation from the
201
+ picture rather than recompute it the same way."""
202
+ return float(np.arctan2(self.direction[0], self.direction[1]))
203
+
204
+ @property
205
+ def polar_from_cone_axis(self) -> float:
206
+ """Angle to :math:`-\\hat{g}`. Equals :math:`\\gamma` for every wave
207
+ vector, which is the cone property stated as one number."""
208
+ return float(np.arccos(np.dot(self.direction, CONE_AXIS)))
209
+
210
+
211
+ @dataclass(frozen=True, slots=True)
212
+ class Surface3D:
213
+ """A ruled surface, as ``(n, 2)`` meshgrid arrays for ``plot_surface``."""
214
+
215
+ x: NDArray[np.float64]
216
+ y: NDArray[np.float64]
217
+ z: NDArray[np.float64]
218
+ tag: str
219
+ lod: str = "fine"
220
+ alpha: float = 1.0
221
+ label: str | None = None
222
+
223
+
224
+ @dataclass(frozen=True, slots=True)
225
+ class Curve3D:
226
+ """An open or closed 3D path, already sampled."""
227
+
228
+ x: NDArray[np.float64]
229
+ y: NDArray[np.float64]
230
+ z: NDArray[np.float64]
231
+ tag: str
232
+ lod: str = "fine"
233
+ dashed: bool = False
234
+ label: str | None = None
235
+
236
+
237
+ @dataclass(frozen=True, slots=True)
238
+ class Point3D:
239
+ """A point with a *named* glyph; the widget owns the name-to-marker map."""
240
+
241
+ x: float
242
+ y: float
243
+ z: float
244
+ glyph: str
245
+ tag: str
246
+ label: str | None = None
247
+
248
+
249
+ @dataclass(frozen=True, slots=True)
250
+ class Scene:
251
+ """One geometry, at one wavelength, as things to draw in three dimensions."""
252
+
253
+ rays: tuple[Ray3D, ...]
254
+ surfaces: tuple[Surface3D, ...]
255
+ curves: tuple[Curve3D, ...]
256
+ points: tuple[Point3D, ...]
257
+ orders: tuple[OrderMark, ...]
258
+ captions: tuple[Line, ...]
259
+ title: str
260
+ limits: tuple[tuple[float, float], tuple[float, float], tuple[float, float]]
261
+ #: The same scene, framed on the cone rim. **Also a cube**, so moving
262
+ #: between the two is a uniform scale plus a translation -- a similarity
263
+ #: transform, which preserves every angle exactly. Nothing is distorted;
264
+ #: only which part of the geometry fills the frame changes.
265
+ rim_limits: tuple[tuple[float, float], tuple[float, float], tuple[float, float]]
266
+ #: How much closer that is, for the label on the canvas.
267
+ rim_magnification: float
268
+ #: Non-empty **exactly** when the rim focus is not worth offering, so a
269
+ #: caller puts *this* in a disabled control's tooltip rather than
270
+ #: inventing one. Same contract as ``blaze_jump``.
271
+ rim_reason: str
272
+ scale_nm: float
273
+ gamma: float
274
+ wavelength: float
275
+ blaze_order: int | None
276
+
277
+ @property
278
+ def rim_available(self) -> bool:
279
+ return not self.rim_reason
280
+
281
+ def ray(self, order: int) -> Ray3D:
282
+ """The ray for one order. Raises if it has none -- an evanescent order
283
+ genuinely has no direction, and a default would invent one."""
284
+ for entry in self.rays:
285
+ if entry.order == order:
286
+ return entry
287
+ raise KeyError(f"order {order} has no ray in this scene")
288
+
289
+ def at(self, lod: str) -> tuple[object, ...]:
290
+ """Every level-of-detail-bearing primitive at one level."""
291
+ return tuple(
292
+ item
293
+ for group in (self.surfaces, self.curves)
294
+ for item in group
295
+ if item.lod == lod
296
+ )
297
+
298
+
299
+ # -- wave vectors ---------------------------------------------------------
300
+
301
+
302
+ def wave_vector(azimuth: float, gamma: float) -> NDArray[np.float64]:
303
+ r"""A unit wave vector at the given azimuth, on the cone of half-angle
304
+ :math:`\gamma`.
305
+
306
+ ``conventions.md`` §3's :math:`\hat{k}_m`. The :math:`(x, y)` part is
307
+ literally :func:`~gratinglab.gui.diagram.direction` scaled by
308
+ :math:`\sin\gamma`, so the 2D and 3D views cannot disagree about a
309
+ direction; only :math:`+\cos\gamma\,\hat{z}` is new here, and that is
310
+ exactly the component the projection drops.
311
+ """
312
+ return np.array([*(direction(azimuth) * np.sin(gamma)), np.cos(gamma)])
313
+
314
+
315
+ def incident_vector(alpha: float, gamma: float) -> NDArray[np.float64]:
316
+ r""":math:`\hat{k}_i`, which travels *toward* the grating (negative
317
+ :math:`\hat{y}`). Equals ``Illumination.direction_cosines``."""
318
+ return np.array([*(-direction(alpha) * np.sin(gamma)), np.cos(gamma)])
319
+
320
+
321
+ def rim_box(
322
+ gamma: float,
323
+ ) -> tuple[
324
+ tuple[tuple[float, float], tuple[float, float], tuple[float, float]], float, str
325
+ ]:
326
+ r"""``(limits, magnification, reason)`` for a view framed on the cone rim.
327
+
328
+ The rim is a circle of radius :math:`\sin\gamma` at height
329
+ :math:`\cos\gamma`, so the box is a cube centred there. At
330
+ :math:`\gamma = 1.5°` that is a 27x uniform magnification -- which is what
331
+ turns the 93.8° azimuth fan from a point cluster into something readable,
332
+ without touching a single angle.
333
+
334
+ ``reason`` is non-empty **exactly** when the magnification is not worth
335
+ offering. At :math:`\gamma = 90°` the rim already *is* the whole scene and
336
+ focusing on it would zoom *out*; a control that did nothing would be the
337
+ mistake this project has named before.
338
+ """
339
+ half = RIM_MARGIN * float(np.sin(gamma))
340
+ magnification = BOX_HALF / half if half > 0.0 else float("inf")
341
+ centre = float(np.cos(gamma))
342
+
343
+ limits = (
344
+ (-half, half),
345
+ (-half, half),
346
+ (centre - half, centre + half),
347
+ )
348
+
349
+ if magnification < MIN_RIM_MAGNIFICATION:
350
+ degrees = np.degrees(gamma)
351
+ if np.isclose(degrees, 90.0):
352
+ reason = (
353
+ "γ = 90°: the cone rim is the whole scene, so there is nothing "
354
+ "to zoom into."
355
+ )
356
+ else:
357
+ reason = (
358
+ f"γ = {degrees:g}° already separates the orders; focusing on "
359
+ "the rim would change little."
360
+ )
361
+ return limits, magnification, reason
362
+
363
+ return limits, magnification, ""
364
+
365
+
366
+ def view_direction(elev_deg: float, azim_deg: float) -> NDArray[np.float64]:
367
+ """The unit vector a matplotlib 3D view looks *along*, from the camera
368
+ toward the origin's far side. Pure, so presets are testable without a
369
+ figure."""
370
+ elev, azim = np.radians(elev_deg), np.radians(azim_deg)
371
+ return np.array(
372
+ [
373
+ -np.cos(elev) * np.cos(azim),
374
+ -np.cos(elev) * np.sin(azim),
375
+ -np.sin(elev),
376
+ ]
377
+ )
378
+
379
+
380
+ # -- captions -------------------------------------------------------------
381
+
382
+
383
+ def scene_title(gamma: float) -> str:
384
+ """Always states γ, and always states that it is to scale."""
385
+ return f"Conical diffraction — γ = {np.degrees(gamma):g}°, to scale"
386
+
387
+
388
+ def captions3d(
389
+ problem: "Problem",
390
+ illumination: "Illumination",
391
+ wavelength: float,
392
+ marks: Sequence[OrderMark],
393
+ scale_nm: float,
394
+ ) -> tuple[Line, ...]:
395
+ """What the drawing is, and what in it is a drawing choice."""
396
+ live = sum(1 for m in marks if m.propagating)
397
+ dark = len(marks) - live
398
+ gamma = illumination.gamma
399
+
400
+ lines: list[Line] = [
401
+ Line(
402
+ f"λ = {wavelength:.3g} nm. {live} of {len(marks)} orders propagate "
403
+ f"and carry a ray; {dark} are evanescent, have no direction, and "
404
+ "appear on the sin β axis beside this view.\n"
405
+ ),
406
+ Line(
407
+ "Every ray is a unit wave vector. Their directions, and the angles "
408
+ "between them, are exact.\n",
409
+ "dim",
410
+ ),
411
+ Line(
412
+ f"The grating patch is in nm and the rays are unit vectors, so "
413
+ f"their relative size is a drawing choice ({scale_nm:.1f} nm per "
414
+ "scene unit). The groove cross-section itself is exact.\n",
415
+ "dim",
416
+ ),
417
+ ]
418
+
419
+ if illumination.is_in_plane:
420
+ lines.append(
421
+ Line(
422
+ "γ = 90°: k_z = 0, the cone degenerates to the dispersion "
423
+ "plane, and every ray lies in it. This view and the "
424
+ "cross-section beside it show the same geometry.\n",
425
+ "dim",
426
+ )
427
+ )
428
+ else:
429
+ lines.append(
430
+ Line(
431
+ f"The cone opens along −ĝ, not ĝ. Every wave vector carries "
432
+ f"k_z = +k cos γ = {np.cos(gamma):.5f} k, so each ray lies "
433
+ f"{np.degrees(gamma):g}° from +ẑ and "
434
+ f"{180 - np.degrees(gamma):g}° from ĝ.\n",
435
+ "dim",
436
+ )
437
+ )
438
+ lines.append(
439
+ Line(
440
+ "The orders differ in azimuth, never in polar angle — they all "
441
+ "sit on this one cone. That is why a cone this narrow can still "
442
+ "carry a wide fan.\n",
443
+ "dim",
444
+ )
445
+ )
446
+
447
+ lines.append(
448
+ Line(
449
+ "The groove profile has no z dependence, so the surface drawn is "
450
+ "the cross-section extruded exactly along ẑ. That is not an "
451
+ "approximation.\n",
452
+ "dim",
453
+ )
454
+ )
455
+ return tuple(lines)
456
+
457
+
458
+ # -- the scene ------------------------------------------------------------
459
+
460
+
461
+ def _grating_patch(problem: "Problem", scale_nm: float, periods: int, lod: str):
462
+ """The cross-section extruded along ẑ. Ruled, so two rows is exact."""
463
+ t = np.linspace(-periods, 0.0, _PATCH_NX[lod])
464
+ strike = np.asarray(strike_point(problem))
465
+ px = (x_nm(t, problem.period) - strike[0]) / scale_nm
466
+ py = (np.asarray(problem.height_nm(t)) - strike[1]) / scale_nm
467
+ half = 0.5 * PATCH_SPAN
468
+
469
+ return Surface3D(
470
+ x=np.column_stack([px, px]),
471
+ y=np.column_stack([py, py]),
472
+ z=np.column_stack([np.full_like(px, -half), np.full_like(px, half)]),
473
+ tag="surface",
474
+ lod=lod,
475
+ alpha=0.55,
476
+ label="grating",
477
+ )
478
+
479
+
480
+ def _cone_mantle(gamma: float, lod: str) -> Surface3D:
481
+ """The cone of half-angle γ about −ĝ. Ruled along its generators."""
482
+ phi = np.linspace(0.0, 2.0 * np.pi, _CONE_NPHI[lod])
483
+ rim = np.array([np.sin(gamma) * np.cos(phi), np.sin(gamma) * np.sin(phi)])
484
+ s = np.array([0.0, 1.0])
485
+
486
+ return Surface3D(
487
+ x=np.outer(rim[0], s),
488
+ y=np.outer(rim[1], s),
489
+ z=np.outer(np.full_like(phi, np.cos(gamma)), s),
490
+ tag="cone",
491
+ lod=lod,
492
+ alpha=0.10,
493
+ label="cone of diffraction",
494
+ )
495
+
496
+
497
+ def _cone_rim(gamma: float, lod: str) -> Curve3D:
498
+ phi = np.linspace(0.0, 2.0 * np.pi, _CONE_NPHI[lod])
499
+ return Curve3D(
500
+ x=np.sin(gamma) * np.cos(phi),
501
+ y=np.sin(gamma) * np.sin(phi),
502
+ z=np.full_like(phi, np.cos(gamma)),
503
+ tag="cone",
504
+ lod=lod,
505
+ dashed=True,
506
+ )
507
+
508
+
509
+ def build_scene(
510
+ problem: "Problem",
511
+ illumination: "Illumination",
512
+ wavelength: float,
513
+ *,
514
+ periods: int = 2,
515
+ pad_orders: int = 2,
516
+ ) -> Scene:
517
+ """Everything one 3D drawing consists of."""
518
+ gamma = illumination.gamma
519
+ marks = order_marks(problem, illumination, wavelength, pad=pad_orders)
520
+ scale_nm = periods * problem.period / PATCH_SPAN
521
+ origin = (0.0, 0.0, 0.0)
522
+ rim_limits, rim_magnification, rim_reason = rim_box(gamma)
523
+
524
+ rays: list[Ray3D] = []
525
+ surfaces: list[Surface3D] = []
526
+ curves: list[Curve3D] = []
527
+ points: list[Point3D] = []
528
+
529
+ for lod in ("fine", "coarse"):
530
+ surfaces.append(_grating_patch(problem, scale_nm, periods, lod))
531
+ surfaces.append(_cone_mantle(gamma, lod))
532
+ curves.append(_cone_rim(gamma, lod))
533
+
534
+ # -- axes ------------------------------------------------------------
535
+ rays.append(Ray3D(origin, tuple(G_HAT), 0.85, "axis", label="ĝ (grooves)"))
536
+ rays.append(Ray3D(origin, tuple(CONE_AXIS), 1.0, "cone",
537
+ label="cone axis −ĝ", dashed=True))
538
+ rays.append(Ray3D(origin, tuple(D_HAT), 0.45, "axis", label="d̂"))
539
+ rays.append(Ray3D(origin, tuple(N_HAT), 0.45, "normal", label="n̂"))
540
+
541
+ nx, ny = facet_normal(problem)
542
+ rays.append(
543
+ Ray3D(origin, (float(nx), float(ny), 0.0), 0.5, "normal",
544
+ label="facet normal", dashed=True)
545
+ )
546
+
547
+ # -- the waves -------------------------------------------------------
548
+ incident = incident_vector(illumination.alpha, gamma)
549
+ rays.append(
550
+ Ray3D(
551
+ tuple(-incident), tuple(incident), 1.0, "incident", label="k̂ᵢ"
552
+ )
553
+ )
554
+
555
+ for mark in marks:
556
+ if mark.beta is None:
557
+ continue # no direction; it stays on the ladder beside this view
558
+ k = wave_vector(mark.beta, gamma)
559
+ rays.append(
560
+ Ray3D(
561
+ origin, tuple(k), 1.0,
562
+ "zero" if mark.order == 0 else "order",
563
+ # 3D text cannot dodge its neighbours, and every order tip is
564
+ # within 1.5 deg of the same point. Only m=0 is labelled here;
565
+ # the ladder beside this view names them all.
566
+ label=f"m={mark.order:+d}" if mark.order == 0 else None,
567
+ order=mark.order,
568
+ )
569
+ )
570
+ points.append(Point3D(*k, glyph="out-of-page", tag="cone"))
571
+ # Its shadow on the dispersion plane -- the 2D view, literally.
572
+ curves.append(
573
+ Curve3D(
574
+ np.array([0.0, k[0]]), np.array([0.0, k[1]]), np.zeros(2),
575
+ tag="axis", dashed=True,
576
+ )
577
+ )
578
+
579
+ blaze_angle = getattr(problem.profile, "blaze_angle", None)
580
+ if blaze_angle is not None:
581
+ beta_b = blaze_direction(np.radians(blaze_angle), illumination.alpha)
582
+ rays.append(
583
+ Ray3D(origin, tuple(wave_vector(beta_b, gamma)), 1.0, "blaze",
584
+ label=f"β_b = {np.degrees(beta_b):.1f}°")
585
+ )
586
+
587
+ points.append(Point3D(0.0, 0.0, 0.0, glyph="strike", tag="incident"))
588
+
589
+ return Scene(
590
+ rays=tuple(rays),
591
+ surfaces=tuple(surfaces),
592
+ curves=tuple(curves),
593
+ points=tuple(points),
594
+ orders=marks,
595
+ captions=captions3d(problem, illumination, wavelength, marks, scale_nm),
596
+ title=scene_title(gamma),
597
+ limits=(
598
+ (-BOX_HALF, BOX_HALF),
599
+ (-BOX_HALF, BOX_HALF),
600
+ (-BOX_HALF, BOX_HALF),
601
+ ),
602
+ rim_limits=rim_limits,
603
+ rim_magnification=rim_magnification,
604
+ rim_reason=rim_reason,
605
+ scale_nm=scale_nm,
606
+ gamma=float(gamma),
607
+ wavelength=float(wavelength),
608
+ blaze_order=None,
609
+ )