de-ebsd 0.2.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 (69) hide show
  1. de_ebsd/__init__.py +76 -0
  2. de_ebsd/core/__init__.py +23 -0
  3. de_ebsd/core/constants.py +81 -0
  4. de_ebsd/core/device.py +12 -0
  5. de_ebsd/core/geometry.py +67 -0
  6. de_ebsd/core/lambert.py +148 -0
  7. de_ebsd/core/rotation.py +139 -0
  8. de_ebsd/crystal/__init__.py +22 -0
  9. de_ebsd/crystal/data/__init__.py +0 -0
  10. de_ebsd/crystal/data/element_properties.py +29 -0
  11. de_ebsd/crystal/data/scattering_tables.py +414 -0
  12. de_ebsd/crystal/lattice.py +517 -0
  13. de_ebsd/crystal/scattering.py +339 -0
  14. de_ebsd/crystal/scattering_torch.py +233 -0
  15. de_ebsd/crystal/structure_factors.py +291 -0
  16. de_ebsd/diffraction/__init__.py +45 -0
  17. de_ebsd/diffraction/backends.py +318 -0
  18. de_ebsd/diffraction/bethe.py +28 -0
  19. de_ebsd/diffraction/kinematic.py +389 -0
  20. de_ebsd/diffraction/kvectors.py +120 -0
  21. de_ebsd/diffraction/prism.py +204 -0
  22. de_ebsd/diffraction/reflections.py +234 -0
  23. de_ebsd/index/__init__.py +61 -0
  24. de_ebsd/index/_kernels.py +222 -0
  25. de_ebsd/index/backproject.py +171 -0
  26. de_ebsd/index/crosscorr.py +654 -0
  27. de_ebsd/index/dictionary.py +362 -0
  28. de_ebsd/index/io.py +324 -0
  29. de_ebsd/index/live/__init__.py +5 -0
  30. de_ebsd/index/live/background.py +103 -0
  31. de_ebsd/index/live/calibration.py +70 -0
  32. de_ebsd/index/live/common.py +44 -0
  33. de_ebsd/index/live/deformation.py +289 -0
  34. de_ebsd/index/live/indexer.py +386 -0
  35. de_ebsd/index/preprocess.py +195 -0
  36. de_ebsd/index/refine/__init__.py +7 -0
  37. de_ebsd/index/refine/highres.py +479 -0
  38. de_ebsd/index/refine/levenberg.py +33 -0
  39. de_ebsd/index/refine/orientation.py +367 -0
  40. de_ebsd/index/refine/pattern_center.py +356 -0
  41. de_ebsd/index/spherical/__init__.py +41 -0
  42. de_ebsd/index/spherical/correlation.py +106 -0
  43. de_ebsd/index/spherical/harmonics.py +311 -0
  44. de_ebsd/index/spherical/indexer.py +345 -0
  45. de_ebsd/index/spherical/wigner.py +190 -0
  46. de_ebsd/sim/__init__.py +20 -0
  47. de_ebsd/sim/detector.py +145 -0
  48. de_ebsd/sim/masterpattern.py +231 -0
  49. de_ebsd/sim/microscope.py +93 -0
  50. de_ebsd/sim/montecarlo/__init__.py +19 -0
  51. de_ebsd/sim/montecarlo/depth.py +167 -0
  52. de_ebsd/sim/montecarlo/transport.py +532 -0
  53. de_ebsd/sim/simulator.py +291 -0
  54. de_ebsd/viz/__init__.py +53 -0
  55. de_ebsd/viz/camera.py +56 -0
  56. de_ebsd/viz/depth_widget.js +76 -0
  57. de_ebsd/viz/explorer.py +647 -0
  58. de_ebsd/viz/figures.py +67 -0
  59. de_ebsd/viz/maps.py +277 -0
  60. de_ebsd/viz/patterns.py +164 -0
  61. de_ebsd/viz/setup_widget.js +171 -0
  62. de_ebsd/viz/views.py +125 -0
  63. de_ebsd/viz/widgets.py +191 -0
  64. de_ebsd-0.2.0.dist-info/METADATA +251 -0
  65. de_ebsd-0.2.0.dist-info/RECORD +69 -0
  66. de_ebsd-0.2.0.dist-info/WHEEL +5 -0
  67. de_ebsd-0.2.0.dist-info/licenses/LICENSE +674 -0
  68. de_ebsd-0.2.0.dist-info/licenses/NOTICE +44 -0
  69. de_ebsd-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,647 @@
1
+ """Interactive, geometric STEM orientation explorer.
2
+
3
+ The explorer deliberately keeps the display layer small. The kinematic model
4
+ owns reflection generation and this module only projects its result into a
5
+ gnomonic canvas, draws a reciprocal-normal sphere, and wires anyplotlib events
6
+ to one scalar-first sample-to-crystal quaternion.
7
+ """
8
+
9
+ # The anyplotlib calls below are intentionally kept visually grouped; several
10
+ # pass descriptive labels alongside their geometry.
11
+ # ruff: noqa: E501,E701
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ from typing import Any
17
+
18
+ import numpy as np
19
+ import torch
20
+
21
+ from de_ebsd.core.rotation import axis_angle_matrix, matrix_to_quaternion, quaternion_to_matrix
22
+ from de_ebsd.diffraction.kinematic import orientation_from_zone_axis
23
+ from de_ebsd.viz.camera import band_path, camera_angles, camera_direction, camera_frame
24
+ from de_ebsd.viz.figures import apl
25
+
26
+ __all__ = ["CrystalExplorer"]
27
+
28
+
29
+ def _quaternion_to_matrix(q: np.ndarray) -> np.ndarray:
30
+ return quaternion_to_matrix(torch.as_tensor(q, dtype=torch.float64)).numpy()
31
+
32
+
33
+ _IDENTITY = np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float64)
34
+
35
+
36
+ def _as_quaternion(value: Any) -> np.ndarray:
37
+ """Return one normalized scalar-first quaternion, rejecting bad rotations."""
38
+ data = getattr(value, "data", value)
39
+ q = np.asarray(data, dtype=np.float64)
40
+ if q.size != 4:
41
+ raise ValueError("orientation must contain exactly one quaternion of shape (4,)")
42
+ q = q.reshape(4)
43
+ if not np.isfinite(q).all():
44
+ raise ValueError("orientation must contain finite values")
45
+ norm = float(np.linalg.norm(q))
46
+ if norm < 1e-12:
47
+ raise ValueError("orientation quaternion must have non-zero norm")
48
+ q = q / norm
49
+ # A single sign makes programmatic comparisons and widget state stable.
50
+ return q if q[0] >= 0 else -q
51
+
52
+
53
+ class CrystalExplorer:
54
+ """Linked STEM orientation explorer with geometric bands and kinematic spots."""
55
+
56
+ def __init__(
57
+ self,
58
+ crystal,
59
+ energy_kev: float = 200.0,
60
+ dmin: float = 0.1,
61
+ *,
62
+ semiangle_deg: float = 5.0,
63
+ excitation_width: float = 0.1,
64
+ orientation=None,
65
+ model=None,
66
+ show_lines: bool = True,
67
+ show_spots: bool = True,
68
+ show_labels: bool = True,
69
+ name: str | None = None,
70
+ ):
71
+ self.crystal = crystal
72
+ self.name = name or "STEM crystal explorer"
73
+ self._energy_kev = self._positive(energy_kev, "energy_kev")
74
+ self._dmin = self._positive(dmin, "dmin")
75
+ self._semiangle_deg = self._fov(semiangle_deg)
76
+ self._excitation_width = self._positive(excitation_width, "excitation_width")
77
+ self.model = model
78
+ if self.model is None:
79
+ from de_ebsd.diffraction.kinematic import KinematicPatternModel
80
+
81
+ self.model = KinematicPatternModel(
82
+ crystal, energy_kev=self._energy_kev, dmin=self._dmin
83
+ )
84
+ self._model_dmin = self._dmin
85
+ self._orientation = _as_quaternion(_IDENTITY if orientation is None else orientation)
86
+ self._base_orientation = self._orientation.copy()
87
+ self._zone_axis: np.ndarray | None = None
88
+ self._roll_deg = 0.0
89
+ self._roll_deg_before = 0.0
90
+ self._tilt_x_deg = 0.0
91
+ self._tilt_y_deg = 0.0
92
+ self._show_lines = bool(show_lines)
93
+ self._show_spots = bool(show_spots)
94
+ self._show_labels = bool(show_labels)
95
+ self._figure = None
96
+ self._projection_ax = self._sphere_ax = self._crystal_ax = None
97
+ self._line_artist = self._spot_artist = self._beam_artist = self._label_artist = None
98
+ self._sphere_artist = self._crystal_artist = None
99
+ self._last_result = None
100
+ self._updating = False
101
+ self._camera = (0.0, 90.0)
102
+ self._initial_energy_kev = self._energy_kev
103
+ self._initial_dmin = self._dmin
104
+ self._initial_semiangle_deg = self._semiangle_deg
105
+ self._initial_show_lines = self._show_lines
106
+ self._initial_show_spots = self._show_spots
107
+ self._initial_show_labels = self._show_labels
108
+ self._initial_orientation = self._orientation.copy()
109
+ self._initial_roll_deg = self._roll_deg
110
+ self._initial_tilt = (self._tilt_x_deg, self._tilt_y_deg)
111
+
112
+ @staticmethod
113
+ def _positive(value, name):
114
+ value = float(value)
115
+ if not np.isfinite(value) or value <= 0:
116
+ raise ValueError(f"{name} must be finite and > 0")
117
+ return value
118
+
119
+ @staticmethod
120
+ def _fov(value):
121
+ value = float(value)
122
+ if not np.isfinite(value) or value <= 0 or value >= 89:
123
+ raise ValueError("semiangle_deg must be finite and in (0, 89)")
124
+ return value
125
+
126
+ @property
127
+ def orientation(self) -> np.ndarray:
128
+ return self._orientation.copy()
129
+
130
+ @property
131
+ def energy_kev(self) -> float:
132
+ return self._energy_kev
133
+
134
+ @property
135
+ def dmin(self) -> float:
136
+ return self._dmin
137
+
138
+ @property
139
+ def semiangle_deg(self) -> float:
140
+ return self._semiangle_deg
141
+
142
+ @property
143
+ def excitation_width(self) -> float:
144
+ return self._excitation_width
145
+
146
+ @property
147
+ def roll_deg(self) -> float:
148
+ return self._roll_deg
149
+
150
+ @property
151
+ def fine_tilt(self) -> tuple[float, float]:
152
+ return self._tilt_x_deg, self._tilt_y_deg
153
+
154
+ @property
155
+ def zone_axis(self):
156
+ return None if self._zone_axis is None else self._zone_axis.copy()
157
+
158
+ @property
159
+ def show_lines(self) -> bool:
160
+ return self._show_lines
161
+
162
+ @property
163
+ def show_spots(self) -> bool:
164
+ return self._show_spots
165
+
166
+ @property
167
+ def show_labels(self) -> bool:
168
+ return self._show_labels
169
+
170
+ @property
171
+ def figure(self):
172
+ if self._figure is None:
173
+ self._make_figure()
174
+ return self._figure
175
+
176
+ @property
177
+ def figures(self) -> dict[str, object]:
178
+ return {"explorer": self.figure}
179
+
180
+ @property
181
+ def result(self):
182
+ """Most recent model projection result, useful to host integrations."""
183
+ _ = self.figure
184
+ return self._last_result
185
+
186
+ def _make_figure(self):
187
+ fig, axes = apl().subplots(1, 3, figsize=(900, 410), width_ratios=[1.35, 1.0, 1.0])
188
+ self._figure = fig
189
+ self._projection_ax, self._sphere_ax, self._crystal_ax = np.ravel(axes)[:3]
190
+ bound = math.tan(math.radians(self._semiangle_deg))
191
+ self._projection_ax = self._projection_ax.axes2d(
192
+ xlim=(-bound, bound),
193
+ ylim=(-bound, bound),
194
+ aspect="equal",
195
+ units="gnomonic slope",
196
+ y_units="gnomonic slope",
197
+ )
198
+ self._projection_ax.set_xlabel("x/z (right)")
199
+ self._projection_ax.set_ylabel("y/z (up)")
200
+ self._projection_ax.set_title("Kikuchi lines + kinematic spots")
201
+
202
+ normals = np.asarray(
203
+ getattr(self.model, "band_normals", np.empty((0, 3))), dtype=float
204
+ ).reshape(-1, 3)
205
+ band_path_points = band_path(normals)
206
+ self._sphere_artist = self._sphere_ax.scatter3d(
207
+ band_path_points[:, 0],
208
+ band_path_points[:, 1],
209
+ band_path_points[:, 2],
210
+ color="#48d1cc",
211
+ point_size=1.4,
212
+ bounds=((-1, 1),) * 3,
213
+ x_label="crystal x",
214
+ y_label="crystal y",
215
+ z_label="crystal z",
216
+ azimuth=0,
217
+ elevation=90,
218
+ gpu="auto",
219
+ )
220
+ self._sphere_artist.set_sphere(radius=1, color="#9aa4b2", alpha=0.12, wireframe=True)
221
+ self._sphere_artist.set_title("Band-line sphere (drag to tilt)")
222
+ # CallbackRegistry.connect accepts bound methods directly. The
223
+ # decorator-style anyplotlib API annotates callbacks and therefore
224
+ # cannot annotate a bound method on Python 3.12.
225
+ self._sphere_artist.callbacks.connect("pointer_move", self._on_sphere_event)
226
+ self._sphere_artist.callbacks.connect("pointer_up", self._on_sphere_event)
227
+ # Draw each reciprocal-plane great circle as a separate native 3-D
228
+ # line. Keeping these static means camera drags never regenerate the
229
+ # reflection set and the sphere remains useful when lines are hidden
230
+ # in the central projection.
231
+
232
+ cart = self._crystal_cartesian()
233
+ self._crystal_artist = self._crystal_ax.scatter3d(
234
+ cart[:, 0] if len(cart) else [],
235
+ cart[:, 1] if len(cart) else [],
236
+ cart[:, 2] if len(cart) else [],
237
+ color="#56b4e9",
238
+ point_size=6,
239
+ bounds=self._crystal_bounds(cart),
240
+ x_label="crystal x (nm)",
241
+ y_label="crystal y (nm)",
242
+ z_label="crystal z (nm)",
243
+ azimuth=-55,
244
+ elevation=24,
245
+ gpu="auto",
246
+ )
247
+ self._crystal_artist.set_title("Crystal (linked orientation)")
248
+ self._recompute(push=False)
249
+
250
+ def _crystal_bounds(self, cart):
251
+ if len(cart) == 0:
252
+ return ((-1, 1),) * 3
253
+ radius = max(float(np.max(np.linalg.norm(cart, axis=1))) * 1.12, 1e-6)
254
+ return ((-radius, radius),) * 3
255
+
256
+ def _crystal_cartesian(self):
257
+ parts = [np.asarray(p, dtype=float) for p in getattr(self.crystal, "positions", ())]
258
+ if not parts:
259
+ return np.empty((0, 3))
260
+ frac = np.concatenate(parts, axis=0)
261
+ return frac @ np.asarray(self.crystal.direct_structure_matrix, dtype=float).T
262
+
263
+ def _apply_tilts(self):
264
+ rx = axis_angle_matrix(np.array([1.0, 0.0, 0.0]), self._tilt_x_deg)
265
+ ry = axis_angle_matrix(np.array([0.0, 1.0, 0.0]), self._tilt_y_deg)
266
+ return self._base_orientation_matrix @ rx @ ry
267
+
268
+ @property
269
+ def _base_orientation_matrix(self):
270
+ return _quaternion_to_matrix(self._base_orientation)
271
+
272
+ def _recompute(self, *, push=True):
273
+ if self._updating:
274
+ return
275
+ self._updating = True
276
+ try:
277
+ self._orientation = matrix_to_quaternion(self._apply_tilts())
278
+ self._last_result = self.model.project(
279
+ self._orientation,
280
+ semiangle_deg=self._semiangle_deg,
281
+ excitation_width=self._excitation_width,
282
+ )
283
+ if self._projection_ax is not None:
284
+ lines = np.asarray(
285
+ getattr(self._last_result, "lines", np.empty((0, 2, 2))), dtype=float
286
+ ).reshape(-1, 2, 2)
287
+ spots = np.asarray(
288
+ getattr(self._last_result, "spots", np.empty((0, 2))), dtype=float
289
+ ).reshape(-1, 2)
290
+ intensities = np.asarray(
291
+ getattr(self._last_result, "intensities", np.ones(len(spots))), dtype=float
292
+ ).reshape(-1)
293
+ if self._line_artist is None:
294
+ self._line_artist = self._projection_ax.add_lines(
295
+ lines if self._show_lines else np.empty((0, 2, 2)),
296
+ name="kikuchi_lines",
297
+ edgecolors="#48d1cc",
298
+ linewidths=1.2,
299
+ label="geometric Kikuchi lines",
300
+ )
301
+ else:
302
+ self._line_artist.set(
303
+ segments=lines if self._show_lines else np.empty((0, 2, 2))
304
+ )
305
+ sizes = 2.5 + 4.0 * np.sqrt(
306
+ np.clip(intensities, 0, None)
307
+ / max(float(np.max(intensities)) if len(intensities) else 1, 1e-12)
308
+ )
309
+ if self._spot_artist is None:
310
+ self._spot_artist = self._projection_ax.add_circles(
311
+ spots if self._show_spots else np.empty((0, 2)),
312
+ name="kinematic_spots",
313
+ radius=sizes if self._show_spots else np.empty((0,)),
314
+ facecolors="#f48fb1",
315
+ edgecolors="#f48fb1",
316
+ alpha=0.85,
317
+ labels=[str(x) for x in getattr(self._last_result, "spot_hkl", [])]
318
+ if self._show_labels
319
+ else [],
320
+ label="kinematic spots",
321
+ )
322
+ else:
323
+ self._spot_artist.set(
324
+ offsets=spots if self._show_spots else np.empty((0, 2)),
325
+ sizes=sizes if self._show_spots else np.empty((0,)),
326
+ labels=[str(x) for x in getattr(self._last_result, "spot_hkl", [])]
327
+ if self._show_labels
328
+ else [],
329
+ )
330
+ if self._beam_artist is None:
331
+ self._beam_artist = self._projection_ax.add_circles(
332
+ [[0.0, 0.0]] if self._show_spots else np.empty((0, 2)),
333
+ name="direct_beam",
334
+ radius=3,
335
+ edgecolors="#ffffff",
336
+ linewidths=1.0,
337
+ label="direct beam",
338
+ )
339
+ self._beam_artist.set(
340
+ offsets=[[0.0, 0.0]] if self._show_spots else np.empty((0, 2)), sizes=3
341
+ )
342
+ spot_hkl = getattr(self._last_result, "spot_hkl", [])
343
+ label_offsets = (
344
+ spots if self._show_spots and self._show_labels else np.empty((0, 2))
345
+ )
346
+ label_texts = (
347
+ ["(" + ",".join(str(int(v)) for v in row) + ")" for row in spot_hkl]
348
+ if self._show_spots and self._show_labels
349
+ else []
350
+ )
351
+ if self._label_artist is None:
352
+ self._label_artist = self._projection_ax.add_texts(
353
+ label_offsets, label_texts, name="spot_labels", color="#f48fb1", fontsize=10
354
+ )
355
+ else:
356
+ self._label_artist.set(offsets=label_offsets, texts=label_texts)
357
+ self._update_linked_views()
358
+ finally:
359
+ self._updating = False
360
+
361
+ def _update_linked_views(self):
362
+ if self._crystal_artist is not None:
363
+ cart = self._crystal_cartesian() @ self._quaternion_matrix_inverse().T
364
+ self._crystal_artist.set_data(cart[:, 0], cart[:, 1], cart[:, 2])
365
+ if self._sphere_artist is not None:
366
+ beam = _quaternion_to_matrix(self._orientation)[:, 2]
367
+ self._sphere_artist.set_highlight(
368
+ float(beam[0]), float(beam[1]), float(beam[2]), color="#ffffff", size=8
369
+ )
370
+
371
+ def _quaternion_matrix_inverse(self):
372
+ return _quaternion_to_matrix(self._orientation).T
373
+
374
+ def set_orientation(self, orientation):
375
+ self._orientation = _as_quaternion(orientation)
376
+ self._base_orientation = self._orientation.copy()
377
+ self._zone_axis = None
378
+ self._tilt_x_deg = self._tilt_y_deg = 0.0
379
+ self._roll_deg = 0.0
380
+ self._roll_deg_before = 0.0
381
+ self._set_camera_for_beam(_quaternion_to_matrix(self._orientation)[:, 2])
382
+ self._recompute()
383
+ return self
384
+
385
+ def set_zone_axis(self, uvw, roll_deg: float | None = None):
386
+ uvw = np.asarray(uvw, dtype=float).reshape(-1)
387
+ if uvw.size != 3:
388
+ raise ValueError("zone axis must have shape (3,)")
389
+ self._zone_axis = uvw.copy()
390
+ if roll_deg is not None:
391
+ roll_deg = float(roll_deg)
392
+ if not np.isfinite(roll_deg):
393
+ raise ValueError("roll_deg must be finite")
394
+ self._roll_deg = roll_deg
395
+ self._roll_deg_before = self._roll_deg
396
+ self._base_orientation = orientation_from_zone_axis(self.crystal, uvw, self._roll_deg)
397
+ self._tilt_x_deg = self._tilt_y_deg = 0.0
398
+ self._set_camera_for_beam(_quaternion_to_matrix(self._base_orientation)[:, 2])
399
+ self._recompute()
400
+ return self
401
+
402
+ def set_roll(self, roll_deg: float):
403
+ roll_deg = float(roll_deg)
404
+ if not np.isfinite(roll_deg):
405
+ raise ValueError("roll_deg must be finite")
406
+ matrix = _quaternion_to_matrix(self._orientation)
407
+ beam = matrix[:, 2]
408
+ self._roll_deg = roll_deg
409
+ # Plot3D has no camera-roll state. Apply roll about the current beam
410
+ # directly and fold it into the authoritative orientation, clearing
411
+ # the two convenience tilt offsets so changing roll cannot move +z.
412
+ delta = roll_deg - getattr(self, "_roll_deg_before", 0.0)
413
+ self._orientation = matrix_to_quaternion(axis_angle_matrix(beam, delta) @ matrix)
414
+ self._base_orientation = self._orientation.copy()
415
+ self._tilt_x_deg = self._tilt_y_deg = 0.0
416
+ self._roll_deg_before = roll_deg
417
+ self._recompute()
418
+ return self
419
+
420
+ def set_fine_tilt(self, tilt_x_deg: float = 0.0, tilt_y_deg: float = 0.0):
421
+ tx, ty = float(tilt_x_deg), float(tilt_y_deg)
422
+ if not np.isfinite([tx, ty]).all():
423
+ raise ValueError("fine tilt values must be finite")
424
+ self._tilt_x_deg, self._tilt_y_deg = tx, ty
425
+ self._recompute()
426
+ return self
427
+
428
+ set_tilt = set_fine_tilt
429
+
430
+ def set_energy(self, energy_kev: float):
431
+ self._energy_kev = self._positive(energy_kev, "energy_kev")
432
+ try:
433
+ self.model.energy_kev = self._energy_kev
434
+ except Exception:
435
+ from de_ebsd.diffraction.kinematic import KinematicPatternModel
436
+
437
+ self.model = KinematicPatternModel(
438
+ self.crystal, energy_kev=self._energy_kev, dmin=self._dmin
439
+ )
440
+ self._recompute()
441
+ return self
442
+
443
+ def set_dmin(self, dmin: float):
444
+ self._dmin = self._positive(dmin, "dmin")
445
+ from de_ebsd.diffraction.kinematic import KinematicPatternModel
446
+
447
+ self.model = KinematicPatternModel(
448
+ self.crystal, energy_kev=self._energy_kev, dmin=self._dmin
449
+ )
450
+ self._model_dmin = self._dmin
451
+ if self._sphere_artist is not None:
452
+ normals = np.asarray(
453
+ getattr(self.model, "band_normals", np.empty((0, 3))), dtype=float
454
+ ).reshape(-1, 3)
455
+ path = band_path(normals)
456
+ self._sphere_artist.set_data(path[:, 0], path[:, 1], path[:, 2])
457
+ self._recompute()
458
+ return self
459
+
460
+ def set_semiangle(self, semiangle_deg: float):
461
+ self._semiangle_deg = self._fov(semiangle_deg)
462
+ if self._projection_ax is not None:
463
+ bound = math.tan(math.radians(self._semiangle_deg))
464
+ self._projection_ax.set_xlim(-bound, bound)
465
+ self._projection_ax.set_ylim(-bound, bound)
466
+ self._recompute()
467
+ return self
468
+
469
+ def set_excitation_width(self, excitation_width: float):
470
+ self._excitation_width = self._positive(excitation_width, "excitation_width")
471
+ self._recompute()
472
+ return self
473
+
474
+ def set_show_lines(self, visible: bool):
475
+ self._show_lines = bool(visible)
476
+ self._recompute()
477
+ return self
478
+
479
+ def set_show_spots(self, visible: bool):
480
+ self._show_spots = bool(visible)
481
+ self._recompute()
482
+ return self
483
+
484
+ def set_show_labels(self, visible: bool):
485
+ self._show_labels = bool(visible)
486
+ self._recompute()
487
+ return self
488
+
489
+ def reset(self):
490
+ self._energy_kev, self._dmin, self._semiangle_deg = (
491
+ self._initial_energy_kev,
492
+ self._initial_dmin,
493
+ self._initial_semiangle_deg,
494
+ )
495
+ self._show_lines, self._show_spots, self._show_labels = (
496
+ self._initial_show_lines,
497
+ self._initial_show_spots,
498
+ self._initial_show_labels,
499
+ )
500
+ self._zone_axis = None
501
+ self._roll_deg = self._tilt_x_deg = self._tilt_y_deg = 0.0
502
+ self._roll_deg_before = self._initial_roll_deg
503
+ self._base_orientation = self._initial_orientation.copy()
504
+ self._orientation = self._initial_orientation.copy()
505
+ if self._dmin != self._model_dmin:
506
+ from de_ebsd.diffraction.kinematic import KinematicPatternModel
507
+
508
+ self.model = KinematicPatternModel(
509
+ self.crystal, energy_kev=self._energy_kev, dmin=self._dmin
510
+ )
511
+ self._model_dmin = self._dmin
512
+ else:
513
+ try:
514
+ self.model.energy_kev = self._energy_kev
515
+ except Exception:
516
+ pass
517
+ if self._projection_ax is not None:
518
+ bound = math.tan(math.radians(self._semiangle_deg))
519
+ self._projection_ax.set_xlim(-bound, bound)
520
+ self._projection_ax.set_ylim(-bound, bound)
521
+ self._set_camera_for_beam(np.array([0.0, 0.0, 1.0]))
522
+ self._recompute()
523
+ return self
524
+
525
+ def _set_camera_for_beam(self, beam):
526
+ self._camera = camera_angles(beam)
527
+ if self._sphere_artist is not None:
528
+ self._sphere_artist.set_view(*self._camera)
529
+
530
+ def _on_sphere_event(self, event):
531
+ if self._updating:
532
+ return
533
+ az = getattr(event, "azimuth", None)
534
+ el = getattr(event, "elevation", None)
535
+ if az is None or el is None or not np.isfinite([az, el]).all():
536
+ return
537
+ self._camera = (float(az), float(el))
538
+ beam = camera_direction(float(az), float(el))
539
+ self._base_orientation = matrix_to_quaternion(
540
+ axis_angle_matrix(beam, self._roll_deg) @ camera_frame(float(az), float(el))
541
+ )
542
+ self._zone_axis = None
543
+ self._tilt_x_deg = self._tilt_y_deg = 0.0
544
+ # anyplotlib does not merge camera fields into Plot3D state. Push the
545
+ # received angles back so the next Python update cannot snap the view.
546
+ self._sphere_artist.set_view(float(az), float(el))
547
+ self._recompute()
548
+
549
+ @property
550
+ def controls(self):
551
+ """Notebook controls; values call the public setters immediately."""
552
+ if hasattr(self, "_controls"):
553
+ return self._controls
554
+ try:
555
+ import ipywidgets as widgets
556
+ except ImportError:
557
+ self._controls = None
558
+ return None
559
+
560
+ def compact(control):
561
+ control.layout = widgets.Layout(width="145px")
562
+ return control
563
+
564
+ energy = compact(widgets.FloatText(value=self.energy_kev, description="keV"))
565
+ fov = compact(widgets.FloatText(value=self.semiangle_deg, description="± angle °"))
566
+ dmin = compact(widgets.FloatText(value=self.dmin, description="dmin nm"))
567
+ zone = compact(widgets.Text(value="", description="zone [uvw]"))
568
+ roll = compact(widgets.FloatText(value=self.roll_deg, description="roll °"))
569
+ tx = compact(widgets.FloatText(value=0.0, description="tilt x °"))
570
+ ty = compact(widgets.FloatText(value=0.0, description="tilt y °"))
571
+ lines = widgets.Checkbox(value=self.show_lines, description="Kikuchi lines")
572
+ spots = widgets.Checkbox(value=self.show_spots, description="Kinematic spots")
573
+ labels = widgets.Checkbox(value=self.show_labels, description="hkl labels")
574
+ error = widgets.Label(value="", layout=widgets.Layout(color="#d55e00", width="100%"))
575
+ reset = widgets.Button(description="Reset", button_style="")
576
+
577
+ def observe(change):
578
+ if change["name"] != "value" or self._updating:
579
+ return
580
+ owner = change["owner"]
581
+ try:
582
+ if owner is energy:
583
+ self.set_energy(change["new"])
584
+ elif owner is fov:
585
+ self.set_semiangle(change["new"])
586
+ elif owner is dmin:
587
+ self.set_dmin(change["new"])
588
+ elif owner is roll:
589
+ self.set_roll(change["new"])
590
+ elif owner is tx or owner is ty:
591
+ self.set_fine_tilt(tx.value, ty.value)
592
+ elif owner is lines:
593
+ self.set_show_lines(change["new"])
594
+ elif owner is spots:
595
+ self.set_show_spots(change["new"])
596
+ elif owner is labels:
597
+ self.set_show_labels(change["new"])
598
+ elif owner is zone and str(change["new"]).strip():
599
+ self.set_zone_axis(
600
+ [float(x) for x in str(change["new"]).replace(",", " ").split()]
601
+ )
602
+ error.value = ""
603
+ except (TypeError, ValueError) as exc:
604
+ error.value = str(exc)
605
+
606
+ def do_reset(_):
607
+ self.reset()
608
+ energy.value, dmin.value, fov.value = self.energy_kev, self.dmin, self.semiangle_deg
609
+ roll.value, tx.value, ty.value = self.roll_deg, self._tilt_x_deg, self._tilt_y_deg
610
+ lines.value, spots.value, labels.value = (
611
+ self.show_lines,
612
+ self.show_spots,
613
+ self.show_labels,
614
+ )
615
+ zone.value, error.value = "", ""
616
+
617
+ reset.on_click(do_reset)
618
+ for control in (energy, fov, dmin, zone, roll, tx, ty, lines, spots):
619
+ control.observe(observe)
620
+ self._controls = widgets.VBox(
621
+ [
622
+ widgets.HBox([energy, dmin, fov], layout=widgets.Layout(flex_flow="row wrap")),
623
+ widgets.HBox([zone, roll, tx, ty], layout=widgets.Layout(flex_flow="row wrap")),
624
+ widgets.HBox([lines, spots, labels, reset]),
625
+ error,
626
+ ]
627
+ )
628
+ return self._controls
629
+
630
+ def _ipython_display_(self):
631
+ from IPython.display import display
632
+
633
+ if self.controls is not None:
634
+ display(self.controls)
635
+ display(self.figure)
636
+
637
+ def __repr__(self):
638
+ return f"CrystalExplorer(energy_kev={self.energy_kev:g}, dmin={self.dmin:g}, orientation={self.orientation.tolist()!r})"
639
+
640
+ def to_html(self):
641
+ """Export the native figure with a clear live-kernel limitation note."""
642
+ note = (
643
+ "<p style='font:12px system-ui;color:#666'>"
644
+ "This exported view is a static snapshot; Python pattern recalculation "
645
+ "requires a live Jupyter or SpyDE kernel.</p>"
646
+ )
647
+ return "<!doctype html><meta charset='utf-8'>" + note + self.figure.to_html()