Repository: dzamkov/subsphere
Pull request #6: Remove Unsafe Blocks
Author: login: MorganBennetDev
Created: 2025-05-18T19:54:50Z | Merged: 2025-05-21T02:13:53Z

Does what it says on the tin. Also adds `bechmarks.txt` to keep track of major performance changes.

Eliminating unsafe blocks did not seem to have a significant impact on performance. Ergonomics of internal code may be slightly worse now since I changed some internal enums to `u8` newtypes (specifically for `match` statements, which now require a `_ => unreachable!()` branch), but the public parts of the code are unchanged.

Code should now also panic on invalid conversions from `u8` to `BaseTriSphere` (not other types because performance impacts were too large).

Eliminates instances of `unsafe { Some(x.unwrap_unchecked()) }` as these seemed redundant (if this specific pattern is important somehow, I can roll back the change quickly).

Next task on my to do list is (finally) improving performance now that I have a better handle on the code.


Diff:
diff --git a/benchmarks.txt b/benchmarks.txt
new file mode 100644
index 0000000..c70bfd4
--- /dev/null
+++ b/benchmarks.txt
@@ -0,0 +1,148 @@
+Last updated: 2025-05-18
+
+Timer precision: 100 ns
+tessellation                                fastest       │ slowest       │ median        │ mean          │ samples │ iters
+├─ hexsphere_icosa_fuller_b_bmod3_face                    │               │               │               │         │
+│  ├─ 1                                     1.399 µs      │ 10.39 µs      │ 1.399 µs      │ 1.543 µs      │ 100     │ 100
+│  ├─ 2                                     4.474 µs      │ 11.02 µs      │ 4.799 µs      │ 4.787 µs      │ 100     │ 400
+│  ├─ 4                                     8.799 µs      │ 12.19 µs      │ 8.999 µs      │ 9.139 µs      │ 100     │ 100
+│  ├─ 8                                     30.59 µs      │ 55.69 µs      │ 30.79 µs      │ 31.93 µs      │ 100     │ 100
+│  ├─ 16                                    100 µs        │ 178.7 µs      │ 111.7 µs      │ 110.9 µs      │ 100     │ 100
+│  ├─ 32                                    376.4 µs      │ 532 µs        │ 409 µs        │ 411.1 µs      │ 100     │ 100
+│  ├─ 64                                    1.388 ms      │ 2.577 ms      │ 1.629 ms      │ 1.646 ms      │ 100     │ 100
+│  ╰─ 128                                   6.041 ms      │ 6.914 ms      │ 6.364 ms      │ 6.366 ms      │ 100     │ 100
+├─ hexsphere_icosa_fuller_b_bmod3_vertex                  │               │               │               │         │
+│  ├─ 1                                     2.087 µs      │ 2.949 µs      │ 2.112 µs      │ 2.152 µs      │ 100     │ 800
+│  ├─ 2                                     8.399 µs      │ 13.89 µs      │ 8.499 µs      │ 8.647 µs      │ 100     │ 100
+│  ├─ 4                                     21.59 µs      │ 75.99 µs      │ 21.59 µs      │ 22.95 µs      │ 100     │ 100
+│  ├─ 8                                     87.99 µs      │ 128.9 µs      │ 88.29 µs      │ 91.11 µs      │ 100     │ 100
+│  ├─ 16                                    284.9 µs      │ 598.6 µs      │ 290.8 µs      │ 300.3 µs      │ 100     │ 100
+│  ├─ 32                                    1.174 ms      │ 1.495 ms      │ 1.208 ms      │ 1.235 ms      │ 100     │ 100
+│  ├─ 64                                    4.578 ms      │ 5.606 ms      │ 4.789 ms      │ 4.826 ms      │ 100     │ 100
+│  ╰─ 128                                   18.92 ms      │ 19.95 ms      │ 19.32 ms      │ 19.36 ms      │ 100     │ 100
+├─ hexsphere_icosa_gnomonic_b_bmod3_face                  │               │               │               │         │
+│  ├─ 1                                     1.462 µs      │ 8.549 µs      │ 1.487 µs      │ 1.671 µs      │ 100     │ 800
+│  ├─ 2                                     4.599 µs      │ 11.39 µs      │ 4.699 µs      │ 4.742 µs      │ 100     │ 100
+│  ├─ 4                                     9.099 µs      │ 24.19 µs      │ 9.199 µs      │ 9.791 µs      │ 100     │ 100
+│  ├─ 8                                     31.09 µs      │ 78.19 µs      │ 31.69 µs      │ 33.51 µs      │ 100     │ 100
+│  ├─ 16                                    99.89 µs      │ 383.3 µs      │ 106.3 µs      │ 124 µs        │ 100     │ 100
+│  ├─ 32                                    370.2 µs      │ 538 µs        │ 385.8 µs      │ 395.3 µs      │ 100     │ 100
+│  ├─ 64                                    1.355 ms      │ 1.788 ms      │ 1.464 ms      │ 1.503 ms      │ 100     │ 100
+│  ╰─ 128                                   5.925 ms      │ 6.807 ms      │ 6.243 ms      │ 6.269 ms      │ 100     │ 100
+├─ hexsphere_icosa_gnomonic_b_bmod3_vertex                │               │               │               │         │
+│  ├─ 1                                     981.1 ns      │ 1.599 µs      │ 1.031 µs      │ 1.054 µs      │ 100     │ 1600
+│  ├─ 2                                     3.449 µs      │ 5.499 µs      │ 3.474 µs      │ 3.564 µs      │ 100     │ 400
+│  ├─ 4                                     5.799 µs      │ 16.89 µs      │ 5.849 µs      │ 6.379 µs      │ 100     │ 200
+│  ├─ 8                                     22.19 µs      │ 297.3 µs      │ 22.39 µs      │ 26.68 µs      │ 100     │ 100
+│  ├─ 16                                    69.69 µs      │ 139.3 µs      │ 73.54 µs      │ 77.97 µs      │ 100     │ 100
+│  ├─ 32                                    273.2 µs      │ 419.9 µs      │ 283.3 µs      │ 291 µs        │ 100     │ 100
+│  ├─ 64                                    1.039 ms      │ 1.442 ms      │ 1.112 ms      │ 1.151 ms      │ 100     │ 100
+│  ╰─ 128                                   4.708 ms      │ 5.367 ms      │ 5.015 ms      │ 5.021 ms      │ 100     │ 100
+├─ hexsphere_octa_fuller_b_bmod3_face                     │               │               │               │         │
+│  ├─ 1                                     668.6 ns      │ 2.931 µs      │ 693.6 ns      │ 762.2 ns      │ 100     │ 1600
+│  ├─ 2                                     1.874 µs      │ 3.187 µs      │ 2.037 µs      │ 2.062 µs      │ 100     │ 800
+│  ├─ 4                                     3.724 µs      │ 9.924 µs      │ 3.774 µs      │ 4.035 µs      │ 100     │ 400
+│  ├─ 8                                     12.19 µs      │ 26.89 µs      │ 12.39 µs      │ 13.19 µs      │ 100     │ 100
+│  ├─ 16                                    37.69 µs      │ 53.39 µs      │ 38.39 µs      │ 39.19 µs      │ 100     │ 100
+│  ├─ 32                                    143.9 µs      │ 715.8 µs      │ 148.4 µs      │ 163.3 µs      │ 100     │ 100
+│  ├─ 64                                    533.6 µs      │ 729.7 µs      │ 559.5 µs      │ 569 µs        │ 100     │ 100
+│  ╰─ 128                                   2.302 ms      │ 2.657 ms      │ 2.394 ms      │ 2.41 ms       │ 100     │ 100
+├─ hexsphere_octa_fuller_b_bmod3_vertex                   │               │               │               │         │
+│  ├─ 1                                     856.1 ns      │ 1.668 µs      │ 893.6 ns      │ 916 ns        │ 100     │ 1600
+│  ├─ 2                                     3.624 µs      │ 9.799 µs      │ 3.649 µs      │ 3.805 µs      │ 100     │ 400
+│  ├─ 4                                     8.799 µs      │ 11.89 µs      │ 8.899 µs      │ 8.958 µs      │ 100     │ 100
+│  ├─ 8                                     36.19 µs      │ 122.4 µs      │ 36.29 µs      │ 37.55 µs      │ 100     │ 100
+│  ├─ 16                                    119 µs        │ 272.1 µs      │ 119.6 µs      │ 126.9 µs      │ 100     │ 100
+│  ├─ 32                                    486.6 µs      │ 610.2 µs      │ 502.1 µs      │ 504.2 µs      │ 100     │ 100
+│  ├─ 64                                    1.84 ms       │ 2.17 ms       │ 1.915 ms      │ 1.924 ms      │ 100     │ 100
+│  ╰─ 128                                   7.596 ms      │ 8.273 ms      │ 7.825 ms      │ 7.823 ms      │ 100     │ 100
+├─ hexsphere_octa_gnomonic_b_bmod3_face                   │               │               │               │         │
+│  ├─ 1                                     606.1 ns      │ 1.149 µs      │ 618.6 ns      │ 644.6 ns      │ 100     │ 1600
+│  ├─ 2                                     1.799 µs      │ 10.19 µs      │ 1.899 µs      │ 2.041 µs      │ 100     │ 100
+│  ├─ 4                                     3.674 µs      │ 5.224 µs      │ 3.799 µs      │ 3.838 µs      │ 100     │ 400
+│  ├─ 8                                     12.29 µs      │ 17.39 µs      │ 12.49 µs      │ 12.67 µs      │ 100     │ 100
+│  ├─ 16                                    38.39 µs      │ 114.6 µs      │ 38.89 µs      │ 40.3 µs       │ 100     │ 100
+│  ├─ 32                                    147.8 µs      │ 252.3 µs      │ 151 µs        │ 153.8 µs      │ 100     │ 100
+│  ├─ 64                                    546.6 µs      │ 676.3 µs      │ 562.1 µs      │ 566.1 µs      │ 100     │ 100
+│  ╰─ 128                                   2.289 ms      │ 2.659 ms      │ 2.374 ms      │ 2.383 ms      │ 100     │ 100
+├─ hexsphere_octa_gnomonic_b_bmod3_vertex                 │               │               │               │         │
+│  ├─ 1                                     374.8 ns      │ 1.09 µs       │ 378 ns        │ 394.1 ns      │ 100     │ 3200
+│  ├─ 2                                     1.399 µs      │ 5.349 µs      │ 1.449 µs      │ 1.477 µs      │ 100     │ 200
+│  ├─ 4                                     2.374 µs      │ 2.874 µs      │ 2.449 µs      │ 2.474 µs      │ 100     │ 400
+│  ├─ 8                                     8.949 µs      │ 26.74 µs      │ 9.049 µs      │ 9.286 µs      │ 100     │ 200
+│  ├─ 16                                    27.79 µs      │ 606.2 µs      │ 27.89 µs      │ 37.33 µs      │ 100     │ 100
+│  ├─ 32                                    107.4 µs      │ 343.8 µs      │ 112.4 µs      │ 116.9 µs      │ 100     │ 100
+│  ├─ 64                                    412.1 µs      │ 607 µs        │ 425.4 µs      │ 435.8 µs      │ 100     │ 100
+│  ╰─ 128                                   1.81 ms       │ 2.166 ms      │ 1.894 ms      │ 1.902 ms      │ 100     │ 100
+├─ trisphere_icosa_fuller_b_0_face                        │               │               │               │         │
+│  ├─ 1                                     1.262 µs      │ 5.249 µs      │ 1.287 µs      │ 1.37 µs       │ 100     │ 800
+│  ├─ 2                                     3.724 µs      │ 12.24 µs      │ 3.899 µs      │ 4.177 µs      │ 100     │ 400
+│  ├─ 4                                     12.49 µs      │ 20.99 µs      │ 13.09 µs      │ 13.36 µs      │ 100     │ 100
+│  ├─ 8                                     47.39 µs      │ 623.6 µs      │ 48.44 µs      │ 59.42 µs      │ 100     │ 100
+│  ├─ 16                                    172.6 µs      │ 442 µs        │ 184 µs        │ 195.2 µs      │ 100     │ 100
+│  ├─ 32                                    681.5 µs      │ 5.584 ms      │ 717.4 µs      │ 778.9 µs      │ 100     │ 100
+│  ├─ 64                                    3.126 ms      │ 3.451 ms      │ 3.259 ms      │ 3.268 ms      │ 100     │ 100
+│  ╰─ 128                                   12.94 ms      │ 14.81 ms      │ 13.44 ms      │ 13.46 ms      │ 100     │ 100
+├─ trisphere_icosa_fuller_b_0_vertex                      │               │               │               │         │
+│  ├─ 1                                     662.3 ns      │ 1.168 µs      │ 687.3 ns      │ 700.7 ns      │ 100     │ 1600
+│  ├─ 2                                     1.949 µs      │ 13.17 µs      │ 1.974 µs      │ 2.153 µs      │ 100     │ 400
+│  ├─ 4                                     12.09 µs      │ 26.99 µs      │ 12.19 µs      │ 12.77 µs      │ 100     │ 100
+│  ├─ 8                                     71.29 µs      │ 117.7 µs      │ 72.89 µs      │ 74.2 µs       │ 100     │ 100
+│  ├─ 16                                    351.6 µs      │ 492.7 µs      │ 355.8 µs      │ 360.3 µs      │ 100     │ 100
+│  ├─ 32                                    1.52 ms       │ 2.279 ms      │ 1.588 ms      │ 1.617 ms      │ 100     │ 100
+│  ├─ 64                                    6.602 ms      │ 8.976 ms      │ 6.789 ms      │ 6.829 ms      │ 100     │ 100
+│  ╰─ 128                                   27.45 ms      │ 39.49 ms      │ 28.25 ms      │ 28.6 ms       │ 100     │ 100
+├─ trisphere_icosa_gnomonic_b_0_face                      │               │               │               │         │
+│  ├─ 1                                     1.324 µs      │ 2.687 µs      │ 1.374 µs      │ 1.407 µs      │ 100     │ 800
+│  ├─ 2                                     3.924 µs      │ 5.599 µs      │ 4.124 µs      │ 4.3 µs        │ 100     │ 400
+│  ├─ 4                                     13.19 µs      │ 24.59 µs      │ 13.59 µs      │ 13.91 µs      │ 100     │ 100
+│  ├─ 8                                     49.59 µs      │ 69.59 µs      │ 51.09 µs      │ 52.55 µs      │ 100     │ 100
+│  ├─ 16                                    183.4 µs      │ 217.4 µs      │ 192.1 µs      │ 193.6 µs      │ 100     │ 100
+│  ├─ 32                                    701.1 µs      │ 1.565 ms      │ 737 µs        │ 759 µs        │ 100     │ 100
+│  ├─ 64                                    3.163 ms      │ 4.595 ms      │ 3.322 ms      │ 3.379 ms      │ 100     │ 100
+│  ╰─ 128                                   13.19 ms      │ 22.24 ms      │ 13.65 ms      │ 13.94 ms      │ 100     │ 100
+├─ trisphere_icosa_gnomonic_b_0_vertex                    │               │               │               │         │
+│  ├─ 1                                     612.3 ns      │ 1.131 µs      │ 637.3 ns      │ 641.7 ns      │ 100     │ 1600
+│  ├─ 2                                     1.887 µs      │ 3.012 µs      │ 1.924 µs      │ 1.949 µs      │ 100     │ 800
+│  ├─ 4                                     6.699 µs      │ 9.649 µs      │ 6.749 µs      │ 6.86 µs       │ 100     │ 200
+│  ├─ 8                                     25.29 µs      │ 50.79 µs      │ 25.39 µs      │ 25.67 µs      │ 100     │ 100
+│  ├─ 16                                    99.19 µs      │ 287.9 µs      │ 99.89 µs      │ 102.5 µs      │ 100     │ 100
+│  ├─ 32                                    385.7 µs      │ 2.886 ms      │ 397.7 µs      │ 486.8 µs      │ 100     │ 100
+│  ├─ 64                                    1.718 ms      │ 2.868 ms      │ 1.756 ms      │ 1.783 ms      │ 100     │ 100
+│  ╰─ 128                                   7.537 ms      │ 9.861 ms      │ 7.764 ms      │ 7.826 ms      │ 100     │ 100
+├─ trisphere_octa_fuller_b_0_face                         │               │               │               │         │
+│  ├─ 1                                     568.6 ns      │ 1.618 µs      │ 581.1 ns      │ 609.9 ns      │ 100     │ 1600
+│  ├─ 2                                     1.637 µs      │ 5.012 µs      │ 1.674 µs      │ 1.746 µs      │ 100     │ 800
+│  ├─ 4                                     5.299 µs      │ 66.29 µs      │ 5.499 µs      │ 6.313 µs      │ 100     │ 100
+│  ├─ 8                                     18.99 µs      │ 29.19 µs      │ 19.19 µs      │ 19.59 µs      │ 100     │ 100
+│  ├─ 16                                    69.59 µs      │ 167.9 µs      │ 74.59 µs      │ 76.85 µs      │ 100     │ 100
+│  ├─ 32                                    270.5 µs      │ 449.6 µs      │ 287.1 µs      │ 293 µs        │ 100     │ 100
+│  ├─ 64                                    1.169 ms      │ 1.496 ms      │ 1.224 ms      │ 1.243 ms      │ 100     │ 100
+│  ╰─ 128                                   5.172 ms      │ 12.46 ms      │ 5.508 ms      │ 5.619 ms      │ 100     │ 100
+├─ trisphere_octa_fuller_b_0_vertex                       │               │               │               │         │
+│  ├─ 1                                     337.3 ns      │ 574.8 ns      │ 343.6 ns      │ 348.4 ns      │ 100     │ 3200
+│  ├─ 2                                     962.3 ns      │ 8.381 µs      │ 999.8 ns      │ 1.099 µs      │ 100     │ 1600
+│  ├─ 4                                     5.299 µs      │ 10.19 µs      │ 5.399 µs      │ 5.509 µs      │ 100     │ 100
+│  ├─ 8                                     29.39 µs      │ 48.59 µs      │ 29.59 µs      │ 30.03 µs      │ 100     │ 100
+│  ├─ 16                                    143.6 µs      │ 179.2 µs      │ 146 µs        │ 147.6 µs      │ 100     │ 100
+│  ├─ 32                                    624.4 µs      │ 763.3 µs      │ 635.6 µs      │ 641.8 µs      │ 100     │ 100
+│  ├─ 64                                    2.679 ms      │ 3.439 ms      │ 2.747 ms      │ 2.786 ms      │ 100     │ 100
+│  ╰─ 128                                   11.18 ms      │ 18.45 ms      │ 11.56 ms      │ 11.69 ms      │ 100     │ 100
+├─ trisphere_octa_gnomonic_b_0_face                       │               │               │               │         │
+│  ├─ 1                                     590.5 ns      │ 853 ns        │ 606.1 ns      │ 610.3 ns      │ 100     │ 3200
+│  ├─ 2                                     1.762 µs      │ 2.049 µs      │ 1.812 µs      │ 1.814 µs      │ 100     │ 800
+│  ├─ 4                                     5.499 µs      │ 59.69 µs      │ 5.799 µs      │ 6.645 µs      │ 100     │ 100
+│  ├─ 8                                     19.89 µs      │ 32.69 µs      │ 20.39 µs      │ 20.79 µs      │ 100     │ 100
+│  ├─ 16                                    73.89 µs      │ 199.7 µs      │ 78.04 µs      │ 80.12 µs      │ 100     │ 100
+│  ├─ 32                                    282.8 µs      │ 660.4 µs      │ 291.2 µs      │ 300.3 µs      │ 100     │ 100
+│  ├─ 64                                    1.197 ms      │ 2.872 ms      │ 1.273 ms      │ 1.319 ms      │ 100     │ 100
+│  ╰─ 128                                   5.312 ms      │ 6.586 ms      │ 5.558 ms      │ 5.601 ms      │ 100     │ 100
+╰─ trisphere_octa_gnomonic_b_0_vertex                     │               │               │               │         │
+   ├─ 1                                     299.8 ns      │ 1.762 µs      │ 312.3 ns      │ 334.8 ns      │ 100     │ 800
+   ├─ 2                                     843.6 ns      │ 1.568 µs      │ 874.8 ns      │ 879 ns        │ 100     │ 1600
+   ├─ 4                                     2.799 µs      │ 9.274 µs      │ 2.849 µs      │ 3.009 µs      │ 100     │ 400
+   ├─ 8                                     10.19 µs      │ 17.89 µs      │ 10.29 µs      │ 10.44 µs      │ 100     │ 100
+   ├─ 16                                    38.69 µs      │ 48.39 µs      │ 39.69 µs      │ 39.86 µs      │ 100     │ 100
+   ├─ 32                                    153.8 µs      │ 250.8 µs      │ 159.5 µs      │ 161.5 µs      │ 100     │ 100
+   ├─ 64                                    636.3 µs      │ 5.877 ms      │ 650.8 µs      │ 858.8 µs      │ 100     │ 100
+   ╰─ 128                                   2.99 ms       │ 7.331 ms      │ 3.111 ms      │ 3.347 ms      │ 100     │ 100
diff --git a/src/basetri.rs b/src/basetri.rs
index 0ba0851..3e1f5da 100644
--- a/src/basetri.rs
+++ b/src/basetri.rs
@@ -129,6 +129,14 @@ pub struct Face(pub(crate) u8);
 impl Face {
     /// Gets the [`BaseTriSphere`] this face belongs to.
     pub const fn sphere(self) -> BaseTriSphere {
+        // SAFETY:
+        // Subtracting 12 and dividing by 8 are very cheap (second operation will be converted to a bitshift) so
+        // converting this to provably safe code (e.g. `if` statements to determine `BaseTriSphere` variant) will incur
+        // a performance penalty.
+        // Input to `std::mem::transmute` must be less than or equal to 2 so the input will be valid as long as the
+        // input is less than or equal to 35. Platonic solid share the same index space to avoid branching (0..20
+        // icosahedron, 20..28 octahedron, and 28..32 tetrahedron) meaning that the maximum value of `self.0` is 31 when
+        // constructed correctly, which is within the valid range. 
         unsafe { std::mem::transmute(self.0.saturating_sub(12) / 8) }
     }
 
@@ -221,7 +229,13 @@ pub struct Vertex(u8);
 impl Vertex {
     /// Gets the [`BaseTriSphere`] this vertex belongs to.
     pub const fn sphere(self) -> BaseTriSphere {
-        unsafe { std::mem::transmute(self.0.saturating_sub(6) / 6) }
+        if self.0 < 12 {
+            BaseTriSphere::Icosa
+        } else if self.0 < 18 {
+            BaseTriSphere::Octa
+        } else {
+            BaseTriSphere::Tetra
+        }
     }
 
     /// Gets the face which [owns](OwnershipInfo) this vertex.
@@ -267,7 +281,7 @@ impl HalfEdge {
         self.inside().sphere()
     }
 
-    /// The index of this half-edge within the [`sides`](crate::Face::sides) list of its
+    /// The index of this half-edge within the [`sides`](Face::sides) list of its
     /// [`inside`](HalfEdge::inside).
     pub const fn side_index(self) -> usize {
         (self.0 & 0b11) as usize
diff --git a/src/hex.rs b/src/hex.rs
index 1697174..b1907c3 100644
--- a/src/hex.rs
+++ b/src/hex.rs
@@ -51,7 +51,7 @@ impl<Proj> HexSphere<Proj> {
     }
 
     /// Replaces the projector of this sphere with the given one.
-    /// 
+    ///
     /// The resulting sphere will be topologically identical to this one, but the positions
     /// of the vertices will be changed according to the new projection.
     pub fn with_projector<NProj>(self, proj: NProj) -> HexSphere<NProj> {
@@ -186,14 +186,14 @@ impl<Proj: Eq + Clone + BaseTriProjector> crate::Sphere for HexSphere<Proj> {
     }
 
     fn face_at(&self, point: [f64; 3]) -> Face<Proj> {
-        unsafe { Face::from_kis(self.kis.face_at(point)).unwrap_unchecked() }
+        Face::from_kis_unchecked(self.kis.face_at(point))
     }
 
     fn num_vertices(&self) -> usize {
         self.dual_num_divisions() * self.kis.base().num_faces()
     }
 
-    fn vertex(&self, index: usize) -> Vertex<Proj> {
+    fn vertex(&self, _index: usize) -> Vertex<Proj> {
         todo!()
     }
 
@@ -258,20 +258,35 @@ impl<Proj: Eq + Clone + BaseTriProjector> Face<Proj> {
         if kis.sphere.b() % 3 != kis.sphere.c() % 3 {
             return None;
         }
+        
+        Some(Self::from_kis_unchecked(kis))
+    }
+    
+    /// Unchecked variant of [from_center](Face::from_center).
+    fn from_center_unchecked(center: tri::Vertex<Proj>) -> Self {
+        debug_assert_eq!(center.sphere.b() % 3, center.sphere.c() % 3, "Sphere b and c parameters must be congruent mod 3.");
+        debug_assert_eq!(center.u % 3, center.adjusted_v() % 3, "Vertex u and adjusted v must be congruent mod 3.");
+        Self { center }
+    }
+    
+    /// Unchecked variant of [from_kis](Face::from_kis).
+    fn from_kis_unchecked(kis: tri::Face<Proj>) -> Self {
+        debug_assert_eq!(kis.sphere.b() % 3, kis.sphere.c() % 3, "Kis b and c parameters must be congruent mod 3.");
+        
         let adj_v = if !kis.region.ty().is_edge() {
             kis.sphere.c()
         } else {
             0
         };
+        
         let [u, v] = match ((kis.u_0 + 2 * (kis.v_0 + adj_v)) % 3, kis.boundary_along_v) {
             (0, _) => [kis.u_0, kis.v_0],
             (1, _) => [kis.u_0, kis.v_0 + 1],
             (_, false) => [kis.u_0 + 1, kis.v_0],
             (_, true) => [kis.u_0 - 1, kis.v_0 + 1],
         };
-        Some(unsafe {
-            Self::from_center(tri::Vertex::new(kis.sphere, kis.region, u, v)).unwrap_unchecked()
-        })
+        
+        Self::from_center_unchecked(tri::Vertex::new(kis.sphere, kis.region, u, v))
     }
 
     /// The [`HexSphere`] that this [`Face`] belongs to.
@@ -489,11 +504,11 @@ impl<Proj> HexSphere<Proj> {
     fn base_face_index(&self, region: BaseRegion) -> usize {
         let face = region.owner();
         let num_owned_vertices_before = face.num_owned_vertices_before()
-            + (face.owns_vertex_1() && region.ty() > BaseRegionType::Edge0) as usize;
+            + (face.owns_vertex_1() && region.ty() > BaseRegionType::EDGE_0) as usize;
         let num_edge_regions_before =
-            face.num_owned_edges_before() + (region.ty() > BaseRegionType::Edge0) as usize;
+            face.num_owned_edges_before() + (region.ty() > BaseRegionType::EDGE_0) as usize;
         let num_interior_regions_before =
-            face.index() + (region.ty() > BaseRegionType::Interior) as usize;
+            face.index() + (region.ty() > BaseRegionType::INTERIOR) as usize;
         num_owned_vertices_before
             + num_edge_regions_before * self.num_faces_per_edge_region()
             + num_interior_regions_before * self.num_faces_per_interior_region()
@@ -503,9 +518,9 @@ impl<Proj> HexSphere<Proj> {
     fn base_vertex_index(&self, region: BaseRegion) -> usize {
         let face = region.owner();
         let num_edge_regions_before =
-            face.num_owned_edges_before() + (region.ty() > BaseRegionType::Edge0) as usize;
+            face.num_owned_edges_before() + (region.ty() > BaseRegionType::EDGE_0) as usize;
         let num_interior_regions_before =
-            face.index() + (region.ty() > BaseRegionType::Interior) as usize;
+            face.index() + (region.ty() > BaseRegionType::INTERIOR) as usize;
         num_edge_regions_before * self.num_vertices_per_edge_region()
             + num_interior_regions_before * self.num_vertices_per_interior_region()
     }
@@ -562,15 +577,12 @@ impl<Proj: Eq + Clone + BaseTriProjector> Iterator for FaceIter<Proj> {
     fn next(&mut self) -> Option<Face<Proj>> {
         loop {
             if self.u < self.u_end {
-                let res = unsafe {
-                    Face::from_center(tri::Vertex {
-                        sphere: self.sphere.kis.clone(),
-                        region: self.region,
-                        u: self.u,
-                        v: self.v,
-                    })
-                    .unwrap_unchecked()
-                };
+                let res = Face::from_center_unchecked(tri::Vertex {
+                    sphere: self.sphere.kis.clone(),
+                    region: self.region,
+                    u: self.u,
+                    v: self.v,
+                });
                 self.u += 3;
                 return Some(res);
             } else if self.region.ty().is_edge() {
@@ -579,18 +591,15 @@ impl<Proj: Eq + Clone + BaseTriProjector> Iterator for FaceIter<Proj> {
                     self.u = 1 + (self.v + 2) % 3;
                     continue;
                 } else if self.u <= self.u_end
-                    && self.region.ty() == BaseRegionType::Edge0
+                    && self.region.ty() == BaseRegionType::EDGE_0
                     && self.region.owner().owns_vertex_1()
                 {
-                    let res = unsafe {
-                        Face::from_center(tri::Vertex {
-                            sphere: self.sphere.kis.clone(),
-                            region: self.region,
-                            u: self.u,
-                            v: self.v,
-                        })
-                        .unwrap_unchecked()
-                    };
+                    let res = Face::from_center_unchecked(tri::Vertex {
+                        sphere: self.sphere.kis.clone(),
+                        region: self.region,
+                        u: self.u,
+                        v: self.v,
+                    });
                     self.u += 3;
                     return Some(res);
                 }
diff --git a/src/lib.rs b/src/lib.rs
index c551117..23048d5 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,4 +1,5 @@
 #![cfg_attr(not(doctest), doc = include_str!("../README.md"))]
+#![deny(clippy::undocumented_unsafe_blocks)]
 #![deny(missing_debug_implementations)]
 #![deny(missing_docs)]
 mod math;
diff --git a/src/math.rs b/src/math.rs
index 67f3adb..33c4dc5 100644
--- a/src/math.rs
+++ b/src/math.rs
@@ -138,20 +138,20 @@ pub fn solve_cubic(a: f64, b: f64, c: f64, d: f64) -> impl Iterator<Item = f64>
     // formula for small leading coefficients?
     if a.abs() < SMALL {
         let disc = c * c - 4.0 * b * d;
-        if disc >= 0.0 {
+        return if disc >= 0.0 {
             let u = -c - c.signum() * disc.sqrt();
             let mut iter = [0.0, u / (2.0 * b), 2.0 * d / u].into_iter();
             iter.next().unwrap();
             if b.abs() < SMALL {
                 iter.next().unwrap();
             }
-            return iter;
+            iter
         } else {
             let mut iter = [0.0, 0.0, 0.0].into_iter();
             iter.next().unwrap();
             iter.next().unwrap();
             iter.next().unwrap();
-            return iter;
+            iter
         }
     }
 
diff --git a/src/proj.rs b/src/proj.rs
index 81d89da..210210d 100644
--- a/src/proj.rs
+++ b/src/proj.rs
@@ -67,12 +67,12 @@ impl<T: Projection> Projection for Transform<T> {
             .to_sphere(vec::add(self.offset, mat::apply(self.linear, coords)))
     }
 
-    fn from_sphere(&self, point: [f64; 3]) -> [f64; 2] {
+    fn from_sphere(&self, _point: [f64; 3]) -> [f64; 2] {
         todo!()
     }
 
     type Transform = Self;
-    fn transform(self, offset: [f64; 2], linear: [[f64; 2]; 2]) -> Self {
+    fn transform(self, _offset: [f64; 2], _linear: [[f64; 2]; 2]) -> Self {
         todo!()
     }
 }
@@ -132,7 +132,7 @@ pub mod tri {
     }
 
     /// The default [`BaseTriProjector`] used when a projector is not explicitly specified.
-    /// 
+    ///
     /// Ideally, this would be defined as an
     /// [impl Trait](https://github.com/rust-lang/rust/issues/63063), but that is not yet supported
     /// in stable Rust.
@@ -300,14 +300,14 @@ pub mod fuller {
             // Special handling for boundaries to improve accuracy and performance
             if v <= 0.0 {
                 debug_assert_eq!(v, 0.0);
-                if u <= 0.0 {
+                return if u <= 0.0 {
                     debug_assert_eq!(u, 0.0);
-                    return self.points[0];
+                    self.points[0]
                 } else {
-                    return vec::normalize(vec::add(
+                    vec::normalize(vec::add(
                         vec::mul(self.points[0], ((1.0 - u) * self.angle).sin()),
                         vec::mul(self.points[1], (u * self.angle).sin()),
-                    ));
+                    ))
                 }
             } else if u <= 0.0 {
                 debug_assert_eq!(u, 0.0);
diff --git a/src/tri.rs b/src/tri.rs
index d84f7a6..ec9b38d 100644
--- a/src/tri.rs
+++ b/src/tri.rs
@@ -32,7 +32,7 @@ impl<Proj> TriSphere<Proj> {
     pub const fn new(base: BaseTriSphere, proj: Proj, b: NonZero<u32>, c: u32) -> Self {
         assert!(
             b.get() < u32::MAX >> 2,
-            "b exceeds maxmimum subdivision size"
+            "b exceeds maximum subdivision size"
         );
         assert!(b.get() >= c, "b must be greater than or equal to c");
         Self {
@@ -56,6 +56,10 @@ impl<Proj> TriSphere<Proj> {
 
     /// The base shape of this subdivided sphere.
     pub const fn base(&self) -> BaseTriSphere {
+        // SAFETY:
+        // Directly converting from `u8` to `BaseTriSphere` with `std::mem::transmute` is the fastest way to retrieve
+        // the base sphere. The input to `transmute` is guaranteed to be valid as long as other parts of the library use
+        // provided methods on `TriSphere` for modifying `base` and do not directly modify the `c_base` field.
         unsafe { std::mem::transmute((self.c_base & 0b11) as u8) }
     }
 
@@ -164,9 +168,9 @@ impl<Proj: Eq + Clone + BaseTriProjector> crate::Sphere for TriSphere<Proj> {
         let mut edge_0 = HalfEdge {
             sphere: self.clone(),
             region,
-            start_u: (u + 1),
+            start_u: u + 1,
             start_v: v,
-            dir: HalfEdgeDir::UnVp,
+            dir: HalfEdgeDir::UN_VP,
         };
         if mat::det_3([point, v_0, v_1]) < 0.0 {
             std::mem::swap(&mut v_0, &mut v_1);
@@ -202,7 +206,7 @@ impl<Proj: Eq + Clone + BaseTriProjector> crate::Sphere for TriSphere<Proj> {
         self.num_faces() / 2 + 2
     }
 
-    fn vertex(&self, index: usize) -> Vertex<Proj> {
+    fn vertex(&self, _index: usize) -> Vertex<Proj> {
         todo!()
     }
 
@@ -264,7 +268,7 @@ fn test_center() {
     use crate::util::tri_area;
     let sphere = TriSphere::new(
         BaseTriSphere::Icosa,
-        crate::proj::Fuller,
+        proj::Fuller,
         NonZero::new(4).unwrap(),
         1,
     );
@@ -313,12 +317,12 @@ impl<Proj: Eq + Clone + BaseTriProjector> crate::Face for Face<Proj> {
     fn side(&self, index: usize) -> HalfEdge<Proj> {
         assert!(index < 3, "side index out of bounds: {}", index);
         let (d_u, d_v, dir) = [
-            (0, 0, HalfEdgeDir::Up),
-            (0, 0, HalfEdgeDir::Vp),
-            (1, 0, HalfEdgeDir::UnVp),
-            (0, 1, HalfEdgeDir::Un),
-            (0, 1, HalfEdgeDir::Vn),
-            (-1, 1, HalfEdgeDir::UpVn),
+            (0, 0, HalfEdgeDir::UP),
+            (0, 0, HalfEdgeDir::VP),
+            (1, 0, HalfEdgeDir::UN_VP),
+            (0, 1, HalfEdgeDir::UN),
+            (0, 1, HalfEdgeDir::VN),
+            (-1, 1, HalfEdgeDir::UP_VN),
         ][(index << 1) | self.boundary_along_v as usize];
         HalfEdge {
             sphere: self.sphere.clone(),
@@ -448,7 +452,7 @@ impl<Proj: Eq + Clone + BaseTriProjector> Vertex<Proj> {
                 0 => {
                     return Self {
                         sphere,
-                        region: BaseRegion::new(edge.inside(), BaseRegionType::Edge0),
+                        region: BaseRegion::new(edge.inside(), BaseRegionType::EDGE_0),
                         u,
                         v: 0,
                     };
@@ -460,7 +464,7 @@ impl<Proj: Eq + Clone + BaseTriProjector> Vertex<Proj> {
                         let v = sphere.c();
                         return Self {
                             sphere,
-                            region: BaseRegion::new(edge.inside(), BaseRegionType::Edge2),
+                            region: BaseRegion::new(edge.inside(), BaseRegionType::EDGE_2),
                             u,
                             v,
                         };
@@ -474,7 +478,7 @@ impl<Proj: Eq + Clone + BaseTriProjector> Vertex<Proj> {
                 let v = sphere.c();
                 Self {
                     sphere,
-                    region: BaseRegion::new(comp.inside(), BaseRegionType::Edge0),
+                    region: BaseRegion::new(comp.inside(), BaseRegionType::EDGE_0),
                     u,
                     v,
                 }
@@ -483,7 +487,7 @@ impl<Proj: Eq + Clone + BaseTriProjector> Vertex<Proj> {
                 debug_assert!(comp.inside().owns_edge_2());
                 Self {
                     sphere,
-                    region: BaseRegion::new(comp.inside(), BaseRegionType::Edge2),
+                    region: BaseRegion::new(comp.inside(), BaseRegionType::EDGE_2),
                     u,
                     v: 0,
                 }
@@ -498,7 +502,7 @@ impl<Proj: Eq + Clone + BaseTriProjector> Vertex<Proj> {
         debug_assert_eq!(sphere.b(), sphere.c());
         Self {
             sphere,
-            region: BaseRegion::new(face, BaseRegionType::Interior),
+            region: BaseRegion::new(face, BaseRegionType::INTERIOR),
             u: 0,
             v: 0,
         }
@@ -509,7 +513,7 @@ impl<Proj: Eq + Clone + BaseTriProjector> Vertex<Proj> {
         if source.index() == 0 {
             Self {
                 sphere,
-                region: BaseRegion::new(source.owner(), BaseRegionType::Edge0),
+                region: BaseRegion::new(source.owner(), BaseRegionType::EDGE_0),
                 u: 0,
                 v: 0,
             }
@@ -518,7 +522,7 @@ impl<Proj: Eq + Clone + BaseTriProjector> Vertex<Proj> {
             let v = sphere.c();
             Self {
                 sphere,
-                region: BaseRegion::new(source.owner(), BaseRegionType::Edge0),
+                region: BaseRegion::new(source.owner(), BaseRegionType::EDGE_0),
                 u,
                 v,
             }
@@ -528,7 +532,7 @@ impl<Proj: Eq + Clone + BaseTriProjector> Vertex<Proj> {
     /// Determines whether this vertex corresponds to a [`BaseVertex`] and if so, returns it.
     pub fn as_base(&self) -> Option<BaseVertex> {
         match self.region.ty() {
-            BaseRegionType::Edge0 => {
+            BaseRegionType::EDGE_0 => {
                 if self.u == 0 {
                     debug_assert_eq!(self.v, 0);
                     Some(self.region.owner().vertex(0))
@@ -539,7 +543,7 @@ impl<Proj: Eq + Clone + BaseTriProjector> Vertex<Proj> {
                     None
                 }
             }
-            BaseRegionType::Edge2 => {
+            BaseRegionType::EDGE_2 => {
                 if self.u == self.sphere.b() {
                     debug_assert_eq!(self.v, self.sphere.c());
                     Some(self.region.owner().vertex(2))
@@ -619,28 +623,46 @@ pub struct HalfEdge<Proj> {
 }
 
 /// Identifies a possible direction of a half-edge with respect to its [`BaseFace`].
-#[repr(u8)]
 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
-pub(crate) enum HalfEdgeDir {
-    /// The +U direction.
-    Up = 0,
+#[repr(transparent)]
+pub(crate) struct HalfEdgeDir(u8);
 
+impl HalfEdgeDir {
+    /// The +U direction.
+    pub const UP: Self = Self(0);
     /// The +V direction.
-    Vp = 1,
-
+    pub const VP: Self = Self(1);
     /// The -U/+V direction.
-    UnVp = 2,
-
+    pub const UN_VP: Self = Self(2);
     /// The -U direction.
-    Un = 3,
-
+    pub const UN: Self = Self(3);
     /// The -V direction.
-    Vn = 4,
-
+    pub const VN: Self = Self(4);
     /// The +U/-V direction.
-    UpVn = 5,
+    pub const UP_VN: Self = Self(5);
+    
+    /// Constructs a [`HalfEdgeDir`] from the given index.
+    ///
+    /// This corresponds to an angle of `index * 60` degrees.
+    pub fn from_index(index: usize) -> Self {
+        assert!(index < 6, "index out of bounds: {}", index);
+        Self(index as u8)
+    }
+    
+    /// Converts this direction into a vector representation.
+    pub fn into_vec(self) -> [i32; 2] {
+        let u = (0x020100000102u64 >> (self.0 as usize * 8)) as u8 as i32 - 1;
+        let v = (0x000001020201u64 >> (self.0 as usize * 8)) as u8 as i32 - 1;
+        [u, v]
+    }
+    
+    /// Rotates this [`HalfEdgeDir`] counter-clockwise by `amount * 60` degrees.
+    pub fn rotate_ccw(self, amount: usize) -> Self {
+        Self(((self.0 as usize + amount) % 6) as u8)
+    }
 }
 
+
 impl<Proj> HalfEdge<Proj> {
     /// Attempts to construct a [`HalfEdge`] with the given properties, normalizing
     /// `region` to be its proper owner.
@@ -725,7 +747,7 @@ impl<Proj> HalfEdge<Proj> {
     ) -> Self {
         if start_u == 0 {
             let c = sphere.c();
-            if dir > HalfEdgeDir::Up {
+            if dir > HalfEdgeDir::UP {
                 return Self::on_edge_u_boundary(
                     sphere,
                     bottom.prev().twin(),
@@ -737,9 +759,9 @@ impl<Proj> HalfEdge<Proj> {
         let n = sphere.b() - sphere.c();
         let in_region = if start_u >= n {
             debug_assert_eq!(start_u, n);
-            dir == HalfEdgeDir::UnVp
+            dir == HalfEdgeDir::UN_VP
         } else {
-            dir < HalfEdgeDir::Un
+            dir < HalfEdgeDir::UN
         };
         if !in_region {
             let start_u = sphere.b() - start_u;
@@ -752,7 +774,7 @@ impl<Proj> HalfEdge<Proj> {
         };
         Self {
             sphere,
-            region: BaseRegion::new(bottom.inside(), BaseRegionType::Interior),
+            region: BaseRegion::new(bottom.inside(), BaseRegionType::INTERIOR),
             start_u,
             start_v,
             dir,
@@ -769,12 +791,12 @@ impl<Proj> HalfEdge<Proj> {
         dir: HalfEdgeDir,
     ) -> Self {
         debug_assert!(start_v > 0);
-        if dir >= HalfEdgeDir::Vn {
+        if dir >= HalfEdgeDir::VN {
             Self::on_edge_exclusive(sphere, edge, 0, start_v, dir)
         } else if start_v >= sphere.c() {
             debug_assert_eq!(start_v, sphere.c());
             Self::on_interior_boundary(sphere, edge, 0, dir)
-        } else if dir == HalfEdgeDir::Up {
+        } else if dir == HalfEdgeDir::UP {
             Self::on_edge_exclusive(sphere, edge, 0, start_v, dir)
         } else {
             Self::on_edge_u_boundary(sphere, edge.prev().twin(), start_v, dir.rotate_ccw(5))
@@ -793,15 +815,15 @@ impl<Proj> HalfEdge<Proj> {
         } else if start_u == 0 {
             let mut edge = edge;
             let mut dir = dir;
-            while dir > HalfEdgeDir::Up {
+            while dir > HalfEdgeDir::UP {
                 edge = edge.prev().twin();
                 dir = dir.rotate_ccw(5);
             }
-            Self::on_edge_exclusive(sphere, edge, 0, 0, HalfEdgeDir::Up)
-        } else if start_u == sphere.b() && dir == HalfEdgeDir::Up {
+            Self::on_edge_exclusive(sphere, edge, 0, 0, HalfEdgeDir::UP)
+        } else if start_u == sphere.b() && dir == HalfEdgeDir::UP {
             let c = sphere.c();
-            Self::on_edge_u_boundary(sphere, edge.twin().prev().twin(), c, HalfEdgeDir::UnVp)
-        } else if dir < HalfEdgeDir::Un {
+            Self::on_edge_u_boundary(sphere, edge.twin().prev().twin(), c, HalfEdgeDir::UN_VP)
+        } else if dir < HalfEdgeDir::UN {
             Self::on_edge_exclusive(sphere, edge, start_u, 0, dir)
         } else if start_u <= sphere.c() {
             Self::on_edge_v_boundary(sphere, edge.twin().next(), start_u, dir.rotate_ccw(1))
@@ -825,7 +847,7 @@ impl<Proj> HalfEdge<Proj> {
             0 => {
                 return Self {
                     sphere,
-                    region: BaseRegion::new(edge.inside(), BaseRegionType::Edge0),
+                    region: BaseRegion::new(edge.inside(), BaseRegionType::EDGE_0),
                     start_u,
                     start_v,
                     dir,
@@ -838,7 +860,7 @@ impl<Proj> HalfEdge<Proj> {
                     let start_v = sphere.c() - start_v;
                     return Self {
                         sphere,
-                        region: BaseRegion::new(edge.inside(), BaseRegionType::Edge2),
+                        region: BaseRegion::new(edge.inside(), BaseRegionType::EDGE_2),
                         start_u,
                         start_v,
                         dir: dir.rotate_ccw(3),
@@ -853,7 +875,7 @@ impl<Proj> HalfEdge<Proj> {
             let start_v = sphere.c() - start_v;
             Self {
                 sphere,
-                region: BaseRegion::new(comp.inside(), BaseRegionType::Edge0),
+                region: BaseRegion::new(comp.inside(), BaseRegionType::EDGE_0),
                 start_u,
                 start_v,
                 dir: dir.rotate_ccw(3),
@@ -863,7 +885,7 @@ impl<Proj> HalfEdge<Proj> {
             debug_assert!(comp.inside().owns_edge_2());
             Self {
                 sphere,
-                region: BaseRegion::new(comp.inside(), BaseRegionType::Edge2),
+                region: BaseRegion::new(comp.inside(), BaseRegionType::EDGE_2),
                 start_u,
                 start_v,
                 dir,
@@ -876,29 +898,29 @@ impl<Proj> HalfEdge<Proj> {
         if sphere.c() == 0 {
             // In this case, edge regions do not own any faces, and therefore no half-edges.
             let (start_u, start_v, dir) = match source.side_index() {
-                0 => (0, 0, HalfEdgeDir::Up),
-                1 => (sphere.b(), 0, HalfEdgeDir::UnVp),
-                _ => (0, sphere.b(), HalfEdgeDir::Vn),
+                0 => (0, 0, HalfEdgeDir::UP),
+                1 => (sphere.b(), 0, HalfEdgeDir::UN_VP),
+                _ => (0, sphere.b(), HalfEdgeDir::VN),
             };
             Self {
                 sphere,
-                region: BaseRegion::new(source.inside(), BaseRegionType::Interior),
+                region: BaseRegion::new(source.inside(), BaseRegionType::INTERIOR),
                 start_u,
                 start_v,
                 dir,
             }
         } else {
-            Self::on_edge_exclusive(sphere, source, 0, 0, HalfEdgeDir::Up)
+            Self::on_edge_exclusive(sphere, source, 0, 0, HalfEdgeDir::UP)
         }
     }
 }
 
 impl<Proj: Eq + Clone + BaseTriProjector> crate::HalfEdge for HalfEdge<Proj> {
-    type Vertex = Vertex<Proj>;
     type Face = Face<Proj>;
+    type Vertex = Vertex<Proj>;
 
     fn side_index(&self) -> usize {
-        self.dir as usize / 2
+        self.dir.0 as usize / 2
     }
 
     fn length(&self) -> f64 {
@@ -914,19 +936,20 @@ impl<Proj: Eq + Clone + BaseTriProjector> crate::HalfEdge for HalfEdge<Proj> {
 
     fn inside(&self) -> Face<Proj> {
         let (d_u, d_v) = match self.dir {
-            HalfEdgeDir::Up => (0, 0),
-            HalfEdgeDir::Vp => (0, 0),
-            HalfEdgeDir::UnVp => (-1, 0),
-            HalfEdgeDir::Un => (0, -1),
-            HalfEdgeDir::Vn => (0, -1),
-            HalfEdgeDir::UpVn => (1, -1),
+            HalfEdgeDir::UP => (0, 0),
+            HalfEdgeDir::VP => (0, 0),
+            HalfEdgeDir::UN_VP => (-1, 0),
+            HalfEdgeDir::UN => (0, -1),
+            HalfEdgeDir::VN => (0, -1),
+            HalfEdgeDir::UP_VN => (1, -1),
+            _ => unreachable!()
         };
         Face {
             sphere: self.sphere.clone(),
             region: self.region,
             u_0: self.start_u.wrapping_add_signed(d_u),
             v_0: self.start_v.wrapping_add_signed(d_v),
-            boundary_along_v: (self.dir as usize) % 2 == 1,
+            boundary_along_v: (self.dir.0 as usize) % 2 == 1,
         }
     }
 
@@ -969,37 +992,14 @@ impl<Proj: Eq + Clone + BaseTriProjector> crate::HalfEdge for HalfEdge<Proj> {
     }
 }
 
-impl HalfEdgeDir {
-    /// Constructs a [`HalfEdgeDir`] from the given index.
-    ///
-    /// This corresponds to an angle of `index * 60` degrees.
-    pub fn from_index(index: usize) -> Self {
-        assert!(index < 6, "index out of bounds: {}", index);
-        unsafe { std::mem::transmute(index as u8) }
-    }
-
-    /// Converts this direction into a vector representation.
-    pub fn into_vec(self) -> [i32; 2] {
-        let u = (0x020100000102u64 >> (self as usize * 8)) as u8 as i32 - 1;
-        let v = (0x000001020201u64 >> (self as usize * 8)) as u8 as i32 - 1;
-        [u, v]
-    }
-
-    /// Rotates this [`HalfEdgeDir`] counter-clockwise by `amount * 60` degrees.
-    pub fn rotate_ccw(self, amount: usize) -> Self {
-        let index = (self as usize + amount) % 6;
-        unsafe { std::mem::transmute(index as u8) }
-    }
-}
-
 impl<Proj> TriSphere<Proj> {
     /// Gets the index of the first face which is owned by the given base region.
     fn base_face_index(&self, region: BaseRegion) -> usize {
         let face = region.owner();
         let num_edge_regions_before =
-            face.num_owned_edges_before() + (region.ty() > BaseRegionType::Edge0) as usize;
+            face.num_owned_edges_before() + (region.ty() > BaseRegionType::EDGE_0) as usize;
         let num_interior_regions_before =
-            face.index() + (region.ty() > BaseRegionType::Interior) as usize;
+            face.index() + (region.ty() > BaseRegionType::INTERIOR) as usize;
         num_edge_regions_before * self.num_faces_per_edge_region()
             + num_interior_regions_before * self.num_faces_per_interior_region()
     }
@@ -1009,11 +1009,11 @@ impl<Proj> TriSphere<Proj> {
     fn base_vertex_index(&self, region: BaseRegion) -> usize {
         let face = region.owner();
         let num_owned_vertices_before = face.num_owned_vertices_before()
-            + (face.owns_vertex_1() && region.ty() > BaseRegionType::Edge0) as usize;
+            + (face.owns_vertex_1() && region.ty() > BaseRegionType::EDGE_0) as usize;
         let num_edge_regions_before =
-            face.num_owned_edges_before() + (region.ty() > BaseRegionType::Edge0) as usize;
+            face.num_owned_edges_before() + (region.ty() > BaseRegionType::EDGE_0) as usize;
         let num_interior_regions_before =
-            face.index() + (region.ty() > BaseRegionType::Interior) as usize;
+            face.index() + (region.ty() > BaseRegionType::INTERIOR) as usize;
         num_owned_vertices_before
             + num_edge_regions_before * self.num_vertices_per_edge_region()
             + num_interior_regions_before * self.num_vertices_per_interior_region()
@@ -1176,7 +1176,7 @@ impl<Proj: Eq + Clone + BaseTriProjector> Iterator for VertexIter<Proj> {
                     self.u = 1;
                     continue;
                 } else if self.u <= self.u_end
-                    && self.region.ty() == BaseRegionType::Edge0
+                    && self.region.ty() == BaseRegionType::EDGE_0
                     && self.region.owner().owns_vertex_1()
                 {
                     let res = Vertex {
@@ -1217,7 +1217,7 @@ pub(crate) struct BaseRegion(u8);
 impl BaseRegion {
     /// Constructs the [`BaseRegion`] for the given face and type.
     pub fn new(owner: BaseFace, ty: BaseRegionType) -> Self {
-        BaseRegion((owner.0 << 2) | (ty as u8))
+        BaseRegion((owner.0 << 2) | ty.0)
     }
 
     /// Gets the owner of this region.
@@ -1227,13 +1227,13 @@ impl BaseRegion {
 
     /// The general type of this [`BaseRegion`].
     pub fn ty(&self) -> BaseRegionType {
-        unsafe { std::mem::transmute(self.0 & 0b11) }
+        BaseRegionType(self.0 & 0b11)
     }
 
     /// Assuming that this region is for an edge, gets the edge corresponding to this region. The
     /// start of the returned edge is the origin of this region.
     pub fn as_edge(self) -> BaseHalfEdge {
-        if self.ty() == BaseRegionType::Edge0 {
+        if self.ty() == BaseRegionType::EDGE_0 {
             self.owner().side(0)
         } else {
             self.owner().side(2).twin()
@@ -1251,30 +1251,29 @@ impl std::fmt::Debug for BaseRegion {
 }
 
 /// The general type of a [`BaseRegion`].
-#[repr(u8)]
+// #[repr(u8)]
 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
-pub(crate) enum BaseRegionType {
-    /// Corresponds to the first edge of [`BaseRegion::owner`].
-    Edge0 = 0,
+#[repr(transparent)]
+pub(crate) struct BaseRegionType(u8);
 
+impl BaseRegionType {
+    /// Corresponds to the first edge of [`BaseRegion::owner`].
+    pub const EDGE_0: Self = Self(0);
     /// Corresponds to the interior of [`BaseRegion::owner`].
-    Interior = 1,
-
+    pub const INTERIOR: Self = Self(1);
     /// Corresponds to the third edge of [`BaseRegion::owner`].
-    Edge2 = 3,
-}
-
-impl BaseRegionType {
+    pub const EDGE_2: Self = Self(3);
+    
     /// Indicates whether this region corresponds to an edge.
     pub fn is_edge(self) -> bool {
-        self != Self::Interior
+        self != Self::INTERIOR
     }
 }
 
 impl BaseTriSphere {
     /// Gets the first [`BaseRegion`] of this base shape.
     pub(crate) fn first_region(self) -> BaseRegion {
-        BaseRegion::new(self.face(0), BaseRegionType::Edge0)
+        BaseRegion::new(self.face(0), BaseRegionType::EDGE_0)
     }
 
     /// Gets the [`BaseRegion`] after the given one.
@@ -1379,7 +1378,7 @@ impl<Proj: BaseTriProjector> SphereProjection<'_, Proj> {
             let u = (u.max(0.0) as u32).min(b - 1);
             let v = (v.max(0.0) as u32).min(b - u - 1);
             debug_assert!(u + v < b);
-            return (BaseRegion::new(face, BaseRegionType::Interior), [u, v]);
+            return (BaseRegion::new(face, BaseRegionType::INTERIOR), [u, v]);
         }
         let coords = vec::sub(coords, self.p_0);
         let coords = vec::mul(coords, self.inv_linear_det);
@@ -1388,20 +1387,20 @@ impl<Proj: BaseTriProjector> SphereProjection<'_, Proj> {
         let (comp, [r_u, r_v]) = if u + v < n as f64 {
             if u >= 0.0 {
                 let u = u as u32;
-                if v >= 0.0 {
+                return if v >= 0.0 {
                     let v = v as u32;
                     debug_assert!(u + v < n);
-                    return (BaseRegion::new(face, BaseRegionType::Interior), [u, v]);
+                    (BaseRegion::new(face, BaseRegionType::INTERIOR), [u, v])
                 } else {
                     debug_assert!(u < b);
                     let r_v = ((v + c as f64).max(0.0) as u32).min(c - 1);
-                    return (BaseRegion::new(face, BaseRegionType::Edge0), [u, r_v]);
+                    (BaseRegion::new(face, BaseRegionType::EDGE_0), [u, r_v])
                 }
             } else {
                 let r_u = ((u + (v + c as f64)).max(0.0) as u32).min(b - 1);
                 let r_v = ((-u) as u32).min(c - 1);
                 if face.owns_edge_2() {
-                    return (BaseRegion::new(face, BaseRegionType::Edge2), [r_u, r_v]);
+                    return (BaseRegion::new(face, BaseRegionType::EDGE_2), [r_u, r_v]);
                 } else {
                     (face.side(2).twin(), [r_u, r_v])
                 }
@@ -1413,18 +1412,18 @@ impl<Proj: BaseTriProjector> SphereProjection<'_, Proj> {
         } else {
             let r_u = (u as u32).min(b - 1);
             let r_v = ((v + c as f64).max(0.0) as u32).min(c - 1);
-            return (BaseRegion::new(face, BaseRegionType::Edge0), [r_u, r_v]);
+            return (BaseRegion::new(face, BaseRegionType::EDGE_0), [r_u, r_v]);
         };
         if comp.side_index() == 0 {
             (
-                BaseRegion::new(comp.inside(), BaseRegionType::Edge0),
+                BaseRegion::new(comp.inside(), BaseRegionType::EDGE_0),
                 [r_u, r_v],
             )
         } else {
             debug_assert_eq!(comp.side_index(), 2);
             debug_assert!(comp.inside().owns_edge_2());
             (
-                BaseRegion::new(comp.inside(), BaseRegionType::Edge2),
+                BaseRegion::new(comp.inside(), BaseRegionType::EDGE_2),
                 [b - r_u - 1, c - r_v - 1],
             )
         }


Reviews:
user: login: dzamkov
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-19T04:15:21Z
user: login: dzamkov
state: CHANGES_REQUESTED
author_association: OWNER
submitted_at: 2025-05-20T03:45:09Z
user: login: MorganBennetDev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T12:38:02Z
user: login: MorganBennetDev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T15:46:29Z
user: login: dzamkov
state: CHANGES_REQUESTED
author_association: OWNER
submitted_at: 2025-05-20T22:15:52Z
user: login: MorganBennetDev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T23:41:55Z
user: login: dzamkov
body: Looks good. Thanks!
state: APPROVED
author_association: OWNER
submitted_at: 2025-05-21T02:12:13Z

Review comments:
diff_hunk: @@ -221,7 +226,7 @@ pub struct Vertex(u8);
 impl Vertex {
     /// Gets the [`BaseTriSphere`] this vertex belongs to.
     pub const fn sphere(self) -> BaseTriSphere {
-        unsafe { std::mem::transmute(self.0.saturating_sub(6) / 6) }
+        BaseTriSphere::from_u8(self.0.saturating_sub(6) / 6)
path: src/basetri.rs
user: login: dzamkov
body: After looking into this a bit more. I think it would actually be better for performance (and safety, obviously) to write this in the simple obvious way:

```rust
if self.0 < 12 {
    BaseTriSphere::Icosa
} else if self.0 < 18 {
    BaseTriSphere::Octa
} else {
    BaseTriSphere::Tetra
}
```

The compiler is smart enough to [optimize out the branching logic](https://godbolt.org/z/bxo8xxvef) and the generated assembly looks better.

On the other hand, `Face::sphere` seems to be [better as written](https://godbolt.org/z/jcc5qM4z6), since division by 8 can be reduced to a simple bit shift.
created_at: 2025-05-19T04:15:20Z
author_association: OWNER
diff_hunk: @@ -415,7 +420,7 @@ struct OwnershipInfo {
 }
 
 /// Provides information about the "ownership" relationships between faces, edges, and vertices.
-static OWNERSHIP: OwnershipInfo = const {
+const OWNERSHIP: OwnershipInfo = const {
path: src/basetri.rs
user: login: dzamkov
body: Regarding the E0013 errors: are you using the latest version of Rust? According to [this](https://doc.rust-lang.org/error_codes/E0013.html), the error should no longer be emitted.

The members marked `static` should ideally remain `static`, since this prevents the compiler from creating multiple copies of the big lookup tables throughout the generated code.
created_at: 2025-05-20T03:01:34Z
author_association: OWNER
diff_hunk: @@ -129,7 +134,7 @@ pub struct Face(pub(crate) u8);
 impl Face {
     /// Gets the [`BaseTriSphere`] this face belongs to.
     pub const fn sphere(self) -> BaseTriSphere {
-        unsafe { std::mem::transmute(self.0.saturating_sub(12) / 8) }
+        BaseTriSphere::from_u8(self.0.saturating_sub(12) / 8)
path: src/basetri.rs
user: login: dzamkov
body: Going by the new unsafe policy, this change should be reverted.

If you want, you can add a comment justifying the safety and performance benefit of the `unsafe` block
created_at: 2025-05-20T03:03:33Z
author_association: OWNER
diff_hunk: @@ -56,7 +56,7 @@ impl<Proj> TriSphere<Proj> {
 
     /// The base shape of this subdivided sphere.
     pub const fn base(&self) -> BaseTriSphere {
-        unsafe { std::mem::transmute((self.c_base & 0b11) as u8) }
+        BaseTriSphere::from_u8((self.c_base & 0b11) as u8)
path: src/tri.rs
user: login: dzamkov
body: This too should be reverted
created_at: 2025-05-20T03:05:22Z
author_association: OWNER
diff_hunk: @@ -619,28 +619,40 @@ pub struct HalfEdge<Proj> {
 }
 
 /// Identifies a possible direction of a half-edge with respect to its [`BaseFace`].
-#[repr(u8)]
 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
-pub(crate) enum HalfEdgeDir {
-    /// The +U direction.
-    Up = 0,
+#[repr(transparent)]
+pub(crate) struct HalfEdgeDir(u8);
 
-    /// The +V direction.
-    Vp = 1,
-
-    /// The -U/+V direction.
-    UnVp = 2,
-
-    /// The -U direction.
-    Un = 3,
-
-    /// The -V direction.
-    Vn = 4,
-
-    /// The +U/-V direction.
-    UpVn = 5,
+impl HalfEdgeDir {
path: src/tri.rs
user: login: dzamkov
body: Changing the enums to struct wrappers makes sense, but make sure to preserve the documentation of the members.

Also, when converting `PascalCase` to `SNAKE_CASE`, make sure to add an underscore between the "words". i.e. `UpVn` should become `UP_VN`. Similarly, `Edge0` should become `EDGE_0` 
created_at: 2025-05-20T03:09:31Z
author_association: OWNER
path: src/hex.rs
user: login: dzamkov
body: The reason for the calls to `unwrap_unchecked` in this file is to eliminate redundant checks in the preceding function call.

e.g. normally the call to `Face::from_center` would check that the vertex is on a sphere with `b % 3 == c % 3`. But, if we do `Face::from_center(vertex).unwrap_unchecked()`, the compiler can eliminate this check even though it is defined inside the body of `Face::from_center`. The difference in generated code is [pretty dramatic](https://godbolt.org/z/c8rKqM7ec).

So, for the sake of performance, we should use `unwrap_unchecked` when we can guarantee that the `None` case is impossible, even if it leads to the silly-looking `Some(x.unwrap_unchecked())` construct.

However, there is a way we can avoid `unsafe` without sacrificing performance: just skip the checks. e.g. instead of `Face::from_center(vertex).unwrap_unchecked()`, write `Face { center: vertex }`. For `Face::from_kis`, you can create a new method which skips the checks (e.g. `Face::from_kis_unchecked`), and then rewrite `Face::from_kis` to call it after performing the checks.

Note that `unwrap_unchecked` is actually checked in tests and debug builds, so we will also want the "unchecked" method(s) to perform checks in debug builds using [`debug_assert`](https://doc.rust-lang.org/std/macro.debug_assert.html).
created_at: 2025-05-20T03:34:04Z
author_association: OWNER
diff_hunk: @@ -415,7 +420,7 @@ struct OwnershipInfo {
 }
 
 /// Provides information about the "ownership" relationships between faces, edges, and vertices.
-static OWNERSHIP: OwnershipInfo = const {
+const OWNERSHIP: OwnershipInfo = const {
path: src/basetri.rs
user: login: MorganBennetDev
body: I thought I had updated relatively recently, but will make sure and add this back.
created_at: 2025-05-20T12:38:02Z
author_association: COLLABORATOR
diff_hunk: @@ -415,7 +420,7 @@ struct OwnershipInfo {
 }
 
 /// Provides information about the "ownership" relationships between faces, edges, and vertices.
-static OWNERSHIP: OwnershipInfo = const {
+const OWNERSHIP: OwnershipInfo = const {
path: src/basetri.rs
user: login: MorganBennetDev
body: It seems this is an issue with the version of RustRover I'm using. IDE reports it as an error but CLI tools do not. Reverting.
created_at: 2025-05-20T15:46:29Z
author_association: COLLABORATOR
diff_hunk: @@ -134,7 +129,13 @@ pub struct Face(pub(crate) u8);
 impl Face {
     /// Gets the [`BaseTriSphere`] this face belongs to.
     pub const fn sphere(self) -> BaseTriSphere {
-        BaseTriSphere::from_u8(self.0.saturating_sub(12) / 8)
+        // Subtracting 12 and dividing by 8 are very cheap (second operation will be converted to a bitshift) so
+        // converting this to provably safe code (e.g. `if` statements to determine `BaseTriSphere` variant) will incur
+        // a performance penalty.
+        // Input to `std::mem::transmute` will be valid as long as `self.0` is less than or equal to 28. Since this
+        // struct represents a face of a triangular Platonic solid, which can never have more than 20 faces, the input
+        // will always be valid unless `Face` was constructed incorrectly somewhere else.
+        unsafe { std::mem::transmute(self.0.saturating_sub(12) / 8) }
path: src/basetri.rs
user: login: dzamkov
body: The reasoning for this is incorrect and could be misleading.

The transmuted value needs to be at most 2, so `self.0` can be at most 35. All of the platonic solids share the same index space (yet another optimization to reduce branching):
`0..20`: Icosahedron
`20..28`: Octahedron
`28..32`: Tetrahedron

So the highest face index will be 31, which is within the limit.
created_at: 2025-05-20T21:58:28Z
author_association: OWNER
path: src/hex.rs
user: login: dzamkov
body: The `unchecked` variants should not be `pub`. We shouldn't leave any way for the user to obtain an invalid `Face`, since that could break downstream code.
created_at: 2025-05-20T22:11:42Z
author_association: OWNER
path: src/hex.rs
user: login: dzamkov
body: A bit of a nitpick, but I prefer when helper functions are defined after (preferably right after) they are used, so that when you see one called in code, you can just scroll down a bit to see the definition.

Thus, I think the order of functions in `Face` should be:
`from_center`
`from_kis`
`from_kis_unchecked`
`from_center_unchecked`
created_at: 2025-05-20T22:14:51Z
author_association: OWNER
diff_hunk: @@ -134,7 +129,13 @@ pub struct Face(pub(crate) u8);
 impl Face {
     /// Gets the [`BaseTriSphere`] this face belongs to.
     pub const fn sphere(self) -> BaseTriSphere {
-        BaseTriSphere::from_u8(self.0.saturating_sub(12) / 8)
+        // Subtracting 12 and dividing by 8 are very cheap (second operation will be converted to a bitshift) so
+        // converting this to provably safe code (e.g. `if` statements to determine `BaseTriSphere` variant) will incur
+        // a performance penalty.
+        // Input to `std::mem::transmute` will be valid as long as `self.0` is less than or equal to 28. Since this
+        // struct represents a face of a triangular Platonic solid, which can never have more than 20 faces, the input
+        // will always be valid unless `Face` was constructed incorrectly somewhere else.
+        unsafe { std::mem::transmute(self.0.saturating_sub(12) / 8) }
path: src/basetri.rs
user: login: MorganBennetDev
body: Forgot to account for truncation in integer division when I was doing my math. Also did not realize the index space detail. Thanks for the catch and correction.
created_at: 2025-05-20T23:41:55Z
author_association: COLLABORATOR

PR comments:
user: login: MorganBennetDev
created_at: 2025-05-18T19:55:42Z
body: Forgot to add: changes `static` to `const` to get rid of some warnings and fixes a typo in an error message.
author_association: COLLABORATOR
user: login: dzamkov
created_at: 2025-05-18T23:31:37Z
body: This library doesn't currently have a policy for unsafe code. Before eliminating *all* uses of unsafe, we should probably settle on what that policy should be. Maintaining zero uses of unsafe is very difficult to balance with performance, especially in a low-level library such as this where individual functions are small and intended to be called many times in a tight loop.

I'd imagine that most users of this library would prioritize performance over *provable* safety, as is typically case for gamedev-related work. Thus, I think whenever there is a potential to use unsafe code to improve performance, we should take it. But, I do understand that haphazard and unjustified use of `unsafe` makes the code look sloppy.

So my proposed policy is this: `unsafe` code can be used if it has some provable performance benefit and there is no alternative without changing the public API. However, there is no requirement for the performance benefit to be significant or measurable in benchmarks, e.g. eliminating a single instruction is enough to justify the use of `unsafe`. Additionally, all uses of `unsafe` should have a comment justifying both soundness ([as is standard in some Rust projects](https://std-dev-guide.rust-lang.org/policy/safety-comments.html)) and performance benefit.

Let me know what you think and I'll review this PR using the policy we agree upon.
author_association: OWNER
user: login: MorganBennetDev
created_at: 2025-05-18T23:51:41Z
body: I can't think of any issues with this policy (aside from *possibly* the instruction part, see end). These changes shouldn't have had an impact on that rubric aside from `BaseTriSphere` but I'm not very experienced with assembly level stuff and the deviations in benchmarks seemed to be mostly noise.

Internally, I think it's reasonable to newtype `u8` instead of using `std::mem::transmute` (unless the changes to `match` statements are significant, once again, I'm not experienced at this low a level). But `unsafe` to maintain `enum`s in the public API is worth the tradeoff by the new rubric.

One clarifying question, how much would instruction count depend on what architecture is targeted?
author_association: COLLABORATOR
user: login: dzamkov
created_at: 2025-05-19T04:04:00Z
body: Instruction count is a bad metric because there's a huge variability in how long different instructions take. What I meant was that if a usage of `unsafe` can completely remove at least one instruction, on some platform, without introducing more instructions or causing a degradation on any other platforms, then its use is justified.

This would be the case, e.g. when removing a bounds check using [`get_unchecked`](https://doc.rust-lang.org/std/primitive.slice.html#method.get_unchecked) or replacing a lookup table/match with direct transmutation to get an enum value. There's really no way for these to cause a degradation on any platform.

For in-depth performance optimization (which, so far, I haven't done on this repo), I recommend installing and familiarizing yourself with a profiling tool, if you haven't already. I use [VTune](https://www.intel.com/content/www/us/en/developer/tools/oneapi/vtune-profiler.html) for Rust code. It can tell you which individual functions (or even instructions within the functions) are contributing most to the execution time. After using it for a while, you'll get a pretty good intuition for how well different snippets of code will be optimized.
author_association: OWNER
user: login: dzamkov
created_at: 2025-05-20T22:09:19Z
body: Since we're now requiring `unsafe` blocks to have safety justification, we might as well have that checked automatically.

Please add `#![deny(clippy::undocumented_unsafe_blocks)]` to `lib.rs`
author_association: OWNER