Skip to content

Commit 0ea8633

Browse files
authored
Merge pull request #18 from CSSFrancis/feat/3d-gpu-embed-perf
2 parents 1a2990e + e41b905 commit 0ea8633

39 files changed

Lines changed: 6711 additions & 4 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,6 @@ docs/_static/anywidget_config.js
4848

4949
# Git worktrees
5050
.worktrees/
51+
52+
# Generated by Sphinx-Gallery (anywidget iframe HTML) — never commit
53+
docs/_static/viewer_widgets/
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""
2+
Inverse Pole Figure (IPF) Explorer
3+
==================================
4+
5+
An EBSD-style orientation explorer for a synthetic polycrystal:
6+
7+
* **Left panel** — IPF-Z orientation map, colored with the standard cubic
8+
IPF key (red = ⟨001⟩, green = ⟨011⟩, blue = ⟨111⟩). Rendered as a
9+
true-color RGB image.
10+
* **Right panel** — the *reduced 3-D inverse pole figure*: every grain's
11+
sample-Z direction, expressed in crystal coordinates and folded into the
12+
cubic fundamental sector, plotted as an IPF-colored point cloud on a
13+
shaded, wireframed unit sphere.
14+
15+
Drag the crosshair on the map: the grain's orientation is marked with a
16+
highlighted dot on the sphere, and the sphere **rotates so that direction
17+
faces you**. Drag on the sphere to orbit freely; the next crosshair move
18+
re-aims the camera.
19+
"""
20+
21+
import numpy as np
22+
import anyplotlib as apl
23+
24+
rng = np.random.default_rng(42)
25+
26+
# ── 1. Synthetic polycrystal: nearest-seed grain map ────────────────────────
27+
H = W = 192
28+
N_GRAINS = 60
29+
30+
seeds = rng.uniform(0, [H, W], size=(N_GRAINS, 2))
31+
yy, xx = np.mgrid[0:H, 0:W]
32+
d2 = (yy[..., None] - seeds[:, 0]) ** 2 + (xx[..., None] - seeds[:, 1]) ** 2
33+
grain_id = np.argmin(d2, axis=-1) # (H, W) labels
34+
35+
36+
# ── 2. Random orientation per grain (uniform rotations via quaternions) ─────
37+
def random_rotations(n):
38+
"""Uniform random rotation matrices, shape (n, 3, 3) (Shoemake method)."""
39+
u1, u2, u3 = rng.random((3, n))
40+
q = np.stack([
41+
np.sqrt(1 - u1) * np.sin(2 * np.pi * u2),
42+
np.sqrt(1 - u1) * np.cos(2 * np.pi * u2),
43+
np.sqrt(u1) * np.sin(2 * np.pi * u3),
44+
np.sqrt(u1) * np.cos(2 * np.pi * u3),
45+
], axis=1) # (n, 4) unit quats
46+
x, y, z, w = q.T
47+
return np.stack([
48+
np.stack([1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)], -1),
49+
np.stack([2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)], -1),
50+
np.stack([2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)], -1),
51+
], axis=1)
52+
53+
54+
rotations = random_rotations(N_GRAINS)
55+
56+
# Sample-Z expressed in each grain's crystal frame: d = Rᵀ · ẑ
57+
dirs = rotations[:, 2, :] # row 2 of R == Rᵀ·ẑ
58+
59+
# ── 3. Reduce to the cubic fundamental sector and IPF-color ────────────────
60+
# For cubic symmetry, sorting |components| ascending lands every direction
61+
# in the standard 001–011–111 stereographic triangle.
62+
reduced = np.sort(np.abs(dirs), axis=1) # (a ≤ b ≤ c)
63+
a, b, c = reduced.T
64+
65+
# Classic IPF key: distance to each triangle corner → R, G, B
66+
rgb = np.stack([c - b, b - a, a], axis=1)
67+
rgb /= rgb.max(axis=1, keepdims=True) + 1e-12 # vivid normalisation
68+
grain_rgb_u8 = (rgb * 255).astype(np.uint8) # (N_GRAINS, 3)
69+
70+
ipf_map = grain_rgb_u8[grain_id] # (H, W, 3) true-color
71+
72+
73+
# ── 4. Figure: RGB map + reduced 3-D IPF point cloud ───────────────────────
74+
fig, (ax_map, ax_ipf) = apl.subplots(
75+
1, 2, figsize=(880, 420),
76+
help="Drag the crosshair: the sphere rotates to face that grain's\n"
77+
"crystal direction. Drag the sphere to orbit freely.")
78+
79+
vmap = ax_map.imshow(ipf_map) # (H, W, 3) → RGB
80+
vmap.set_title("IPF-Z orientation map")
81+
cross = vmap.add_widget("crosshair", cx=W // 2, cy=H // 2, color="#ffffff")
82+
83+
# reduced directions live on the unit sphere → fix bounds to keep the
84+
# origin centred and the geometry origin-true
85+
vipf = ax_ipf.scatter3d(
86+
reduced[:, 0], reduced[:, 1], reduced[:, 2],
87+
colors=grain_rgb_u8, point_size=6,
88+
x_label="[100]", y_label="[010]", z_label="[001]",
89+
bounds=((-1, 1),) * 3, zoom=1.4,
90+
)
91+
vipf.set_title("Reduced 3D IPF (cubic fundamental sector)")
92+
# Shaded unit sphere with lat/long wireframe behind the direction vectors
93+
vipf.set_sphere(1.0)
94+
95+
96+
# ── 5. Crosshair → highlight + rotate-to-face ───────────────────────────────
97+
def face_camera(v):
98+
"""(azimuth°, elevation°) that aim the camera straight down *v*.
99+
100+
With the turntable camera, the view faces unit vector ``v`` when
101+
``el = asin(vz)`` and ``az = atan2(vx, -vy)``.
102+
"""
103+
vx, vy, vz = v
104+
el = np.degrees(np.arcsin(np.clip(vz, -1.0, 1.0)))
105+
az = np.degrees(np.arctan2(vx, -vy))
106+
return az, el
107+
108+
109+
def show_orientation(gid: int) -> None:
110+
v = reduced[gid]
111+
vipf.set_highlight(*v, color="#ffffff", size=8)
112+
az, el = face_camera(v)
113+
vipf.set_view(azimuth=az, elevation=el)
114+
115+
116+
@cross.add_event_handler("pointer_move")
117+
def on_move(event):
118+
ix = int(np.clip(round(cross.cx), 0, W - 1))
119+
iy = int(np.clip(round(cross.cy), 0, H - 1))
120+
show_orientation(int(grain_id[iy, ix]))
121+
122+
123+
show_orientation(int(grain_id[H // 2, W // 2]))
124+
125+
fig # Interactive

0 commit comments

Comments
 (0)