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.
- de_ebsd/__init__.py +76 -0
- de_ebsd/core/__init__.py +23 -0
- de_ebsd/core/constants.py +81 -0
- de_ebsd/core/device.py +12 -0
- de_ebsd/core/geometry.py +67 -0
- de_ebsd/core/lambert.py +148 -0
- de_ebsd/core/rotation.py +139 -0
- de_ebsd/crystal/__init__.py +22 -0
- de_ebsd/crystal/data/__init__.py +0 -0
- de_ebsd/crystal/data/element_properties.py +29 -0
- de_ebsd/crystal/data/scattering_tables.py +414 -0
- de_ebsd/crystal/lattice.py +517 -0
- de_ebsd/crystal/scattering.py +339 -0
- de_ebsd/crystal/scattering_torch.py +233 -0
- de_ebsd/crystal/structure_factors.py +291 -0
- de_ebsd/diffraction/__init__.py +45 -0
- de_ebsd/diffraction/backends.py +318 -0
- de_ebsd/diffraction/bethe.py +28 -0
- de_ebsd/diffraction/kinematic.py +389 -0
- de_ebsd/diffraction/kvectors.py +120 -0
- de_ebsd/diffraction/prism.py +204 -0
- de_ebsd/diffraction/reflections.py +234 -0
- de_ebsd/index/__init__.py +61 -0
- de_ebsd/index/_kernels.py +222 -0
- de_ebsd/index/backproject.py +171 -0
- de_ebsd/index/crosscorr.py +654 -0
- de_ebsd/index/dictionary.py +362 -0
- de_ebsd/index/io.py +324 -0
- de_ebsd/index/live/__init__.py +5 -0
- de_ebsd/index/live/background.py +103 -0
- de_ebsd/index/live/calibration.py +70 -0
- de_ebsd/index/live/common.py +44 -0
- de_ebsd/index/live/deformation.py +289 -0
- de_ebsd/index/live/indexer.py +386 -0
- de_ebsd/index/preprocess.py +195 -0
- de_ebsd/index/refine/__init__.py +7 -0
- de_ebsd/index/refine/highres.py +479 -0
- de_ebsd/index/refine/levenberg.py +33 -0
- de_ebsd/index/refine/orientation.py +367 -0
- de_ebsd/index/refine/pattern_center.py +356 -0
- de_ebsd/index/spherical/__init__.py +41 -0
- de_ebsd/index/spherical/correlation.py +106 -0
- de_ebsd/index/spherical/harmonics.py +311 -0
- de_ebsd/index/spherical/indexer.py +345 -0
- de_ebsd/index/spherical/wigner.py +190 -0
- de_ebsd/sim/__init__.py +20 -0
- de_ebsd/sim/detector.py +145 -0
- de_ebsd/sim/masterpattern.py +231 -0
- de_ebsd/sim/microscope.py +93 -0
- de_ebsd/sim/montecarlo/__init__.py +19 -0
- de_ebsd/sim/montecarlo/depth.py +167 -0
- de_ebsd/sim/montecarlo/transport.py +532 -0
- de_ebsd/sim/simulator.py +291 -0
- de_ebsd/viz/__init__.py +53 -0
- de_ebsd/viz/camera.py +56 -0
- de_ebsd/viz/depth_widget.js +76 -0
- de_ebsd/viz/explorer.py +647 -0
- de_ebsd/viz/figures.py +67 -0
- de_ebsd/viz/maps.py +277 -0
- de_ebsd/viz/patterns.py +164 -0
- de_ebsd/viz/setup_widget.js +171 -0
- de_ebsd/viz/views.py +125 -0
- de_ebsd/viz/widgets.py +191 -0
- de_ebsd-0.2.0.dist-info/METADATA +251 -0
- de_ebsd-0.2.0.dist-info/RECORD +69 -0
- de_ebsd-0.2.0.dist-info/WHEEL +5 -0
- de_ebsd-0.2.0.dist-info/licenses/LICENSE +674 -0
- de_ebsd-0.2.0.dist-info/licenses/NOTICE +44 -0
- de_ebsd-0.2.0.dist-info/top_level.txt +1 -0
de_ebsd/viz/figures.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Shared anyplotlib primitives.
|
|
2
|
+
|
|
3
|
+
Every ``.show()`` in de_ebsd renders through :mod:`de_ebsd.viz`, so the
|
|
4
|
+
package has one way to draw. Helpers return an ``anyplotlib.Figure``:
|
|
5
|
+
interactive in Jupyter, exportable via ``figure.save_html(path)``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
__all__ = ["apl", "bar_figure", "image_figure", "label", "to_uint8_rgb"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def apl():
|
|
16
|
+
"""Import anyplotlib lazily so headless jobs never pay for the display stack."""
|
|
17
|
+
try:
|
|
18
|
+
import anyplotlib
|
|
19
|
+
except ImportError as error: # pragma: no cover
|
|
20
|
+
raise ImportError(
|
|
21
|
+
"anyplotlib is required for .show(); install it with `pip install anyplotlib`"
|
|
22
|
+
) from error
|
|
23
|
+
return anyplotlib
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def label(panel, title: str) -> None:
|
|
27
|
+
"""Attach a title to a plot panel if the panel supports one."""
|
|
28
|
+
if not title:
|
|
29
|
+
return
|
|
30
|
+
for method in ("set_title", "set_label"):
|
|
31
|
+
setter = getattr(panel, method, None)
|
|
32
|
+
if callable(setter):
|
|
33
|
+
try:
|
|
34
|
+
setter(title)
|
|
35
|
+
except Exception: # noqa: BLE001 - a missing title must never break .show()
|
|
36
|
+
pass
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def to_uint8_rgb(image: np.ndarray) -> np.ndarray:
|
|
41
|
+
"""Normalize a 2D array to a grayscale (H, W, 3) uint8 texture."""
|
|
42
|
+
image = np.asarray(image, dtype=np.float64)
|
|
43
|
+
lo, hi = float(np.nanmin(image)), float(np.nanmax(image))
|
|
44
|
+
scale = 255.0 / (hi - lo) if hi > lo else 0.0
|
|
45
|
+
gray = ((image - lo) * scale).astype(np.uint8)
|
|
46
|
+
return np.repeat(gray[..., None], 3, axis=-1)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def bar_figure(values, labels, title: str = "", colors=None):
|
|
50
|
+
"""Bar chart of one value per labeled item (structure factors, reflections)."""
|
|
51
|
+
values = np.asarray(values, dtype=np.float64)
|
|
52
|
+
fig, ax = apl().subplots(1, 1, figsize=(max(560, 18 * len(values)), 340))
|
|
53
|
+
kwargs = {"x_labels": list(labels), "show_values": False}
|
|
54
|
+
if colors is not None:
|
|
55
|
+
kwargs["colors"] = list(colors)
|
|
56
|
+
else:
|
|
57
|
+
kwargs["color"] = "#4fc3f7"
|
|
58
|
+
label(ax.bar(values, **kwargs), title)
|
|
59
|
+
return fig
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def image_figure(image: np.ndarray, title: str = "", cmap: str = "gray"):
|
|
63
|
+
"""Single image panel. Row 0 is drawn at the bottom (Lambert +Y up)."""
|
|
64
|
+
image = np.asarray(image, dtype=np.float64)
|
|
65
|
+
fig, ax = apl().subplots(1, 1, figsize=(520, 520))
|
|
66
|
+
label(ax.imshow(image, cmap=cmap, origin="lower"), title)
|
|
67
|
+
return fig
|
de_ebsd/viz/maps.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Indexing result figures: pattern match, orientation map, SO(3) correlation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from de_ebsd.core.constants import DIVISION_FLOOR
|
|
8
|
+
from de_ebsd.viz.figures import apl, label
|
|
9
|
+
|
|
10
|
+
__all__ = ["show_correlation", "show_map", "show_match", "show_refinement"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _to_numpy(x) -> np.ndarray:
|
|
14
|
+
detach = getattr(x, "detach", None)
|
|
15
|
+
if detach is not None:
|
|
16
|
+
x = detach().cpu().numpy()
|
|
17
|
+
return np.asarray(x, dtype=np.float64)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _to_image(x) -> np.ndarray:
|
|
21
|
+
image = np.squeeze(_to_numpy(x))
|
|
22
|
+
if image.ndim != 2:
|
|
23
|
+
raise ValueError(f"expected a 2-D pattern, got shape {image.shape}")
|
|
24
|
+
return image
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _standardize(image: np.ndarray) -> np.ndarray:
|
|
28
|
+
image = image - image.mean()
|
|
29
|
+
std = float(image.std())
|
|
30
|
+
return image / std if std > DIVISION_FLOOR else image
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _ncc(a: np.ndarray, b: np.ndarray) -> float:
|
|
34
|
+
a0 = a - a.mean()
|
|
35
|
+
b0 = b - b.mean()
|
|
36
|
+
na = float(np.linalg.norm(a0))
|
|
37
|
+
nb = float(np.linalg.norm(b0))
|
|
38
|
+
if na < DIVISION_FLOOR or nb < DIVISION_FLOOR:
|
|
39
|
+
return 0.0
|
|
40
|
+
return float(np.vdot(a0, b0) / (na * nb))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def show_match(experimental, master, detector, orientation, energy_kev=None):
|
|
44
|
+
"""The indexing sanity check: experimental vs simulated pattern at the fit.
|
|
45
|
+
|
|
46
|
+
Renders three panels side by side -- the experimental pattern, the pattern
|
|
47
|
+
:meth:`de_ebsd.sim.MasterPattern.project` produces at the indexed
|
|
48
|
+
``orientation``, and their (contrast-standardized) difference -- so a viewer
|
|
49
|
+
can see at a glance whether the orientation is right. The middle panel is
|
|
50
|
+
titled with the normalized cross-correlation (:func:`_ncc`) between the
|
|
51
|
+
experimental and simulated patterns: a contrast-invariant number in
|
|
52
|
+
``[-1, 1]`` that quantifies the match (``~1`` is a good index).
|
|
53
|
+
|
|
54
|
+
Parameters
|
|
55
|
+
----------
|
|
56
|
+
experimental
|
|
57
|
+
The measured detector image ``(H, W)`` (numpy array or torch tensor;
|
|
58
|
+
a leading length-1 axis is squeezed).
|
|
59
|
+
master
|
|
60
|
+
The :class:`de_ebsd.sim.MasterPattern` used for indexing.
|
|
61
|
+
detector
|
|
62
|
+
The :class:`de_ebsd.sim.detector.EBSDDetector` geometry.
|
|
63
|
+
orientation
|
|
64
|
+
The indexed orientation as a length-1 :class:`orix.quaternion.Rotation`
|
|
65
|
+
(e.g. a single element of :meth:`SphericalIndexer.index`'s output).
|
|
66
|
+
energy_kev
|
|
67
|
+
Energy bin to project (default: the master's highest).
|
|
68
|
+
|
|
69
|
+
Returns
|
|
70
|
+
-------
|
|
71
|
+
anyplotlib.Figure
|
|
72
|
+
A 1x3 figure (experimental | simulated | difference).
|
|
73
|
+
"""
|
|
74
|
+
exp = _to_image(experimental)
|
|
75
|
+
sim = _to_image(master.project(detector, orientation, energy_kev=energy_kev))
|
|
76
|
+
if sim.shape != exp.shape:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"simulated pattern {sim.shape} does not match experimental {exp.shape}; "
|
|
79
|
+
"check the detector shape and orientation length (must be 1)"
|
|
80
|
+
)
|
|
81
|
+
diff = _standardize(exp) - _standardize(sim)
|
|
82
|
+
score = _ncc(exp, sim)
|
|
83
|
+
|
|
84
|
+
fig, axes = apl().subplots(1, 3, figsize=(1140, 400))
|
|
85
|
+
ax_e, ax_s, ax_d = np.ravel(axes)[:3]
|
|
86
|
+
label(ax_e.imshow(exp, cmap="gray", origin="lower"), "experimental")
|
|
87
|
+
label(ax_s.imshow(sim, cmap="gray", origin="lower"), f"simulated | NCC = {score:.3f}")
|
|
88
|
+
label(ax_d.imshow(diff, cmap="coolwarm", origin="lower"), "difference")
|
|
89
|
+
fig.match_ncc = score
|
|
90
|
+
return fig
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _ipf_rgb(crystal_map) -> np.ndarray:
|
|
94
|
+
from orix.plot import IPFColorKeyTSL
|
|
95
|
+
from orix.vector import Vector3d
|
|
96
|
+
|
|
97
|
+
point_group = crystal_map.phases[crystal_map.phase_id[0]].point_group
|
|
98
|
+
key = IPFColorKeyTSL(point_group, direction=Vector3d.zvector())
|
|
99
|
+
rgb = key.orientation2color(crystal_map.rotations) # (N, 3) floats in [0, 1]
|
|
100
|
+
rgb = np.asarray(rgb, dtype=np.float64).reshape(*crystal_map.shape, 3)
|
|
101
|
+
return (np.clip(rgb, 0.0, 1.0) * 255.0).astype(np.uint8)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _quality_image(crystal_map) -> np.ndarray:
|
|
105
|
+
if "quality" not in crystal_map.prop:
|
|
106
|
+
raise KeyError("crystal_map.prop has no 'quality'; index_scan stores it there")
|
|
107
|
+
return np.asarray(crystal_map.prop["quality"], dtype=np.float64).reshape(crystal_map.shape)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def show_map(crystal_map, value: str = "ipf"):
|
|
111
|
+
"""Show an indexed scan (orix ``CrystalMap``) as an IPF and/or quality map.
|
|
112
|
+
|
|
113
|
+
Parameters
|
|
114
|
+
----------
|
|
115
|
+
crystal_map
|
|
116
|
+
The :class:`orix.crystal_map.CrystalMap` from
|
|
117
|
+
:meth:`SphericalIndexer.index_scan` (single phase, ``prop['quality']``
|
|
118
|
+
holding the per-point score).
|
|
119
|
+
value
|
|
120
|
+
Which map(s) to draw:
|
|
121
|
+
|
|
122
|
+
* ``"ipf"`` (default) -- the IPF-z orientation map (orix TSL coloring);
|
|
123
|
+
* ``"quality"`` -- the ``prop['quality']`` field, sequential colormap;
|
|
124
|
+
* ``"both"`` -- the two side by side in a 1x2 figure.
|
|
125
|
+
|
|
126
|
+
Returns
|
|
127
|
+
-------
|
|
128
|
+
anyplotlib.Figure
|
|
129
|
+
A single-panel figure (``"ipf"`` / ``"quality"``) or a 1x2 figure
|
|
130
|
+
(``"both"``). Row 0 of the scan is drawn at the top (``origin="upper"``).
|
|
131
|
+
"""
|
|
132
|
+
if value not in ("ipf", "quality", "both"):
|
|
133
|
+
raise ValueError("value must be 'ipf', 'quality', or 'both'")
|
|
134
|
+
|
|
135
|
+
if value == "both":
|
|
136
|
+
fig, axes = apl().subplots(1, 2, figsize=(1000, 480))
|
|
137
|
+
ax_ipf, ax_q = np.ravel(axes)[:2]
|
|
138
|
+
label(ax_ipf.imshow(_ipf_rgb(crystal_map), origin="upper"), "IPF-z")
|
|
139
|
+
panel_q = ax_q.imshow(_quality_image(crystal_map), cmap="viridis", origin="upper")
|
|
140
|
+
label(panel_q, "quality")
|
|
141
|
+
_colorbar_label(panel_q, "quality")
|
|
142
|
+
return fig
|
|
143
|
+
|
|
144
|
+
fig, ax = apl().subplots(1, 1, figsize=(560, 520))
|
|
145
|
+
if value == "ipf":
|
|
146
|
+
label(ax.imshow(_ipf_rgb(crystal_map), origin="upper"), "IPF-z orientation map")
|
|
147
|
+
else:
|
|
148
|
+
panel = ax.imshow(_quality_image(crystal_map), cmap="viridis", origin="upper")
|
|
149
|
+
label(panel, "quality map")
|
|
150
|
+
_colorbar_label(panel, "quality")
|
|
151
|
+
return fig
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _colorbar_label(panel, text: str) -> None:
|
|
155
|
+
setter = getattr(panel, "set_colorbar_label", None)
|
|
156
|
+
if callable(setter):
|
|
157
|
+
try:
|
|
158
|
+
setter(text)
|
|
159
|
+
except Exception: # noqa: BLE001 - a missing colorbar must never break .show()
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def show_correlation(correlation):
|
|
164
|
+
"""Render the SO(3) cross-correlation volume as a legible 2-D heatmap.
|
|
165
|
+
|
|
166
|
+
The cross-correlation from
|
|
167
|
+
:func:`de_ebsd.index.spherical.correlation.so3_cross_correlation` is a function on
|
|
168
|
+
all of SO(3), sampled on the ``(alpha, beta, gamma)`` grid (shape
|
|
169
|
+
``(N, N, N)``). This reduces it to an ``(alpha, gamma)`` heatmap by taking
|
|
170
|
+
the maximum over the ``beta`` axis (so the global peak survives the
|
|
171
|
+
projection), marks the peak cell, and titles the panel with the peak value
|
|
172
|
+
and its ``(alpha, beta, gamma)`` grid indices.
|
|
173
|
+
|
|
174
|
+
Parameters
|
|
175
|
+
----------
|
|
176
|
+
correlation
|
|
177
|
+
The correlation grid, shape ``(N, N, N)`` for a single pattern, or a
|
|
178
|
+
stack ``(Np, N, N, N)`` whose **first** pattern is shown (numpy array or
|
|
179
|
+
torch tensor). Obtain it from
|
|
180
|
+
:meth:`~de_ebsd.index.spherical.indexer.SphericalIndexer.index` with
|
|
181
|
+
``return_surface=True`` (the third returned element).
|
|
182
|
+
|
|
183
|
+
Returns
|
|
184
|
+
-------
|
|
185
|
+
anyplotlib.Figure
|
|
186
|
+
A single heatmap panel with the peak marked. The peak grid indices and
|
|
187
|
+
value are also attached to the figure as ``fig.peak_index`` (an
|
|
188
|
+
``(alpha, beta, gamma)`` tuple) and ``fig.peak_value`` for programmatic
|
|
189
|
+
access.
|
|
190
|
+
"""
|
|
191
|
+
corr = _to_numpy(correlation)
|
|
192
|
+
if corr.ndim == 4:
|
|
193
|
+
corr = corr[0]
|
|
194
|
+
if corr.ndim != 3:
|
|
195
|
+
raise ValueError(f"correlation must be (N, N, N) or (Np, N, N, N), got shape {corr.shape}")
|
|
196
|
+
|
|
197
|
+
a_idx, b_idx, g_idx = (int(i) for i in np.unravel_index(int(np.argmax(corr)), corr.shape))
|
|
198
|
+
peak = float(corr[a_idx, b_idx, g_idx])
|
|
199
|
+
projection = corr.max(axis=1) # (alpha, gamma), max over beta -- keeps the global peak
|
|
200
|
+
|
|
201
|
+
fig, ax = apl().subplots(1, 1, figsize=(620, 560))
|
|
202
|
+
panel = ax.imshow(projection, cmap="magma", origin="lower")
|
|
203
|
+
label(
|
|
204
|
+
panel,
|
|
205
|
+
f"SO(3) correlation (max over beta) | peak {peak:.4g} "
|
|
206
|
+
f"at (a, b, g) = ({a_idx}, {b_idx}, {g_idx})",
|
|
207
|
+
)
|
|
208
|
+
_ax_setter(panel, "set_xlabel", "gamma index")
|
|
209
|
+
_ax_setter(panel, "set_ylabel", "alpha index")
|
|
210
|
+
# mark the peak: heatmap x is the gamma index, y is the alpha index
|
|
211
|
+
try:
|
|
212
|
+
panel.add_points([(g_idx, a_idx)], color="#4fc3f7", sizes=14, alpha=0.9)
|
|
213
|
+
except Exception: # noqa: BLE001 - the title already reports the peak location
|
|
214
|
+
pass
|
|
215
|
+
|
|
216
|
+
fig.peak_index = (a_idx, b_idx, g_idx)
|
|
217
|
+
fig.peak_value = peak
|
|
218
|
+
return fig
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _ax_setter(panel, method: str, value: str) -> None:
|
|
222
|
+
setter = getattr(panel, method, None)
|
|
223
|
+
if callable(setter):
|
|
224
|
+
try:
|
|
225
|
+
setter(value)
|
|
226
|
+
except Exception: # noqa: BLE001 - a missing axis label must never break .show()
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def show_refinement(history):
|
|
231
|
+
"""Plot the refinement NCC trajectory (convergence) vs iteration.
|
|
232
|
+
|
|
233
|
+
Parameters
|
|
234
|
+
----------
|
|
235
|
+
history
|
|
236
|
+
The per-iteration NCC trajectory from
|
|
237
|
+
:func:`de_ebsd.index.refine.orientation.refine_orientation` (``return_history=True``),
|
|
238
|
+
shape ``(n_iterations, Np)`` -- one column per pattern -- or ``(n_iterations,)`` for a
|
|
239
|
+
single pattern (numpy array or torch tensor). ``history[0]`` is the seed's
|
|
240
|
+
score at ``w = 0``.
|
|
241
|
+
|
|
242
|
+
Returns
|
|
243
|
+
-------
|
|
244
|
+
anyplotlib.Figure
|
|
245
|
+
A line plot of NCC against iteration: one thin curve per pattern, plus a
|
|
246
|
+
thick mean curve when more than one pattern is refined.
|
|
247
|
+
"""
|
|
248
|
+
hist = _to_numpy(history)
|
|
249
|
+
if hist.ndim == 1:
|
|
250
|
+
hist = hist[:, None]
|
|
251
|
+
if hist.ndim != 2:
|
|
252
|
+
raise ValueError(
|
|
253
|
+
f"history must be (n_iterations, Np) or (n_iterations,), got shape {hist.shape}"
|
|
254
|
+
)
|
|
255
|
+
n_iterations, n_p = hist.shape
|
|
256
|
+
iters = np.arange(n_iterations, dtype=np.float64)
|
|
257
|
+
|
|
258
|
+
fig, ax = apl().subplots(1, 1, figsize=(680, 420))
|
|
259
|
+
per_pattern_label = "per pattern" if n_p > 1 else "NCC"
|
|
260
|
+
plot = ax.plot(
|
|
261
|
+
hist[:, 0],
|
|
262
|
+
axes=[iters],
|
|
263
|
+
units="iteration",
|
|
264
|
+
y_units="NCC",
|
|
265
|
+
color="#4fc3f7",
|
|
266
|
+
linewidth=1.0 if n_p > 1 else 2.0,
|
|
267
|
+
alpha=0.5 if n_p > 1 else 1.0,
|
|
268
|
+
label=per_pattern_label,
|
|
269
|
+
)
|
|
270
|
+
for p in range(1, n_p):
|
|
271
|
+
plot.add_line(hist[:, p], x_axis=iters, color="#4fc3f7", linewidth=1.0, alpha=0.5)
|
|
272
|
+
if n_p > 1:
|
|
273
|
+
plot.add_line(hist.mean(axis=1), x_axis=iters, color="#ff7043", linewidth=2.5, label="mean")
|
|
274
|
+
_ax_setter(plot, "set_title", "Refinement convergence")
|
|
275
|
+
_ax_setter(plot, "set_xlabel", "iteration")
|
|
276
|
+
_ax_setter(plot, "set_ylabel", "NCC")
|
|
277
|
+
return fig
|
de_ebsd/viz/patterns.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Master-pattern, crystal-structure and direction-sampling figures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from de_ebsd.viz.figures import apl, label, to_uint8_rgb
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"crystal_structure_figure",
|
|
11
|
+
"hemispheres_figure",
|
|
12
|
+
"phase_overview_figure",
|
|
13
|
+
"sampling_sphere_figure",
|
|
14
|
+
"sphere_figure",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
_ELEMENT_COLORS = {
|
|
18
|
+
"H": (1.00, 1.00, 1.00),
|
|
19
|
+
"C": (0.36, 0.40, 0.45),
|
|
20
|
+
"N": (0.25, 0.40, 0.95),
|
|
21
|
+
"O": (0.93, 0.20, 0.22),
|
|
22
|
+
"Al": (0.75, 0.65, 0.65),
|
|
23
|
+
"Si": (0.94, 0.66, 0.48),
|
|
24
|
+
"Ti": (0.75, 0.76, 0.78),
|
|
25
|
+
"Cr": (0.54, 0.60, 0.78),
|
|
26
|
+
"Mn": (0.61, 0.48, 0.78),
|
|
27
|
+
"Fe": (0.88, 0.40, 0.20),
|
|
28
|
+
"Co": (0.94, 0.56, 0.63),
|
|
29
|
+
"Ni": (0.31, 0.82, 0.31),
|
|
30
|
+
"Cu": (0.78, 0.50, 0.20),
|
|
31
|
+
"Zn": (0.49, 0.50, 0.69),
|
|
32
|
+
}
|
|
33
|
+
_DEFAULT_COLOR = (0.40, 0.75, 0.90)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def hemispheres_figure(north: np.ndarray, south: np.ndarray, title: str = "", cmap: str = "gray"):
|
|
37
|
+
"""North and south hemisphere maps side by side."""
|
|
38
|
+
fig, axes = apl().subplots(1, 2, figsize=(940, 470))
|
|
39
|
+
ax_n, ax_s = np.ravel(axes)[:2]
|
|
40
|
+
panel_n = ax_n.imshow(np.asarray(north, dtype=np.float64), cmap=cmap, origin="lower")
|
|
41
|
+
panel_s = ax_s.imshow(np.asarray(south, dtype=np.float64), cmap=cmap, origin="lower")
|
|
42
|
+
label(panel_n, f"{title} - north" if title else "north")
|
|
43
|
+
label(panel_s, f"{title} - south" if title else "south")
|
|
44
|
+
return fig
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def sphere_figure(
|
|
48
|
+
equirectangular: np.ndarray,
|
|
49
|
+
title: str = "",
|
|
50
|
+
azimuth: float = -60.0,
|
|
51
|
+
elevation: float = 30.0,
|
|
52
|
+
n_grid: int = 96,
|
|
53
|
+
):
|
|
54
|
+
"""The master pattern textured onto an interactive 3D sphere.
|
|
55
|
+
|
|
56
|
+
``equirectangular`` is sampled on a latitude/longitude grid: row 0 is the
|
|
57
|
+
north pole, columns span longitude -pi..pi (``MasterPattern.to_equirectangular``).
|
|
58
|
+
"""
|
|
59
|
+
fig, ax = apl().subplots(1, 1, figsize=(560, 560))
|
|
60
|
+
_sphere_panel(ax, equirectangular, title, azimuth, elevation, n_grid)
|
|
61
|
+
return fig
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _sphere_panel(ax, equirectangular, title, azimuth, elevation, n_grid):
|
|
65
|
+
theta = np.linspace(0.0, np.pi, n_grid)
|
|
66
|
+
phi = np.linspace(-np.pi, np.pi, 2 * n_grid)
|
|
67
|
+
t, p = np.meshgrid(theta, phi, indexing="ij")
|
|
68
|
+
x = np.sin(t) * np.cos(p)
|
|
69
|
+
y = np.sin(t) * np.sin(p)
|
|
70
|
+
z = np.cos(t)
|
|
71
|
+
|
|
72
|
+
surface = ax.plot_surface(
|
|
73
|
+
x, y, z, bounds=((-1.0, 1.0),) * 3, azimuth=azimuth, elevation=elevation, gpu="auto"
|
|
74
|
+
)
|
|
75
|
+
surface.set_texture(to_uint8_rgb(equirectangular), cull_backfaces=True)
|
|
76
|
+
label(surface, title)
|
|
77
|
+
return surface
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def phase_overview_figure(crystal, equirectangular: np.ndarray, *, name="Phase", n_grid=96):
|
|
81
|
+
"""Dominant master pattern and compact structure in one figure.
|
|
82
|
+
|
|
83
|
+
One figure means one anywidget in Jupyter and one registration through
|
|
84
|
+
SpyDE's Electron bridge.
|
|
85
|
+
"""
|
|
86
|
+
fig, axes = apl().subplots(1, 2, figsize=(820, 400), width_ratios=[1.45, 1.0])
|
|
87
|
+
master_ax, crystal_ax = np.ravel(axes)[:2]
|
|
88
|
+
_sphere_panel(master_ax, equirectangular, f"{name} master pattern", -60.0, 30.0, n_grid)
|
|
89
|
+
_crystal_structure_panel(crystal_ax, crystal, f"{name} structure")
|
|
90
|
+
return fig
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def crystal_structure_figure(crystal, title: str = ""):
|
|
94
|
+
"""Expanded atoms of one unit cell as a 3-D plot, in Cartesian nanometres."""
|
|
95
|
+
fig, ax = apl().subplots(1, 1, figsize=(480, 420))
|
|
96
|
+
_crystal_structure_panel(ax, crystal, title or "Crystal structure")
|
|
97
|
+
return fig
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _crystal_structure_panel(ax, crystal, title: str):
|
|
101
|
+
fractional = np.concatenate(crystal.positions, axis=0)
|
|
102
|
+
elements = [
|
|
103
|
+
site.element
|
|
104
|
+
for site, positions in zip(crystal.sites, crystal.positions, strict=True)
|
|
105
|
+
for _ in positions
|
|
106
|
+
]
|
|
107
|
+
cartesian = fractional @ crystal.direct_structure_matrix.T
|
|
108
|
+
colors = np.asarray([_ELEMENT_COLORS.get(e, _DEFAULT_COLOR) for e in elements])
|
|
109
|
+
|
|
110
|
+
# include the cell corners so non-orthogonal cells are not clipped
|
|
111
|
+
corners = np.asarray([[i, j, k] for i in (0.0, 1.0) for j in (0.0, 1.0) for k in (0.0, 1.0)])
|
|
112
|
+
extent = np.vstack((cartesian, corners @ crystal.direct_structure_matrix.T))
|
|
113
|
+
lo, hi = extent.min(axis=0), extent.max(axis=0)
|
|
114
|
+
padding = 0.08 * np.maximum(hi - lo, 1e-6)
|
|
115
|
+
bounds = tuple((float(a), float(b)) for a, b in zip(lo - padding, hi + padding, strict=True))
|
|
116
|
+
|
|
117
|
+
points = ax.scatter3d(
|
|
118
|
+
cartesian[:, 0],
|
|
119
|
+
cartesian[:, 1],
|
|
120
|
+
cartesian[:, 2],
|
|
121
|
+
colors=colors,
|
|
122
|
+
point_size=max(5.0, min(12.0, 48.0 / np.sqrt(len(cartesian)))),
|
|
123
|
+
bounds=bounds,
|
|
124
|
+
x_label="x (nm)",
|
|
125
|
+
y_label="y (nm)",
|
|
126
|
+
z_label="z (nm)",
|
|
127
|
+
azimuth=-55.0,
|
|
128
|
+
elevation=24.0,
|
|
129
|
+
gpu="auto",
|
|
130
|
+
)
|
|
131
|
+
label(points, title)
|
|
132
|
+
return points
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def sampling_sphere_figure(directions: np.ndarray, title: str = "Incident-direction sampling"):
|
|
136
|
+
"""Sampled unit directions on an interactive reference sphere.
|
|
137
|
+
|
|
138
|
+
The first half of ``directions`` is colored as the northern hemisphere and
|
|
139
|
+
the second half as the southern, matching ``KVectorGrid.flat``.
|
|
140
|
+
"""
|
|
141
|
+
directions = np.asarray(directions, dtype=np.float64).reshape(-1, 3)
|
|
142
|
+
if directions.size == 0 or directions.shape[1] != 3:
|
|
143
|
+
raise ValueError("directions must have shape (N, 3) and contain at least one vector")
|
|
144
|
+
|
|
145
|
+
n_north = directions.shape[0] // 2
|
|
146
|
+
colors = np.empty((directions.shape[0], 3), dtype=np.float64)
|
|
147
|
+
colors[:n_north] = (0.35, 0.71, 0.98)
|
|
148
|
+
colors[n_north:] = (0.98, 0.63, 0.36)
|
|
149
|
+
|
|
150
|
+
fig, ax = apl().subplots(1, 1, figsize=(560, 560))
|
|
151
|
+
points = ax.scatter3d(
|
|
152
|
+
directions[:, 0],
|
|
153
|
+
directions[:, 1],
|
|
154
|
+
directions[:, 2],
|
|
155
|
+
colors=colors,
|
|
156
|
+
point_size=3.0,
|
|
157
|
+
bounds=((-1.0, 1.0),) * 3,
|
|
158
|
+
azimuth=-60.0,
|
|
159
|
+
elevation=30.0,
|
|
160
|
+
gpu="auto",
|
|
161
|
+
)
|
|
162
|
+
points.set_sphere(radius=1.0, color="#9aa4b2", alpha=0.10, wireframe=True)
|
|
163
|
+
label(points, title)
|
|
164
|
+
return fig
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Detector geometry mirrors core.geometry.sample_to_detector_matrix (row vectors).
|
|
2
|
+
// Exported separately so parity with Python can be tested without a browser.
|
|
3
|
+
export function setupGeometry(s) {
|
|
4
|
+
const rad = a => a * Math.PI / 180;
|
|
5
|
+
const cross = (a,b) => [a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]];
|
|
6
|
+
let basis = [[0,1,0],[0,0,1],[1,0,0]];
|
|
7
|
+
for (const [axis, angle] of [[0,-s.sample_tilt],[0,s.detector_tilt],[1,-s.azimuthal],[2,-s.twist]]) {
|
|
8
|
+
const u=basis[axis], c=Math.cos(rad(angle)), sn=Math.sin(rad(angle));
|
|
9
|
+
basis=basis.map(v => {
|
|
10
|
+
const uv=cross(u,v), dot=u.reduce((sum,x,i)=>sum+x*v[i],0);
|
|
11
|
+
return v.map((x,i)=>x*c+uv[i]*sn+u[i]*dot*(1-c));
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
const H=s.detector_rows*s.px_size/1000, W=s.detector_cols*s.px_size/1000, L=s.pcz*H;
|
|
15
|
+
const toSample = v => [0,1,2].map(j => v.reduce((sum,x,i)=>sum+x*basis[i][j],0));
|
|
16
|
+
const c=Math.cos(rad(s.sample_tilt)), sn=Math.sin(rad(s.sample_tilt));
|
|
17
|
+
// Laboratory X horizontal, Z vertical; positive sample tilt slopes down right.
|
|
18
|
+
const toLab = v => [c*v[0]+sn*v[2],v[1],-sn*v[0]+c*v[2]];
|
|
19
|
+
const corners=[[-s.pcx*W,s.pcy*H,L],[(1-s.pcx)*W,s.pcy*H,L],
|
|
20
|
+
[(1-s.pcx)*W,-(1-s.pcy)*H,L],[-s.pcx*W,-(1-s.pcy)*H,L]].map(toSample);
|
|
21
|
+
const pc=toSample([0,0,L]);
|
|
22
|
+
return {H,W,L,corners,lab:corners.map(toLab),pc,pcLab:toLab(pc),normal:[sn,0,c],
|
|
23
|
+
tangent:[c,0,-sn], center:corners[0].map((_,j)=>corners.reduce((sum,v)=>sum+v[j],0)/4),
|
|
24
|
+
planeAngle:Math.acos(Math.min(1,Math.abs(basis[2][2])))*180/Math.PI,
|
|
25
|
+
footprint:s.probe_diameter_nm>0 && c>1e-12 ? [s.probe_diameter_nm/c,s.probe_diameter_nm] : null};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function render({model,el}) {
|
|
29
|
+
const host=el.shadowRoot || el.attachShadow({mode:"open"});
|
|
30
|
+
host.innerHTML=`<style>
|
|
31
|
+
:host {display:block; max-width:580px; color-scheme:light dark}
|
|
32
|
+
* {box-sizing:border-box}
|
|
33
|
+
.setup {--fg:var(--spyde-fg,var(--jp-ui-font-color1,light-dark(#242a33,#d8dee9)));
|
|
34
|
+
--bg:var(--spyde-panel-bg,var(--jp-layout-color1,light-dark(#f6f7f9,#20242d)));
|
|
35
|
+
--line:var(--spyde-border,var(--jp-border-color2,light-dark(#ccd0d7,#4c5565)));
|
|
36
|
+
--beam:var(--spyde-accent,light-dark(#1672b8,#5ba7f7)); --sample:light-dark(#a45c17,#e5a05c);
|
|
37
|
+
--screen:light-dark(#197349,#66c58a); --pc:light-dark(#ad2858,#f080a6);
|
|
38
|
+
color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:6px;
|
|
39
|
+
font:12px var(--jp-ui-font-family,system-ui,sans-serif);overflow:hidden}
|
|
40
|
+
header {padding:9px 12px;border-bottom:1px solid var(--line);display:flex;gap:10px;flex-wrap:wrap}
|
|
41
|
+
header span {margin-left:auto} svg {display:block;width:100%;font:12px system-ui;touch-action:manipulation}
|
|
42
|
+
svg text {fill:var(--fg)} .insets {display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--line)}
|
|
43
|
+
.controls {padding:10px 12px;display:grid;grid-template-columns:1fr 1fr;gap:7px 14px;border-top:1px solid var(--line)}
|
|
44
|
+
label {display:flex;align-items:center;justify-content:space-between;gap:6px;min-width:0}
|
|
45
|
+
input {font:inherit;color:inherit;background:var(--bg);border:1px solid var(--line);border-radius:3px;
|
|
46
|
+
width:84px;min-width:0;padding:3px 5px} .facts,.note {padding:7px 12px;line-height:1.55}
|
|
47
|
+
.facts {border-top:1px solid var(--line);font-variant-numeric:tabular-nums} .note {font-size:11px}
|
|
48
|
+
@media(max-width:400px) {.insets {grid-template-columns:1fr}.controls {gap:7px}.controls label {flex-wrap:wrap}.controls input {width:100%}}
|
|
49
|
+
</style><section class="setup"><header><strong>Microscope / detector</strong><span></span></header>
|
|
50
|
+
<svg class="geometry" height="250" role="img" aria-label="Laboratory side view: beam, sample normal and detector"></svg>
|
|
51
|
+
<div class="insets"><svg class="face" height="175" role="img" aria-label="Detector face and Bruker pattern center"></svg>
|
|
52
|
+
<svg class="probe" height="175" role="img" aria-label="Geometric probe footprint on the sample"></svg></div>
|
|
53
|
+
<div class="facts" aria-live="polite"></div><div class="controls"></div>
|
|
54
|
+
<div class="note">Geometry editor only: existing simulation results do not change. In Jupyter, read edits with
|
|
55
|
+
<code>to_microscope()</code> / <code>to_detector()</code>, then recompute. Screen pitch includes binning.</div></section>`;
|
|
56
|
+
const root=host.querySelector('.setup'), main=host.querySelector('.geometry'), face=host.querySelector('.face'),
|
|
57
|
+
probe=host.querySelector('.probe'), controls=host.querySelector('.controls'), facts=host.querySelector('.facts');
|
|
58
|
+
const fields=[['energy_kev','Beam (keV)',0.001,null,0.1],['sample_tilt','Incidence (°)',0,90,0.1],
|
|
59
|
+
['probe_diameter_nm','Probe Ø (nm; 0 = unset)',0,null,1],['detector_tilt','Screen tilt from vertical (°)',-90,90,0.1],
|
|
60
|
+
['pcx','PCx / width',null,null,0.001],['pcy','PCy / height',null,null,0.001],
|
|
61
|
+
['pcz','PCz / height',0.000001,null,0.001],['px_size','Screen pitch (µm)',0.000001,null,1],
|
|
62
|
+
['detector_rows','Rows',1,null,1],['detector_cols','Columns',1,null,1],
|
|
63
|
+
['azimuthal','Azimuth (°)',-180,180,0.1],['twist','Screen twist (°)',-180,180,0.1]];
|
|
64
|
+
const inputs=new Map();
|
|
65
|
+
for (const [key,label,min,max,step] of fields) {
|
|
66
|
+
const wrapper=document.createElement('label'), input=document.createElement('input');
|
|
67
|
+
wrapper.append(document.createTextNode(label)); input.type='number'; input.step=step;
|
|
68
|
+
if(min!==null) input.min=min; if(max!==null) input.max=max;
|
|
69
|
+
input.setAttribute('aria-label',label); wrapper.append(input); controls.append(wrapper); inputs.set(key,input);
|
|
70
|
+
input.addEventListener('change',()=>{
|
|
71
|
+
if(input.value.trim()==='' || !input.checkValidity() || !Number.isFinite(input.valueAsNumber)) {
|
|
72
|
+
input.reportValidity(); input.value=model.get(key); return;
|
|
73
|
+
}
|
|
74
|
+
model.set(key,input.valueAsNumber); model.save_changes();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const node=(svg,tag,attrs={},text='')=>{
|
|
78
|
+
const n=document.createElementNS('http://www.w3.org/2000/svg',tag);
|
|
79
|
+
for(const [k,v] of Object.entries(attrs)) n.setAttribute(k,v);
|
|
80
|
+
n.textContent=text; svg.append(n); return n;
|
|
81
|
+
};
|
|
82
|
+
const text=(svg,x,y,value,anchor='start')=>node(svg,'text',{x,y,'text-anchor':anchor},value);
|
|
83
|
+
const line=(svg,a,b,color,width=1,dash='')=>node(svg,'line',
|
|
84
|
+
{x1:a[0],y1:a[1],x2:b[0],y2:b[1],stroke:color,'stroke-width':width,'stroke-dasharray':dash});
|
|
85
|
+
const arrow=(svg,a,b,color)=>{
|
|
86
|
+
line(svg,a,b,color,2); const angle=Math.atan2(b[1]-a[1],b[0]-a[0]);
|
|
87
|
+
const p=[b,[b[0]-8*Math.cos(angle-.4),b[1]-8*Math.sin(angle-.4)],
|
|
88
|
+
[b[0]-8*Math.cos(angle+.4),b[1]-8*Math.sin(angle+.4)]];
|
|
89
|
+
node(svg,'polygon',{points:p.map(v=>v.join(',')).join(' '),fill:color});
|
|
90
|
+
};
|
|
91
|
+
const fmt=n=>Number(n).toFixed(2);
|
|
92
|
+
function draw() {
|
|
93
|
+
const s=Object.fromEntries(fields.map(([key])=>[key,Number(model.get(key))]));
|
|
94
|
+
const g=setupGeometry(s), has=model.get('has_detector');
|
|
95
|
+
const css=getComputedStyle(root), beam=css.getPropertyValue('--beam'), sample=css.getPropertyValue('--sample'),
|
|
96
|
+
screen=css.getPropertyValue('--screen'), pcColor=css.getPropertyValue('--pc');
|
|
97
|
+
for(const [key,input] of inputs) if(input!==host.activeElement) input.value=s[key];
|
|
98
|
+
host.querySelector('header span').textContent=`${model.get('crystal_label') || 'Sample'} · ${fmt(s.energy_kev)} keV`;
|
|
99
|
+
for(const svg of [main,face,probe]) {svg.replaceChildren(); svg.setAttribute('viewBox',`0 0 ${svg.clientWidth || 280} ${svg.getAttribute('height')}`);}
|
|
100
|
+
const w=main.clientWidth||560, H=250, size=has?Math.max(g.H,g.W,g.L):1;
|
|
101
|
+
const scaleVec=(v,n)=>v.map(x=>x*n);
|
|
102
|
+
const a=scaleVec(g.tangent,-size*.42), b=scaleVec(g.tangent,size*.42),
|
|
103
|
+
normal=scaleVec(g.normal,size*.40), source=[0,0,size*.72];
|
|
104
|
+
const all=[a,b,normal,source,[0,0,0],...(has?g.lab:[])];
|
|
105
|
+
const xmin=Math.min(...all.map(p=>p[0])),xmax=Math.max(...all.map(p=>p[0]));
|
|
106
|
+
const zmin=Math.min(...all.map(p=>p[2])),zmax=Math.max(...all.map(p=>p[2]));
|
|
107
|
+
const k=Math.min((w-96)/(xmax-xmin),(H-76)/(zmax-zmin));
|
|
108
|
+
const P=v=>[48+(w-96-(xmax-xmin)*k)/2+(v[0]-xmin)*k,40+(zmax-v[2])*k];
|
|
109
|
+
const origin=P([0,0,0]), A=P(a), B=P(b);
|
|
110
|
+
text(main,12,18,'Laboratory side view · equal mm scale');
|
|
111
|
+
const back=v=>v.map((x,i)=>x-size*.07*g.normal[i]);
|
|
112
|
+
node(main,'polygon',{points:[a,b,back(b),back(a)].map(v=>P(v).join(',')).join(' '),fill:sample,opacity:.25});
|
|
113
|
+
line(main,A,B,sample,4); arrow(main,origin,P(normal),sample); arrow(main,P(source),origin,beam);
|
|
114
|
+
text(main,P(source)[0]-8,P(source)[1]+9,'beam','end');
|
|
115
|
+
text(main,P(normal)[0]+5,P(normal)[1]+4,'n');
|
|
116
|
+
text(main,12,H-10,`Incidence ${s.sample_tilt.toFixed(1)}° from n · grazing ${(90-s.sample_tilt).toFixed(1)}°`);
|
|
117
|
+
const r=28, end=[origin[0]+r*g.normal[0],origin[1]-r*g.normal[2]];
|
|
118
|
+
node(main,'path',{d:`M ${origin[0]} ${origin[1]-r} A ${r} ${r} 0 0 1 ${end[0]} ${end[1]}`,
|
|
119
|
+
fill:'none',stroke:sample,'stroke-width':1.5});
|
|
120
|
+
node(main,'circle',{cx:origin[0],cy:origin[1],r:3,fill:beam});
|
|
121
|
+
if(has) {
|
|
122
|
+
node(main,'polygon',{points:g.lab.map(v=>P(v).join(',')).join(' '),fill:screen,'fill-opacity':.2,stroke:screen,'stroke-width':3});
|
|
123
|
+
const pc=P(g.pcLab); line(main,origin,pc,pcColor,1.5,'4 3');
|
|
124
|
+
node(main,'circle',{cx:pc[0],cy:pc[1],r:4,fill:pcColor}); text(main,pc[0]+7,pc[1]-7,'PC');
|
|
125
|
+
text(main,w-12,34,`normal distance ${fmt(g.L)} mm`,'end');
|
|
126
|
+
text(main,w-12,51,`screen / sample planes: ${g.planeAngle.toFixed(1)}°`,'end');
|
|
127
|
+
}
|
|
128
|
+
// Detector face, independent of the side projection; row zero is the top.
|
|
129
|
+
const fw=face.clientWidth||280, fh=175;
|
|
130
|
+
text(face,12,20,has?'Detector face · Bruker PC':'No detector supplied');
|
|
131
|
+
if(has) {
|
|
132
|
+
const maxX=Math.max(1,s.pcx),minX=Math.min(0,s.pcx),maxY=Math.max(1,s.pcy),minY=Math.min(0,s.pcy);
|
|
133
|
+
const kf=Math.min((fw-60)/(g.W*(maxX-minX)),98/(g.H*(maxY-minY)));
|
|
134
|
+
const left=30-minX*g.W*kf, top=40-minY*g.H*kf, ww=g.W*kf, hh=g.H*kf;
|
|
135
|
+
node(face,'rect',{x:left,y:top,width:ww,height:hh,fill:screen,'fill-opacity':.13,stroke:screen});
|
|
136
|
+
const x=left+s.pcx*ww,y=top+s.pcy*hh;
|
|
137
|
+
line(face,[x-7,y],[x+7,y],pcColor,2);line(face,[x,y-7],[x,y+7],pcColor,2);
|
|
138
|
+
text(face,fw/2,fh-20,`${s.detector_cols} × ${s.detector_rows} pixels`,'middle');
|
|
139
|
+
text(face,fw/2,fh-4,`${fmt(g.W)} × ${fmt(g.H)} mm · row 0 at top`,'middle');
|
|
140
|
+
face.style.cursor='crosshair';
|
|
141
|
+
face.onclick=event=>{
|
|
142
|
+
const rect=face.getBoundingClientRect();
|
|
143
|
+
model.set('pcx',Math.min(1,Math.max(0,(event.clientX-rect.left-left)/ww)));
|
|
144
|
+
model.set('pcy',Math.min(1,Math.max(0,(event.clientY-rect.top-top)/hh)));model.save_changes();
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const pw=probe.clientWidth||280;
|
|
148
|
+
text(probe,12,20,'Probe footprint · sample-plane inset');
|
|
149
|
+
if(g.footprint) {
|
|
150
|
+
const [major,minor]=g.footprint, rx=Math.min((pw-48)/2,82), ry=rx*minor/major;
|
|
151
|
+
node(probe,'ellipse',{cx:pw/2,cy:83,rx,ry,fill:beam,'fill-opacity':.18,stroke:beam,'stroke-width':2});
|
|
152
|
+
line(probe,[pw/2-rx,83],[pw/2+rx,83],beam,1,'3 3');
|
|
153
|
+
text(probe,pw/2,143,`${fmt(major)} × ${fmt(minor)} nm`,'middle');
|
|
154
|
+
text(probe,pw/2,161,'Parallel circular probe · separate scale','middle');
|
|
155
|
+
} else {
|
|
156
|
+
text(probe,pw/2,78,s.probe_diameter_nm?'No finite footprint at 90°':'Probe diameter not specified','middle');
|
|
157
|
+
text(probe,pw/2,99,'Not an interaction-volume model','middle');
|
|
158
|
+
}
|
|
159
|
+
const centerLab=g.lab.reduce((sum,p)=>sum+p[2],0)/4;
|
|
160
|
+
const minHeight=Math.min(...g.corners.map(p=>p[2]));
|
|
161
|
+
facts.textContent=has?`PC (${s.pcx.toFixed(3)}, ${s.pcy.toFixed(3)}, ${s.pcz.toFixed(3)}) · screen center: ${fmt(centerLab)} mm above impact (lab), ${fmt(g.center[2])} mm above the sample plane (normal). `+
|
|
162
|
+
(minHeight<0?'Warning: part of the screen lies behind the extended sample plane. ':'')+
|
|
163
|
+
'Click the detector face to set PCx / PCy.':
|
|
164
|
+
'Sample normal points out of the material. Probe size is descriptive metadata, not a solver input.';
|
|
165
|
+
}
|
|
166
|
+
const names=[...fields.map(f=>f[0]),'crystal_label','has_detector'];
|
|
167
|
+
for(const name of names) model.on(`change:${name}`,draw);
|
|
168
|
+
const observer=new ResizeObserver(draw); observer.observe(root); draw();
|
|
169
|
+
return ()=>{observer.disconnect();for(const name of names) model.off(`change:${name}`,draw);};
|
|
170
|
+
}
|
|
171
|
+
export default {render};
|