Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
5c8c993
BUG: CubicOps - Recover precision for misorientations on cubic sym ops
imikejackson May 29, 2026
92452d2
ENH: Pole figure pipeline overhaul for MTEX compatibility
imikejackson Apr 24, 2026
53e9236
ENH: Squash-merge topic/color_palettes
imikejackson Apr 24, 2026
cf108a6
ENH: Squash-merge topic/add_inverse_pole_figure
imikejackson Apr 24, 2026
f18a6a0
TEST: IPF corner-probe + MTEX IPF legend comparison tool
imikejackson Apr 24, 2026
aba0c7a
TEST: Emit both TSL and Nolze-Hielscher IPF legends for MTEX comparison
imikejackson Apr 25, 2026
1c2c5e6
TEST: Emit gridded Nolze-Hielscher IPF legend variant for MTEX matching
imikejackson Apr 25, 2026
6f09813
TEST: Emit gridded TSL IPF legend variant for MTEX matching
imikejackson Apr 25, 2026
37497e1
BUG: Fix stray vertical column in 622 IPF legend rendering
imikejackson Apr 25, 2026
f4c91a6
BUG: GriddedColorKey 3-arg direction2Color must honor angleLimits
imikejackson Apr 25, 2026
162922f
BUG: GriddedColorKey snap must not push (eta, chi) outside SST
imikejackson Apr 25, 2026
cca6970
BUG: Drop eta clamp in GriddedColorKey, only clamp chi
imikejackson Apr 25, 2026
1e5f43d
ENH: Switch IPF/PF outputs from TIFF to PNG via STB
imikejackson Apr 25, 2026
f58c16f
DOC: Validate EbsdLib TSL against EDAX_TSL_IPF.bmp reference
imikejackson Apr 25, 2026
a98a0ba
ENH: Add PUCMColorKey (perceptually uniform IPF coloring)
imikejackson Apr 25, 2026
c504ed8
TEST: Emit PUCM IPF legend (per-pixel and gridded) per Laue class
imikejackson Apr 26, 2026
419f180
Phase-0 Design commits
imikejackson Apr 30, 2026
a2e1e21
ENH: HexagonalOps SymOps struct + convention-aware sym op tables (PR 2a)
imikejackson Apr 30, 2026
cad0b89
ENH: HexagonalOps generateSphereCoordsFromEulers honors HexConvention…
imikejackson Apr 30, 2026
3637da3
ENH: HexagonalOps IPF color path honors HexConvention (PR 2c)
imikejackson May 1, 2026
24ac9a1
ENH: Propagate SymOps + HexConvention dispatch to HexLow / TrigHigh /…
imikejackson May 1, 2026
ff90a18
DOC: Document canonical=X||a* decision and v2->v3 ordering finding (P…
imikejackson May 1, 2026
52891b5
ENH: render_ebsd CLI driver + smoke test for PF/IPF/legend matrix (PR…
imikejackson May 1, 2026
c163f56
BUG: PoleFigureCompositor was dropping config.hexConvention (PR 2g)
imikejackson May 1, 2026
17c734a
ENH: IPF legend labels honor HexConvention (PR 2h)
imikejackson May 1, 2026
cd27a9e
ENH: getDefaultPoleFigureNames honors HexConvention (PR 2i)
imikejackson May 5, 2026
60cf301
ENH: render_ebsd CLI rejects missing-output-dir and bad positional args
imikejackson May 5, 2026
9f706d3
STY: clang-format reflow + ODF bin clarifications
imikejackson May 5, 2026
c85c888
ENH: Crop IPF triangle legend to content (PR 2j)
imikejackson May 7, 2026
71a853a
ENH: InversePoleFigureConfiguration_t carries HexConvention (PR 2k)
imikejackson May 7, 2026
8a53bfb
ENH: ColorKeyKind dispatch on LaueOps; drop HexConvention from IPF color
imikejackson May 12, 2026
8c542c4
BUG: PUCMColorKey thread-races wlenthe lazy lookup-table init
imikejackson May 12, 2026
66658f4
TEST: Fix PoleFigure generation tests with new exemplars.
imikejackson May 12, 2026
afc2a82
DOC: Add EbsdLib 3.0 release checklist
imikejackson May 12, 2026
b482e8a
DOC: Mark Phase 1a/1c progress on the v3.0 release checklist
imikejackson May 12, 2026
ea11496
DOC: Mark Phase 1 complete on v3.0 checklist
imikejackson May 13, 2026
b853f56
ENH: Update HexConvention ordering so it plays nicer with upstream UI…
imikejackson May 13, 2026
93e3c5d
TEST: Stabilize MTEX position-validation goldens; mark Phase 2 complete
imikejackson May 13, 2026
c8c5328
DOC: Mark Phase 3 (downstream consumer audit) complete on v3.0 checklist
imikejackson May 13, 2026
9991457
DOC: v3.0.0 release notes + API reference
imikejackson May 13, 2026
265c1e5
DOC: Redraw X||a vs X||a* SVG with hexagonal basal plane
imikejackson May 13, 2026
ae33437
DOC: Drop Code_Review/ cross-refs from public release docs
imikejackson May 13, 2026
5b9aa89
BUG: NolzeHielscher color key produced flat gray for Triclinic -1
imikejackson May 14, 2026
c27a958
ENH: generate_ipf_legends emits PUCM legends; NH/PUCM smooth crop mat…
imikejackson May 14, 2026
086c963
ADD: clean PUCM IPF reference image for AllLaueClasses_RandO.ang
imikejackson May 29, 2026
2edbdb2
CMAKE: Add all EbsdLib executables to their own group.
imikejackson Jun 2, 2026
0467d90
COMP: Fix variable shadowing errors
imikejackson Jun 3, 2026
2da66b1
COMP: Fix compiler errors on MSVC
imikejackson Jun 3, 2026
1c514fe
BUG: PoleFigureCompositorTest tolerates cross-compiler raster drift
imikejackson Jun 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ debug
Test/ProjectTest/Build
CMakeUserPresets.json
.claude
Docs/superpowers

# Python build-related files
pyebsd/build/
Expand Down
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ option(EbsdLib_BUILD_H5SUPPORT "Build H5Support Library" OFF)


# set project's name
project(EbsdLibProj VERSION 2.4.0)
project(EbsdLibProj VERSION 3.0.0)


# Request C++17 standard, using new CMake variables.
Expand Down
361 changes: 0 additions & 361 deletions Code_Review/TODO.md

This file was deleted.

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
228 changes: 228 additions & 0 deletions Data/Pole_Figure_Validation/ReadMe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
# Pole Figure Position-Space Validation

## Why this exists

The EbsdLib v3.0 release includes major behavioral changes to the
pole-figure pipeline: hexagonal/trigonal direction conventions shifted to
`X||a*` (matching MTEX), `LaueOps::_calcRodNearestOrigin` was rewritten in
quaternion space to fix an undefined-behavior bug at 180° rotations,
several low-symmetry Laue classes had their symmetry orbits expanded, and
the underlying sphere-coordinate generation changed for every Laue class.

A pixel-level pole-figure comparison would not survive the planned
follow-on rewrite of the renderer (Lambert-square → MTEX-style direct
projection). Instead this directory holds a **position-space**
validation: for every `(canonical
orientation × Laue class × default plane family)` bucket, the projected
`(x, y)` positions of the symmetry-equivalent poles on the unit disk are
compared against MTEX, which we treat as crystallographic ground truth.
Position-space tests survive any future change to the pixel renderer.

The validation is checked at every test run via
`Source/Test/PoleFigurePositionTest.cpp`. The current state of the world:

> 396 buckets compared, 396 within tolerance `1e-5`, worst max-distance
> across all 1752 emitted points: `6.29 × 10⁻⁸`.

The MATLAB script sorts each bucket's `(px, py)` rows lexicographically
before writing, so two consecutive regenerations of the CSV are
byte-identical. This is purely to make `git diff` on the golden a clean
"the goldens moved" signal — the comparator in
`PoleFigurePositionTest.cpp` is order-independent, so the math is
unaffected by the ordering choice.

## What's in this directory

| File | Role |
| ---- | ---- |
| `pole_figure_euler_data.dream3d` | DREAM3D-NX HDF5 holding 12 EMsoftSO3-sampled orientation clouds (Cube, Goss, Brass, Copper, S, S1, S2, R, RC_rd1, RC_rd2, RC_nd1, RC_nd2). Currently informational — the C++ test uses ideal Bunge tuples baked into source, but this file is the canonical source the tuples are mirrored from. |
| `pole_figure_data.d3dpipeline` | The DREAM3D-NX pipeline JSON that produced the `.dream3d` file. Re-run via DREAM3D-NX if the orientation list needs to change. |
| `mtex_pole_figure_positions.m` | MATLAB script. Generates the golden CSV from MTEX. |
| `run_mtex_pole_figure_positions.sh` | zsh wrapper that launches MATLAB headlessly with MTEX initialized. |
| `mtex_pole_figure_positions.csv` | **The committed golden.** Loaded by `PoleFigurePositionTest` at test time and used as the comparison target. Regenerated by the MATLAB script above; the only reason to regenerate is if the canonical orientation list, Laue dispatch, or plane-family table changes. |
| `compare_pf_positions.py` | Standalone Python comparator. Diagnostic tool — not used by CI. Useful when investigating a regression because it prints the per-bucket worst-case pairs. The C++ test does the same comparison in-process, so day-to-day CI does not need this script. |
| `ReadMe.md` | This file. |

## CSV schema

Both `mtex_pole_figure_positions.csv` and the EbsdLib emission share this
schema, one row per projected pole:

```csv
orient_id,orient_name,rotation_point_group,symmetry_name,plane_family,x,y
0,Cube,432,Cubic_High m-3m,<001>,1.00000000,0.00000000
0,Cube,432,Cubic_High m-3m,<001>,-1.00000000,-0.00000000
0,Cube,432,Cubic_High m-3m,<001>,0.00000000,1.00000000
...
```

| Column | Meaning |
| ----------------------- | ------- |
| `orient_id` | 0-based index into the canonical orientation table. |
| `orient_name` | Human-readable label: `Cube`, `Goss`, `Brass`, `Copper`, `S`, `S1`, `S2`, `R`, `RC_rd1`, `RC_rd2`, `RC_nd1`, `RC_nd2`. |
| `rotation_point_group` | EbsdLib rotation point group string (`432`, `622`, `-3m`, etc.). The bucket join key. |
| `symmetry_name` | Human-readable Laue class. Informational — EbsdLib and MTEX do not always use identical strings here, and the comparison does not depend on the value. |
| `plane_family` | EbsdLib `getDefaultPoleFigureNames()` label (e.g. `<001>`, `<10-10>`). The bucket join key, must match exactly between sides. |
| `x`, `y` | Stereographic projection of the symmetry-equivalent direction onto the unit disk; 8 decimals. |

The bucket key for comparison is `(orient_id, rotation_point_group, plane_family)`.

## Canonical orientation table

| `orient_id` | Name | Bunge (φ₁, Φ, φ₂) deg |
| ----------- | --------- | --------------------- |
| 0 | Cube | (0, 0, 0) |
| 1 | Goss | (0, 45, 0) |
| 2 | Brass | (35, 45, 0) |
| 3 | Copper | (90, 35, 45) |
| 4 | S | (59, 37, 63) |
| 5 | S1 | (55, 30, 65) |
| 6 | S2 | (45, 35, 65) |
| 7 | R | (55, 75, 25) |
| 8 | RC_rd1 | (0, 20, 0) |
| 9 | RC_rd2 | (0, 35, 0) |
| 10 | RC_nd1 | (20, 0, 0) |
| 11 | RC_nd2 | (35, 0, 0) |

These are the EMsoftSO3Sampler centers used to sample the cloud
orientations in `pole_figure_euler_data.dream3d`.

## Per-Laue-class plane-family table

`PoleFigurePositionTest` and `mtex_pole_figure_positions.m` must agree on
this table to the character — it's the bucket-join key. EbsdLib's source
of truth is `LaueOps::getDefaultPoleFigureNames()`; MTEX mirrors it in
the script's `laue` struct. Hex/trig classes use the v3 `X||a*`
convention.

| `rotation_point_group` | Laue class | Family 0 | Family 1 | Family 2 |
| ---------------------- | -------------------- | --------- | ---------- | ---------- |
| `432` | Cubic_High m-3m | `<001>` | `<011>` | `<111>` |
| `23` | Cubic_Low m-3 | `<001>` | `<011>` | `<111>` |
| `622` | Hexagonal_High 6/mmm | `<0001>` | `<10-10>` | `<2-1-10>` |
| `6` | Hexagonal_Low 6/m | `<0001>` | `<10-10>` | `<11-20>` |
| `32` | Trigonal_High -3m | `<0001>` | `<0-110>` | `<1-100>` |
| `3` | Trigonal_Low -3 | `<0001>` | `<-1-120>` | `<2-1-10>` |
| `422` | Tetragonal_High 4/mmm| `<001>` | `<100>` | `<110>` |
| `4` | Tetragonal_Low 4/m | `<001>` | `<100>` | `<110>` |
| `222` | OrthoRhombic mmm | `<001>` | `<100>` | `<010>` |
| `2` | Monoclinic 2/m | `<001>` | `<100>` | `<010>` |
| `1` | Triclinic -1 | `<001>` | `<100>` | `<010>` |

## Methodology details that matter

### Stereographic projection convention

Both sides project upper-hemisphere unit vectors onto the disk via:

```
if (z < 0) { x, y, z = -x, -y, -z; } // antipodal fold to upper hemisphere
px = x / (1 + z)
py = y / (1 + z)
```

This is the same formula used by
`Source/EbsdLib/Utilities/ComputeStereographicProjection.cpp` for actual PF
rendering; the MATLAB script reproduces it explicitly.

### Symmetry-orbit deduplication

MTEX's `symmetrise(m, cs)` returns one entry per group element — for a
pole that lies on a symmetry element (e.g. the `[001]` direction under
m-3m sits on a 4-fold axis), several group elements stabilize the pole
and produce duplicate vectors. EbsdLib emits the unique orbit
(`|G| / |stabilizer|` entries). The MATLAB script deduplicates the
`symmetrise` output before projecting so the row counts agree per bucket.

### Cartesian normalization

MTEX's `Miller(h, k, l, cs)` is in lattice units — `Miller([1 1 1])` has
cartesian length √3, `Miller([0 0 0 1])` for hex with c=1.6 has length
1.6. EbsdLib explicitly normalizes its hardcoded direction vectors. The
MATLAB script normalizes after `ori * mSym` so both sides project
unit-length vectors.

### Equator antipode canonicalization

Stereographic projection sends equator points (z = 0) to the boundary
circle. The `if (z < 0)` fold rule is FP-unstable when z is essentially
zero — antipodal pairs `(+v, -v)` on the equator can land at either
`(x, y)` or `(-x, -y)` depending on which side of zero a rounding error
lands on. Both representations refer to the same crystallographic
direction. The comparator (both Python diagnostic and C++ test) folds
equator points to a canonical antipode (prefer y > 0; ties broken by
x > 0) before matching.

### Comparison

For each bucket key, the comparator runs greedy nearest-neighbor matching
between the EbsdLib points and the MTEX points (set sizes are ≤ 24, so
brute force is fine), and reports the maximum matched distance. The C++
test asserts every bucket's worst-case distance is below `1e-5`.

## Regenerating the golden

You only need to regenerate `mtex_pole_figure_positions.csv` if the
canonical orientation list, the Laue dispatch, or the plane-family table
changes — i.e. if the schema of what's being validated changes. Routine
EbsdLib code changes do not need a golden regeneration.

To regenerate:

```bash
./run_mtex_pole_figure_positions.sh
```

This launches MATLAB headlessly, runs `startup_mtex`, then runs
`mtex_pole_figure_positions.m`, which writes
`mtex_pole_figure_positions.csv` into this same directory. After
regeneration, run the test:

```bash
ctest -R "EbsdLib::PoleFigurePositionTest" --verbose
```

Inspect any per-bucket failures with the diagnostic comparator:

```bash
python3 compare_pf_positions.py \
<build>/Testing/Temporary/PoleFigurePositions/ebsdlib_pole_figure_positions.csv \
Data/Pole_Figure_Validation/mtex_pole_figure_positions.csv \
--tol 1e-5 --top 30
```

If the regenerated golden differs in any way that is *not* explained by
genuine convention agreement (e.g. an unexplained sign flip on an
interior point), do not commit the new golden — investigate first.
The whole point of the golden is that it is independent ground truth;
auto-rolling it forward to "make the test pass" defeats the purpose.

## Troubleshooting / Common patterns

### Per-bucket count mismatch

EbsdLib and MTEX disagree on how many points a bucket should have. Check:
- Did `LaueOps::getNumSymmetry()` change for the affected Laue class?
- Did `getDefaultPoleFigureNames()` change a label, breaking the bucket-join key?
- Is MTEX's `symmetrise` returning duplicates because the dedup step in
the MATLAB script broke?

### Worst pairs are all `(±x, ±y) ↔ (∓x, ∓y)` on the unit circle

That's the equator-antipode FP issue. The canonicalization step should
catch it. If it isn't, the equator detection threshold (`equatorEps =
1e-5`) may need adjusting, or the rotation result may be returning
sample-frame vectors with `z` slightly outside the expected range.

### Worst pairs have magnitudes outside the unit disk on the MTEX side

Cartesian-normalization step in the MATLAB script is not firing.
Re-check that `mag = sqrt(xs.^2 + ys.^2 + zs.^2)` is being applied to
the post-`ori * mSym` vector and that `xs ./ mag` etc. are stored back.

### Worst pairs have a clean reflection or rotation on every interior point

That's a real convention disagreement — most likely the Bunge convention
or the sample reference frame. Use the diagnostic comparator to confirm
it's systematic across all orientations before going looking for the
underlying convention mismatch.
146 changes: 146 additions & 0 deletions Data/Pole_Figure_Validation/compare_pf_positions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""
Compare an EbsdLib-emitted pole-figure-positions CSV against the committed
MTEX golden CSV bucket-by-bucket. Each bucket = (orient_id,
rotation_point_group, plane_family).

For each bucket, performs a greedy nearest-neighbor match between the EbsdLib
points and the MTEX points. Reports the max distance per bucket so problem
buckets sort to the top.

This script is a developer / diagnostic tool. The same comparison is performed
in-process by PoleFigurePositionTest.cpp at test time, so day-to-day CI does
not need this script. Use it when investigating a regression to inspect the
exact points and per-bucket worst-case distances.

Usage:
python3 compare_pf_positions.py <ebsdlib_csv> <mtex_csv> [--tol 1e-3]

The MTEX golden lives next to this script
(Data/Pole_Figure_Validation/mtex_pole_figure_positions.csv) and is
regenerated by run_mtex_pole_figure_positions.sh in this same directory.
The EbsdLib CSV is produced by PoleFigurePositionTest at
<build>/Testing/Temporary/PoleFigurePositions/ebsdlib_pole_figure_positions.csv.
"""
import argparse
import csv
import math
import os
import sys
from collections import defaultdict


def canonicalize_equator(x, y, equator_eps=1e-5):
"""Stereographic projection of upper-hemisphere unit vectors lands inside
the unit disk; equator points (z=0) land on the boundary circle. The
`if z < 0 ... flip` rule used by both EbsdLib and MTEX is FP-unstable
at z = 0, so antipodal pairs (+v, -v) on the equator can land at either
(x, y) or (-x, -y) depending on which side of zero a rounding error
lands. Both representations refer to the same crystallographic direction.

To make the comparison stable, fold equator points (r >= 1 - eps) to a
canonical antipode: prefer y > 0; ties broken by x > 0. Interior points
are untouched."""
r2 = x * x + y * y
if r2 < (1.0 - equator_eps) ** 2:
return x, y
if y < -equator_eps:
return -x, -y
if abs(y) < equator_eps and x < 0.0:
return -x, -y
return x, y


def load_csv(path):
buckets = defaultdict(list)
with open(path, newline="") as f:
reader = csv.DictReader(f)
for row in reader:
key = (int(row["orient_id"]), row["rotation_point_group"], row["plane_family"])
x, y = canonicalize_equator(float(row["x"]), float(row["y"]))
buckets[key].append((x, y))
return buckets


def greedy_match_max_distance(a_pts, b_pts):
"""Greedy nearest-neighbor: for each point in `a_pts` find nearest unused
in `b_pts`, return the maximum matched distance. If lengths differ, returns
+inf (the prior count check should have caught that, but be safe)."""
if len(a_pts) != len(b_pts):
return math.inf, None
used = [False] * len(b_pts)
worst = 0.0
worst_pair = None
for ax, ay in a_pts:
best_d = math.inf
best_j = -1
for j, (bx, by) in enumerate(b_pts):
if used[j]:
continue
d = math.hypot(ax - bx, ay - by)
if d < best_d:
best_d = d
best_j = j
used[best_j] = True
if best_d > worst:
worst = best_d
worst_pair = ((ax, ay), b_pts[best_j])
return worst, worst_pair


def main():
ap = argparse.ArgumentParser()
ap.add_argument("ebsdlib_csv", help="EbsdLib-emitted CSV (typically <build>/Testing/Temporary/PoleFigurePositions/ebsdlib_pole_figure_positions.csv)")
ap.add_argument("mtex_csv", help="MTEX golden CSV (typically Data/Pole_Figure_Validation/mtex_pole_figure_positions.csv)")
ap.add_argument("--tol", type=float, default=1e-3, help="Per-bucket max-distance tolerance (default 1e-3)")
ap.add_argument("--top", type=int, default=20, help="Show top-N worst buckets (default 20)")
args = ap.parse_args()

for p in (args.ebsdlib_csv, args.mtex_csv):
if not os.path.isfile(p):
print(f"missing CSV: {p}", file=sys.stderr)
return 1

eb = load_csv(args.ebsdlib_csv)
mt = load_csv(args.mtex_csv)

eb_keys = set(eb.keys())
mt_keys = set(mt.keys())
only_eb = eb_keys - mt_keys
only_mt = mt_keys - eb_keys
common = sorted(eb_keys & mt_keys)

if only_eb or only_mt:
print(f"BUCKET KEY MISMATCH: only-in-EbsdLib={len(only_eb)}, only-in-MTEX={len(only_mt)}")
for k in sorted(only_eb)[:5]:
print(f" only EbsdLib: {k}")
for k in sorted(only_mt)[:5]:
print(f" only MTEX: {k}")

results = []
for key in common:
worst, pair = greedy_match_max_distance(eb[key], mt[key])
results.append((worst, key, pair, len(eb[key])))

results.sort(key=lambda r: -r[0])

fail = sum(1 for w, *_ in results if w > args.tol)
npass = len(results) - fail
print(f"Buckets: {len(results)} compared, {npass} within tol={args.tol}, {fail} over tol")
print()
print("Top-{} worst buckets (max greedy-NN distance per bucket):".format(args.top))
print(f"{'orient_id':>10} {'rpg':>6} {'family':<14} {'pts':>4} {'max_d':>14} {'pair (eb -> mt)':<60}")
for worst, key, pair, n in results[: args.top]:
oid, rpg, fam = key
if pair is None:
pair_str = "(count mismatch)"
else:
(ex, ey), (mx, my) = pair
pair_str = f"({ex:+.5f}, {ey:+.5f}) -> ({mx:+.5f}, {my:+.5f})"
print(f"{oid:>10} {rpg:>6} {fam:<14} {n:>4} {worst:>14.2e} {pair_str:<60}")

return 0 if fail == 0 else 2


if __name__ == "__main__":
sys.exit(main())
Loading
Loading