Repository: dzamkov/subsphere
Pull request #7: Slight Performance Improvement to `solve_cubic` and Switch Benchmarking to Criterion
Author: login: MorganBennetDev
Created: 2025-05-24T01:24:56Z | Merged: 2025-05-29T01:27:54Z

Largest diff is switching benchmarking code from Divan to Criterion since it provides more useful functionality (includes change from previous benchmark in output, generates charts).

Summary:
* Switches benchmarking library from Divan to Criterion.
* Moves all vector allocations from benched code to benchmark setup code.
* Updates `solve_cubic` for a ~5% performance increase in fuller vertex benchmarks.
* Changes output of `solve_cubic` to a custom `enum` which implements `IntoIterator`.
* Moves `solve_cubic` to `math/polynomials.rs` for organization.

Changing benchmarking code so soon after adding it may not be a good look, so this can be left out if desired.

Recommend squashing this if/when accepted.

Next on the agenda is adding slerp as a projection option.


Diff:
diff --git a/Cargo.lock b/Cargo.lock
index 034ee54..258c24d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -8,12 +8,27 @@ version = "2.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
 
+[[package]]
+name = "aho-corasick"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
+dependencies = [
+ "memchr",
+]
+
 [[package]]
 name = "aligned-vec"
 version = "0.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4aa90d7ce82d4be67b64039a3d588d38dbcc6736577de4a847025ce5b0c468d1"
 
+[[package]]
+name = "anes"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
+
 [[package]]
 name = "anstyle"
 version = "1.0.10"
@@ -141,6 +156,12 @@ version = "0.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
 
+[[package]]
+name = "cast"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
+
 [[package]]
 name = "cc"
 version = "1.2.19"
@@ -168,24 +189,50 @@ version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
 
+[[package]]
+name = "ciborium"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
+dependencies = [
+ "ciborium-io",
+ "ciborium-ll",
+ "serde",
+]
+
+[[package]]
+name = "ciborium-io"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
+
+[[package]]
+name = "ciborium-ll"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
+dependencies = [
+ "ciborium-io",
+ "half",
+]
+
 [[package]]
 name = "clap"
-version = "4.5.37"
+version = "4.5.38"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071"
+checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000"
 dependencies = [
  "clap_builder",
 ]
 
 [[package]]
 name = "clap_builder"
-version = "4.5.37"
+version = "4.5.38"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2"
+checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120"
 dependencies = [
  "anstyle",
  "clap_lex",
- "terminal_size",
 ]
 
 [[package]]
@@ -200,12 +247,6 @@ version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
 
-[[package]]
-name = "condtype"
-version = "1.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af"
-
 [[package]]
 name = "crc32fast"
 version = "1.4.2"
@@ -215,6 +256,39 @@ dependencies = [
  "cfg-if",
 ]
 
+[[package]]
+name = "criterion"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3bf7af66b0989381bd0be551bd7cc91912a655a58c6918420c9527b1fd8b4679"
+dependencies = [
+ "anes",
+ "cast",
+ "ciborium",
+ "clap",
+ "criterion-plot",
+ "itertools 0.13.0",
+ "num-traits",
+ "oorandom",
+ "plotters",
+ "rayon",
+ "regex",
+ "serde",
+ "serde_json",
+ "tinytemplate",
+ "walkdir",
+]
+
+[[package]]
+name = "criterion-plot"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
+dependencies = [
+ "cast",
+ "itertools 0.10.5",
+]
+
 [[package]]
 name = "crossbeam-deque"
 version = "0.8.6"
@@ -246,31 +320,6 @@ version = "0.2.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929"
 
-[[package]]
-name = "divan"
-version = "0.1.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a405457ec78b8fe08b0e32b4a3570ab5dff6dd16eb9e76a5ee0a9d9cbd898933"
-dependencies = [
- "cfg-if",
- "clap",
- "condtype",
- "divan-macros",
- "libc",
- "regex-lite",
-]
-
-[[package]]
-name = "divan-macros"
-version = "0.1.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9556bc800956545d6420a640173e5ba7dfa82f38d3ea5a167eb555bc69ac3323"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
 [[package]]
 name = "either"
 version = "1.15.0"
@@ -283,16 +332,6 @@ version = "1.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
 
-[[package]]
-name = "errno"
-version = "0.3.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e"
-dependencies = [
- "libc",
- "windows-sys",
-]
-
 [[package]]
 name = "exr"
 version = "1.73.0"
@@ -448,6 +487,15 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "itertools"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
+dependencies = [
+ "either",
+]
+
 [[package]]
 name = "itertools"
 version = "0.12.1"
@@ -457,6 +505,21 @@ dependencies = [
  "either",
 ]
 
+[[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
+
 [[package]]
 name = "jobserver"
 version = "0.1.33"
@@ -473,6 +536,16 @@ version = "0.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0"
 
+[[package]]
+name = "js-sys"
+version = "0.3.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
 [[package]]
 name = "lebe"
 version = "0.5.2"
@@ -495,12 +568,6 @@ dependencies = [
  "cc",
 ]
 
-[[package]]
-name = "linux-raw-sys"
-version = "0.9.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
-
 [[package]]
 name = "log"
 version = "0.4.27"
@@ -626,6 +693,12 @@ version = "1.21.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
 
+[[package]]
+name = "oorandom"
+version = "11.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
+
 [[package]]
 name = "palette"
 version = "0.7.6"
@@ -704,6 +777,34 @@ version = "0.3.32"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
 
+[[package]]
+name = "plotters"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
+dependencies = [
+ "num-traits",
+ "plotters-backend",
+ "plotters-svg",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "plotters-backend"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
+
+[[package]]
+name = "plotters-svg"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
+dependencies = [
+ "plotters-backend",
+]
+
 [[package]]
 name = "png"
 version = "0.17.16"
@@ -857,7 +958,7 @@ dependencies = [
  "built",
  "cfg-if",
  "interpolate_name",
- "itertools",
+ "itertools 0.12.1",
  "libc",
  "libfuzzer-sys",
  "log",
@@ -914,10 +1015,33 @@ dependencies = [
 ]
 
 [[package]]
-name = "regex-lite"
-version = "0.1.6"
+name = "regex"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a"
+checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
 
 [[package]]
 name = "rgb"
@@ -926,23 +1050,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a"
 
 [[package]]
-name = "rustix"
-version = "1.0.5"
+name = "rustversion"
+version = "1.0.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf"
-dependencies = [
- "bitflags 2.9.0",
- "errno",
- "libc",
- "linux-raw-sys",
- "windows-sys",
-]
+checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2"
 
 [[package]]
-name = "rustversion"
+name = "ryu"
 version = "1.0.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2"
+checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
 
 [[package]]
 name = "serde"
@@ -964,6 +1090,18 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "serde_json"
+version = "1.0.140"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
+dependencies = [
+ "itoa",
+ "memchr",
+ "ryu",
+ "serde",
+]
+
 [[package]]
 name = "serde_spanned"
 version = "0.6.8"
@@ -1010,7 +1148,7 @@ checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9"
 name = "subsphere"
 version = "0.7.0"
 dependencies = [
- "divan",
+ "criterion",
 ]
 
 [[package]]
@@ -1053,16 +1191,6 @@ version = "0.12.16"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
 
-[[package]]
-name = "terminal_size"
-version = "0.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed"
-dependencies = [
- "rustix",
- "windows-sys",
-]
-
 [[package]]
 name = "thiserror"
 version = "1.0.69"
@@ -1094,6 +1222,16 @@ dependencies = [
  "weezl",
 ]
 
+[[package]]
+name = "tinytemplate"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
 [[package]]
 name = "toml"
 version = "0.8.20"
@@ -1151,6 +1289,16 @@ version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b"
 
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
 [[package]]
 name = "wasi"
 version = "0.11.0+wasi-snapshot-preview1"
@@ -1224,12 +1372,31 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "web-sys"
+version = "0.3.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
 [[package]]
 name = "weezl"
 version = "0.1.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082"
 
+[[package]]
+name = "winapi-util"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
+dependencies = [
+ "windows-sys",
+]
+
 [[package]]
 name = "windows-sys"
 version = "0.59.0"
diff --git a/Cargo.toml b/Cargo.toml
index 53fd49d..adb6951 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,7 +14,7 @@ readme = "README.md"
 members = ["render"]
 
 [dev-dependencies]
-divan = "0.1.21"
+criterion = "0.6"
 
 [[bench]]
 name = "tessellation"
diff --git a/benches/tessellation.rs b/benches/tessellation.rs
index ef8d7e5..bf0d380 100644
--- a/benches/tessellation.rs
+++ b/benches/tessellation.rs
@@ -1,247 +1,458 @@
 use std::hint::black_box;
 use std::num::NonZero;
+use criterion::{criterion_group, criterion_main, BatchSize, BenchmarkId, Criterion};
 use subsphere::prelude::*;
 
-fn main() {
-    divan::main();
-}
-
-fn tessellation_vertex_benchmark<Tessellation: subsphere::Sphere>(sphere: Tessellation) {
-    black_box(
-        sphere.vertices()
-            .map(|v| v.pos())
-            .collect::<Vec<_>>()
-    );
-}
+criterion_group!(
+    trisphere_octa,
+    trisphere_octa_fuller_b_0_face,
+    trisphere_octa_fuller_b_0_vertex,
+    trisphere_octa_gnomonic_b_0_face,
+    trisphere_octa_gnomonic_b_0_vertex
+);
 
-fn tessellation_face_benchmark<Tessellation: subsphere::Sphere>(sphere: Tessellation) {
-    let mut indices = Vec::new();
-    for face in sphere.faces() {
-        indices.extend(face.vertices().map(|v| v.index()));
-    }
-    black_box(indices);
-}
+criterion_group!(
+    trisphere_icosa,
+    trisphere_icosa_fuller_b_0_face,
+    trisphere_icosa_fuller_b_0_vertex,
+    trisphere_icosa_gnomonic_b_0_face,
+    trisphere_icosa_gnomonic_b_0_vertex,
+);
 
-#[divan::bench(args = [1, 2, 4, 8, 16, 32, 64, 128])]
-fn trisphere_icosa_fuller_b_0_vertex(b: u32) {
-    let sphere = subsphere::TriSphere::new(
-        subsphere::BaseTriSphere::Icosa,
-        subsphere::proj::Fuller,
-        NonZero::new(b).unwrap(),
-        0,
-    );
-    
-    tessellation_vertex_benchmark(sphere);
-}
-
-#[divan::bench(args = [1, 2, 4, 8, 16, 32, 64, 128])]
-fn trisphere_icosa_gnomonic_b_0_vertex(b: u32) {
-    let sphere = subsphere::TriSphere::new(
-        subsphere::BaseTriSphere::Icosa,
-        subsphere::proj::Gnomonic,
-        NonZero::new(b).unwrap(),
-        0,
-    );
-    
-    tessellation_vertex_benchmark(sphere);
-}
-
-#[divan::bench(args = [1, 2, 4, 8, 16, 32, 64, 128])]
-fn trisphere_octa_fuller_b_0_vertex(b: u32) {
-    let sphere = subsphere::TriSphere::new(
-        subsphere::BaseTriSphere::Octa,
-        subsphere::proj::Fuller,
-        NonZero::new(b).unwrap(),
-        0,
-    );
-    
-    tessellation_vertex_benchmark(sphere);
-}
-
-#[divan::bench(args = [1, 2, 4, 8, 16, 32, 64, 128])]
-fn trisphere_octa_gnomonic_b_0_vertex(b: u32) {
-    let sphere = subsphere::TriSphere::new(
-        subsphere::BaseTriSphere::Octa,
-        subsphere::proj::Gnomonic,
-        NonZero::new(b).unwrap(),
-        0,
-    );
-    
-    tessellation_vertex_benchmark(sphere);
-}
+criterion_group!(
+    hexsphere_octa,
+    hexsphere_octa_fuller_b_bmod3_face,
+    hexsphere_octa_fuller_b_bmod3_vertex,
+    hexsphere_octa_gnomonic_b_bmod3_face,
+    hexsphere_octa_gnomonic_b_bmod3_vertex
+);
 
-#[divan::bench(args = [1, 2, 4, 8, 16, 32, 64, 128])]
-fn trisphere_icosa_fuller_b_0_face(b: u32) {
-    let sphere = subsphere::TriSphere::new(
-        subsphere::BaseTriSphere::Icosa,
-        subsphere::proj::Fuller,
-        NonZero::new(b).unwrap(),
-        0,
-    );
-    
-    tessellation_face_benchmark(sphere);
-}
+criterion_group!(
+    hexsphere_icosa,
+    hexsphere_icosa_fuller_b_bmod3_face,
+    hexsphere_icosa_fuller_b_bmod3_vertex,
+    hexsphere_icosa_gnomonic_b_bmod3_face,
+    hexsphere_icosa_gnomonic_b_bmod3_vertex,
+);
 
-#[divan::bench(args = [1, 2, 4, 8, 16, 32, 64, 128])]
-fn trisphere_icosa_gnomonic_b_0_face(b: u32) {
-    let sphere = subsphere::TriSphere::new(
-        subsphere::BaseTriSphere::Icosa,
-        subsphere::proj::Gnomonic,
-        NonZero::new(b).unwrap(),
-        0,
-    );
-    
-    tessellation_face_benchmark(sphere);
-}
+criterion_main!(
+    trisphere_octa,
+    trisphere_icosa,
+    hexsphere_icosa,
+    hexsphere_octa
+);
 
-#[divan::bench(args = [1, 2, 4, 8, 16, 32, 64, 128])]
-fn trisphere_octa_fuller_b_0_face(b: u32) {
-    let sphere = subsphere::TriSphere::new(
-        subsphere::BaseTriSphere::Octa,
-        subsphere::proj::Fuller,
-        NonZero::new(b).unwrap(),
-        0,
-    );
+fn tessellation_vertex_benchmark<Tessellation: subsphere::Sphere>(sphere: Tessellation, vertices: &mut Vec<[f64; 3]>) {
+    vertices.extend(sphere.vertices()
+        .map(|v| v.pos()));
     
-    tessellation_face_benchmark(sphere);
+    black_box(vertices);
 }
 
-#[divan::bench(args = [1, 2, 4, 8, 16, 32, 64, 128])]
-fn trisphere_octa_gnomonic_b_0_face(b: u32) {
-    let sphere = subsphere::TriSphere::new(
-        subsphere::BaseTriSphere::Octa,
-        subsphere::proj::Gnomonic,
-        NonZero::new(b).unwrap(),
-        0,
-    );
+fn tessellation_face_benchmark<Tessellation: subsphere::Sphere>(sphere: Tessellation, indices: &mut Vec<usize>) {
+    for face in sphere.faces() {
+        indices.extend(face.vertices().map(|v| v.index()));
+    }
     
-    tessellation_face_benchmark(sphere);
+    black_box(indices);
 }
 
-#[divan::bench(args = [1, 2, 4, 8, 16, 32, 64, 128])]
-fn hexsphere_icosa_fuller_b_bmod3_vertex(b: u32) {
-    let c = b % 3;
-    
-    let sphere = subsphere::HexSphere::from_kis(
-        subsphere::TriSphere::new(
+fn trisphere_icosa_fuller_b_0_vertex(c: &mut Criterion) {
+    let mut group = c.benchmark_group("trisphere_icosa_fuller_b_0_vertex");
+    for b in [1, 2, 4, 8, 16, 32, 64, 128] {
+        let sphere = subsphere::TriSphere::new(
             subsphere::BaseTriSphere::Icosa,
             subsphere::proj::Fuller,
             NonZero::new(b).unwrap(),
-            c,
-        )
-    ).unwrap();
-    
-    tessellation_vertex_benchmark(sphere);
+            0,
+        );
+        let n = sphere.vertices().count();
+        let vertices = Vec::with_capacity(n);
+        
+        group.bench_with_input(BenchmarkId::from_parameter(b), &b, |bencher, _| {
+            bencher.iter_batched_ref(
+                || vertices.clone(),
+                |data| tessellation_vertex_benchmark(sphere, data),
+                BatchSize::SmallInput
+            );
+        });
+    }
+    group.finish();
 }
 
-#[divan::bench(args = [1, 2, 4, 8, 16, 32, 64, 128])]
-fn hexsphere_icosa_gnomonic_b_bmod3_vertex(b: u32) {
-    let c = b % 3;
-    
-    let sphere = subsphere::HexSphere::from_kis(
-        subsphere::TriSphere::new(
+fn trisphere_icosa_gnomonic_b_0_vertex(c: &mut Criterion) {
+    let mut group = c.benchmark_group("trisphere_icosa_gnomonic_b_0_vertex");
+    for b in [1, 2, 4, 8, 16, 32, 64, 128] {
+        let sphere = subsphere::TriSphere::new(
             subsphere::BaseTriSphere::Icosa,
             subsphere::proj::Gnomonic,
             NonZero::new(b).unwrap(),
-            c,
-        )
-    ).unwrap();
-    
-    tessellation_vertex_benchmark(sphere);
+            0,
+        );
+        let n = sphere.vertices().count();
+        let vertices = Vec::with_capacity(n);
+        
+        group.bench_with_input(BenchmarkId::from_parameter(b), &b, |bencher, _| {
+            bencher.iter_batched_ref(
+                || vertices.clone(),
+                |data| tessellation_vertex_benchmark(sphere, data),
+                BatchSize::SmallInput
+            );
+        });
+    }
+    group.finish();
 }
 
-#[divan::bench(args = [1, 2, 4, 8, 16, 32, 64, 128])]
-fn hexsphere_octa_fuller_b_bmod3_vertex(b: u32) {
-    let c = b % 3;
-    
-    let sphere = subsphere::HexSphere::from_kis(
-        subsphere::TriSphere::new(
+fn trisphere_octa_fuller_b_0_vertex(c: &mut Criterion) {
+    let mut group = c.benchmark_group("trisphere_octa_fuller_b_0_vertex");
+    for b in [1, 2, 4, 8, 16, 32, 64, 128] {
+        let sphere = subsphere::TriSphere::new(
             subsphere::BaseTriSphere::Octa,
             subsphere::proj::Fuller,
             NonZero::new(b).unwrap(),
-            c,
-        )
-    ).unwrap();
-    
-    tessellation_vertex_benchmark(sphere);
+            0,
+        );
+        let n = sphere.vertices().count();
+        let vertices = Vec::with_capacity(n);
+        
+        group.bench_with_input(BenchmarkId::from_parameter(b), &b, |bencher, _| {
+            bencher.iter_batched_ref(
+                || vertices.clone(),
+                |data| tessellation_vertex_benchmark(sphere, data),
+                BatchSize::SmallInput
+            );
+        });
+    }
+    group.finish();
 }
 
-#[divan::bench(args = [1, 2, 4, 8, 16, 32, 64, 128])]
-fn hexsphere_octa_gnomonic_b_bmod3_vertex(b: u32) {
-    let c = b % 3;
-    
-    let sphere = subsphere::HexSphere::from_kis(
-        subsphere::TriSphere::new(
+fn trisphere_octa_gnomonic_b_0_vertex(c: &mut Criterion) {
+    let mut group = c.benchmark_group("trisphere_octa_gnomonic_b_0_vertex");
+    for b in [1, 2, 4, 8, 16, 32, 64, 128] {
+        let sphere = subsphere::TriSphere::new(
             subsphere::BaseTriSphere::Octa,
             subsphere::proj::Gnomonic,
             NonZero::new(b).unwrap(),
-            c,
-        )
-    ).unwrap();
-    
-    tessellation_vertex_benchmark(sphere);
+            0,
+        );
+        let n = sphere.vertices().count();
+        let vertices = Vec::with_capacity(n);
+        
+        group.bench_with_input(BenchmarkId::from_parameter(b), &b, |bencher, _| {
+            bencher.iter_batched_ref(
+                || vertices.clone(),
+                |data| tessellation_vertex_benchmark(sphere, data),
+                BatchSize::SmallInput
+            );
+        });
+    }
+    group.finish();
 }
 
-#[divan::bench(args = [1, 2, 4, 8, 16, 32, 64, 128])]
-fn hexsphere_icosa_fuller_b_bmod3_face(b: u32) {
-    let c = b % 3;
-    
-    let sphere = subsphere::HexSphere::from_kis(
-        subsphere::TriSphere::new(
+fn trisphere_icosa_fuller_b_0_face(c: &mut Criterion) {
+    let mut group = c.benchmark_group("trisphere_icosa_fuller_b_0_face");
+    for b in [1, 2, 4, 8, 16, 32, 64, 128] {
+        let sphere = subsphere::TriSphere::new(
             subsphere::BaseTriSphere::Icosa,
             subsphere::proj::Fuller,
             NonZero::new(b).unwrap(),
-            c,
-        )
-    ).unwrap();
-    
-    tessellation_face_benchmark(sphere);
+            0,
+        );
+        let n = sphere.num_faces() * (sphere.face(0).vertices().count() + 1);
+        let indices = Vec::with_capacity(n);
+        
+        group.bench_with_input(BenchmarkId::from_parameter(b), &b, |bencher, _| {
+            bencher.iter_batched_ref(
+                || indices.clone(),
+                |data| tessellation_face_benchmark(sphere, data),
+                BatchSize::SmallInput
+            );
+        });
+    }
+    group.finish();
 }
 
-#[divan::bench(args = [1, 2, 4, 8, 16, 32, 64, 128])]
-fn hexsphere_icosa_gnomonic_b_bmod3_face(b: u32) {
-    let c = b % 3;
-    
-    let sphere = subsphere::HexSphere::from_kis(
-        subsphere::TriSphere::new(
+fn trisphere_icosa_gnomonic_b_0_face(c: &mut Criterion) {
+    let mut group = c.benchmark_group("trisphere_icosa_gnomonic_b_0_face");
+    for b in [1, 2, 4, 8, 16, 32, 64, 128] {
+        let sphere = subsphere::TriSphere::new(
             subsphere::BaseTriSphere::Icosa,
             subsphere::proj::Gnomonic,
             NonZero::new(b).unwrap(),
-            c,
-        )
-    ).unwrap();
-    
-    tessellation_face_benchmark(sphere);
+            0,
+        );
+        let n = sphere.num_faces() * (sphere.face(0).vertices().count() + 1);
+        let indices = Vec::with_capacity(n);
+        
+        group.bench_with_input(BenchmarkId::from_parameter(b), &b, |bencher, _| {
+            bencher.iter_batched_ref(
+                || indices.clone(),
+                |data| tessellation_face_benchmark(sphere, data),
+                BatchSize::SmallInput
+            );
+        });
+    }
+    group.finish();
 }
 
-#[divan::bench(args = [1, 2, 4, 8, 16, 32, 64, 128])]
-fn hexsphere_octa_fuller_b_bmod3_face(b: u32) {
-    let c = b % 3;
-    
-    let sphere = subsphere::HexSphere::from_kis(
-        subsphere::TriSphere::new(
+fn trisphere_octa_fuller_b_0_face(c: &mut Criterion) {
+    let mut group = c.benchmark_group("trisphere_octa_fuller_b_0_face");
+    for b in [1, 2, 4, 8, 16, 32, 64, 128] {
+        let sphere = subsphere::TriSphere::new(
             subsphere::BaseTriSphere::Octa,
             subsphere::proj::Fuller,
             NonZero::new(b).unwrap(),
-            c,
-        )
-    ).unwrap();
-    
-    tessellation_face_benchmark(sphere);
+            0,
+        );
+        let n = sphere.num_faces() * (sphere.face(0).vertices().count() + 1);
+        let indices = Vec::with_capacity(n);
+        
+        group.bench_with_input(BenchmarkId::from_parameter(b), &b, |bencher, _| {
+            bencher.iter_batched_ref(
+                || indices.clone(),
+                |data| tessellation_face_benchmark(sphere, data),
+                BatchSize::SmallInput
+            );
+        });
+    }
+    group.finish();
 }
 
-#[divan::bench(args = [1, 2, 4, 8, 16, 32, 64, 128])]
-fn hexsphere_octa_gnomonic_b_bmod3_face(b: u32) {
-    let c = b % 3;
-    
-    let sphere = subsphere::HexSphere::from_kis(
-        subsphere::TriSphere::new(
+fn trisphere_octa_gnomonic_b_0_face(c: &mut Criterion) {
+    let mut group = c.benchmark_group("trisphere_octa_gnomonic_b_0_face");
+    for b in [1, 2, 4, 8, 16, 32, 64, 128] {
+        let sphere = subsphere::TriSphere::new(
             subsphere::BaseTriSphere::Octa,
             subsphere::proj::Gnomonic,
             NonZero::new(b).unwrap(),
-            c,
-        )
-    ).unwrap();
-    
-    tessellation_face_benchmark(sphere);
-}
\ No newline at end of file
+            0,
+        );
+        let n = sphere.num_faces() * (sphere.face(0).vertices().count() + 1);
+        let indices = Vec::with_capacity(n);
+        
+        group.bench_with_input(BenchmarkId::from_parameter(b), &b, |bencher, _| {
+            bencher.iter_batched_ref(
+                || indices.clone(),
+                |data| tessellation_face_benchmark(sphere, data),
+                BatchSize::SmallInput
+            );
+        });
+    }
+    group.finish();
+}
+
+fn hexsphere_icosa_fuller_b_bmod3_vertex(c: &mut Criterion) {
+    let mut group = c.benchmark_group("hexsphere_icosa_fuller_b_bmod3_vertex");
+    for b in [1, 2, 4, 8, 16, 32, 64, 128] {
+        let c = b % 3;
+        
+        let sphere = subsphere::HexSphere::from_kis(
+            subsphere::TriSphere::new(
+                subsphere::BaseTriSphere::Icosa,
+                subsphere::proj::Fuller,
+                NonZero::new(b).unwrap(),
+                c,
+            )
+        ).unwrap();
+        let n = sphere.vertices().count();
+        let vertices = Vec::with_capacity(n);
+        
+        group.bench_with_input(BenchmarkId::from_parameter(b), &b, |bencher, _| {
+            bencher.iter_batched_ref(
+                || vertices.clone(),
+                |data| tessellation_vertex_benchmark(sphere, data),
+                BatchSize::SmallInput
+            );
+        });
+    }
+    group.finish();
+}
+
+fn hexsphere_icosa_gnomonic_b_bmod3_vertex(c: &mut Criterion) {
+    let mut group = c.benchmark_group("hexsphere_icosa_gnomonic_b_bmod3_vertex");
+    for b in [1, 2, 4, 8, 16, 32, 64, 128] {
+        let c = b % 3;
+        
+        let sphere = subsphere::HexSphere::from_kis(
+            subsphere::TriSphere::new(
+                subsphere::BaseTriSphere::Icosa,
+                subsphere::proj::Gnomonic,
+                NonZero::new(b).unwrap(),
+                c,
+            )
+        ).unwrap();
+        let n = sphere.vertices().count();
+        let vertices = Vec::with_capacity(n);
+        
+        group.bench_with_input(BenchmarkId::from_parameter(b), &b, |bencher, _| {
+            bencher.iter_batched_ref(
+                || vertices.clone(),
+                |data| tessellation_vertex_benchmark(sphere, data),
+                BatchSize::SmallInput
+            );
+        });
+    }
+    group.finish();
+}
+
+fn hexsphere_octa_fuller_b_bmod3_vertex(c: &mut Criterion) {
+    let mut group = c.benchmark_group("hexsphere_octa_fuller_b_bmod3_vertex");
+    for b in [1, 2, 4, 8, 16, 32, 64, 128] {
+        let c = b % 3;
+        
+        let sphere = subsphere::HexSphere::from_kis(
+            subsphere::TriSphere::new(
+                subsphere::BaseTriSphere::Octa,
+                subsphere::proj::Fuller,
+                NonZero::new(b).unwrap(),
+                c,
+            )
+        ).unwrap();
+        let n = sphere.vertices().count();
+        let vertices = Vec::with_capacity(n);
+        
+        group.bench_with_input(BenchmarkId::from_parameter(b), &b, |bencher, _| {
+            bencher.iter_batched_ref(
+                || vertices.clone(),
+                |data| tessellation_vertex_benchmark(sphere, data),
+                BatchSize::SmallInput
+            );
+        });
+    }
+    group.finish();
+}
+
+fn hexsphere_octa_gnomonic_b_bmod3_vertex(c: &mut Criterion) {
+    let mut group = c.benchmark_group("hexsphere_octa_gnomonic_b_bmod3_vertex");
+    for b in [1, 2, 4, 8, 16, 32, 64, 128] {
+        let c = b % 3;
+        
+        let sphere = subsphere::HexSphere::from_kis(
+            subsphere::TriSphere::new(
+                subsphere::BaseTriSphere::Octa,
+                subsphere::proj::Gnomonic,
+                NonZero::new(b).unwrap(),
+                c,
+            )
+        ).unwrap();
+        let n = sphere.vertices().count();
+        let vertices = Vec::with_capacity(n);
+        
+        group.bench_with_input(BenchmarkId::from_parameter(b), &b, |bencher, _| {
+            bencher.iter_batched_ref(
+                || vertices.clone(),
+                |data| tessellation_vertex_benchmark(sphere, data),
+                BatchSize::SmallInput
+            );
+        });
+    }
+    group.finish();
+}
+
+fn hexsphere_icosa_fuller_b_bmod3_face(c: &mut Criterion) {
+    let mut group = c.benchmark_group("hexsphere_icosa_fuller_b_bmod3_face");
+    for b in [1, 2, 4, 8, 16, 32, 64, 128] {
+        let c = b % 3;
+        
+        let sphere = subsphere::HexSphere::from_kis(
+            subsphere::TriSphere::new(
+                subsphere::BaseTriSphere::Icosa,
+                subsphere::proj::Fuller,
+                NonZero::new(b).unwrap(),
+                c,
+            )
+        ).unwrap();
+        let n = sphere.num_faces() * (sphere.face(0).vertices().count() + 1);
+        let indices = Vec::with_capacity(n);
+        
+        group.bench_with_input(BenchmarkId::from_parameter(b), &b, |bencher, _| {
+            bencher.iter_batched_ref(
+                || indices.clone(),
+                |data| tessellation_face_benchmark(sphere, data),
+                BatchSize::SmallInput
+            );
+        });
+    }
+    group.finish();
+}
+
+fn hexsphere_icosa_gnomonic_b_bmod3_face(c: &mut Criterion) {
+    let mut group = c.benchmark_group("hexsphere_icosa_gnomonic_b_bmod3_face");
+    for b in [1, 2, 4, 8, 16, 32, 64, 128] {
+        let c = b % 3;
+        
+        let sphere = subsphere::HexSphere::from_kis(
+            subsphere::TriSphere::new(
+                subsphere::BaseTriSphere::Icosa,
+                subsphere::proj::Gnomonic,
+                NonZero::new(b).unwrap(),
+                c,
+            )
+        ).unwrap();
+        let n = sphere.num_faces() * (sphere.face(0).vertices().count() + 1);
+        let indices = Vec::with_capacity(n);
+        
+        group.bench_with_input(BenchmarkId::from_parameter(b), &b, |bencher, _| {
+            bencher.iter_batched_ref(
+                || indices.clone(),
+                |data| tessellation_face_benchmark(sphere, data),
+                BatchSize::SmallInput
+            );
+        });
+    }
+    group.finish();
+}
+
+fn hexsphere_octa_fuller_b_bmod3_face(c: &mut Criterion) {
+    let mut group = c.benchmark_group("hexsphere_octa_fuller_b_bmod3_face");
+    for b in [1, 2, 4, 8, 16, 32, 64, 128] {
+        let c = b % 3;
+        
+        let sphere = subsphere::HexSphere::from_kis(
+            subsphere::TriSphere::new(
+                subsphere::BaseTriSphere::Octa,
+                subsphere::proj::Fuller,
+                NonZero::new(b).unwrap(),
+                c,
+            )
+        ).unwrap();
+        let n = sphere.num_faces() * (sphere.face(0).vertices().count() + 1);
+        let indices = Vec::with_capacity(n);
+        
+        group.bench_with_input(BenchmarkId::from_parameter(b), &b, |bencher, _| {
+            bencher.iter_batched_ref(
+                || indices.clone(),
+                |data| tessellation_face_benchmark(sphere, data),
+                BatchSize::SmallInput
+            );
+        });
+    }
+    group.finish();
+}
+
+fn hexsphere_octa_gnomonic_b_bmod3_face(c: &mut Criterion) {
+    let mut group = c.benchmark_group("hexsphere_octa_gnomonic_b_bmod3_face");
+    for b in [1, 2, 4, 8, 16, 32, 64, 128] {
+        let c = b % 3;
+        
+        let sphere = subsphere::HexSphere::from_kis(
+            subsphere::TriSphere::new(
+                subsphere::BaseTriSphere::Octa,
+                subsphere::proj::Gnomonic,
+                NonZero::new(b).unwrap(),
+                c,
+            )
+        ).unwrap();
+        let n = sphere.num_faces() * (sphere.face(0).vertices().count() + 1);
+        let indices = Vec::with_capacity(n);
+        
+        group.bench_with_input(BenchmarkId::from_parameter(b), &b, |bencher, _| {
+            bencher.iter_batched_ref(
+                || indices.clone(),
+                |data| tessellation_face_benchmark(sphere, data),
+                BatchSize::SmallInput
+            );
+        });
+    }
+    group.finish();
+}
diff --git a/benchmarks.txt b/benchmarks.txt
deleted file mode 100644
index c70bfd4..0000000
--- a/benchmarks.txt
+++ /dev/null
@@ -1,148 +0,0 @@
-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/math.rs b/src/math.rs
index 33c4dc5..3826438 100644
--- a/src/math.rs
+++ b/src/math.rs
@@ -1,7 +1,6 @@
 //! This module contains a minimal set of linear algebra types and functions used by this crate. It
 //! has a very specific grab bag of functionality and is not intended to be publicly exposed for
 //! general use.
-pub use std::f64::consts::PI;
 
 /// Contains functions related to vectors.
 pub(crate) mod vec {
@@ -129,99 +128,3 @@ pub(crate) mod mat {
         [[d, -b], [-c, a]]
     }
 }
-
-/// Determines the real roots of a cubic polynomial of the form `a x³ + b x² + c x + d`.
-pub fn solve_cubic(a: f64, b: f64, c: f64, d: f64) -> impl Iterator<Item = f64> {
-    // Handle quadratic case explicitly.
-    const SMALL: f64 = 1.0e-6;
-    // TODO: This is pretty hacky. Is there a numerically stable continuation of the cubic
-    // formula for small leading coefficients?
-    if a.abs() < SMALL {
-        let disc = c * c - 4.0 * b * d;
-        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();
-            }
-            iter
-        } else {
-            let mut iter = [0.0, 0.0, 0.0].into_iter();
-            iter.next().unwrap();
-            iter.next().unwrap();
-            iter.next().unwrap();
-            iter
-        }
-    }
-
-    // See https://mathworld.wolfram.com/CubicFormula.html for derivation
-    let b_i_3a = (b / 3.0) / a;
-    let c_i_a = c / a;
-    let d_i_a = d / a;
-
-    // Let `x = t - b / 3 a`. Then the equation becomes `t³ + 3 q t - 2 r = 0` where:
-    let q = c_i_a / 3.0 - b_i_3a * b_i_3a;
-    let r = (b_i_3a * c_i_a - d_i_a) / 2.0 - b_i_3a * b_i_3a * b_i_3a;
-    let disc = q * q * q + r * r;
-    if disc >= 0.0 {
-        // Equation has one real
-        let w = (r + disc.sqrt()).cbrt();
-        let t = w - q / w;
-        let mut iter = [0.0, 0.0, t - b_i_3a].into_iter();
-        iter.next().unwrap();
-        iter.next().unwrap();
-        iter
-    } else {
-        // Equation has three real roots
-        let h = (r * r - disc).sqrt();
-        let s_r = h.cbrt();
-        // TODO: Evaluation of `acos` and `cos` shouldn't actually be necessary here.
-        let s_theta_0 = (r / h).acos() / 3.0;
-        let t_0 = 2.0 * s_r * (s_theta_0 + 2.0 * PI / 3.0).cos();
-        let x_0 = t_0 - b_i_3a;
-        let t_1 = 2.0 * s_r * (s_theta_0 - 2.0 * PI / 3.0).cos();
-        let x_1 = t_1 - b_i_3a;
-        let t_2 = 2.0 * s_r * s_theta_0.cos();
-        let x_2 = t_2 - b_i_3a;
-        [x_0, x_1, x_2].into_iter()
-    }
-}
-
-#[test]
-fn test_solve_cubic() {
-    // Cubic cases
-    assert_similar(solve_cubic(1.0, -1.0, 1.0, -1.0).collect(), [1.0]);
-    assert_similar(solve_cubic(1.0, 0.0, 0.0, -27.0).collect(), [3.0]);
-    assert_similar(solve_cubic(8.0, 8.0, 0.0, -3.0).collect(), [0.5]);
-    assert_similar(
-        solve_cubic(1.0, 4.0, 0.0, -5.0).collect(),
-        [
-            -(5.0f64.sqrt() + 5.0) / 2.0,
-            (5.0f64.sqrt() - 5.0) / 2.0,
-            1.0,
-        ],
-    );
-    assert_similar(
-        solve_cubic(1.0, -2.0, 0.0, 1.0).collect(),
-        [
-            (1.0 - 5.0f64.sqrt()) / 2.0,
-            1.0,
-            (1.0 + 5.0f64.sqrt()) / 2.0,
-        ],
-    );
-
-    // Quadratic cases
-    assert_similar(solve_cubic(0.0, 1.0, 0.0, -1.0).collect(), [-1.0, 1.0]);
-    assert_similar(solve_cubic(0.0, 1.0, 0.0, 1.0).collect(), []);
-
-    // Linear cases
-    assert_similar(solve_cubic(0.0, 0.0, 1.0, -1.0).collect(), [1.0]);
-}
-
-#[cfg(test)]
-fn assert_similar<const N: usize>(a: Vec<f64>, b: [f64; N]) {
-    if a.len() != b.len() || a.iter().zip(b.iter()).any(|(x, y)| (x - y).abs() > 1e-6) {
-        panic!("vectors are not similar, a = {:?}, b = {:?}", a, b);
-    }
-}
diff --git a/src/proj.rs b/src/proj.rs
index 210210d..fb6d892 100644
--- a/src/proj.rs
+++ b/src/proj.rs
@@ -1,14 +1,17 @@
 //! Contains types related to projections.
+
+pub mod fuller;
+
 use crate::basetri;
 #[expect(unused_imports)]
 use crate::basetri::BaseTriSphere;
-use crate::math::{self, mat, vec};
+use crate::math::{mat, vec};
 #[expect(unused_imports)]
 use crate::tri::TriSphere;
 
 pub use fuller::Fuller;
 pub use gnomonic::Gnomonic;
-pub use tri::{BaseTriProjector, TriProjector, Default};
+pub use tri::{BaseTriProjector, Default, TriProjector};
 
 /// Maps two-dimensional "local" coordinates to points on the unit sphere.
 ///
@@ -251,185 +254,3 @@ pub mod gnomonic {
         ]
     }
 }
-
-/// Contains types related to the [`Fuller`] projection.
-pub mod fuller {
-    use super::*;
-
-    /// The [Fuller](https://en.wikipedia.org/wiki/Dymaxion_map) projection method.
-    ///
-    /// This projection preserves distances exactly along the boundaries of base triangles.
-    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
-    pub struct Fuller;
-
-    impl Fuller {
-        /// Constructs a [`Projection`] for a specified spherical triangle. The triangle
-        /// must be equilateral, with the angle between any two endpoints equal to `angle`.
-        pub fn triangle(self, angle: f64, points: [[f64; 3]; 3]) -> Triangle {
-            // TODO: Validate triangle when debug assertions are on
-            Triangle { angle, points }
-        }
-    }
-
-    impl BaseTriProjector for Fuller {
-        type Triangle = Triangle;
-        fn inside(&self, edge: basetri::HalfEdge) -> Triangle {
-            self.triangle(
-                edge.sphere().edge_length(),
-                [
-                    edge.start().pos(),
-                    edge.next().start().pos(),
-                    edge.prev().start().pos(),
-                ],
-            )
-        }
-    }
-
-    /// A [`Projection`] for an equilateral triangle using the [`Fuller`] projection.
-    #[derive(Clone, Copy, Debug)]
-    pub struct Triangle {
-        /// The angle, in radians, between any two endpoints of the triangle.
-        angle: f64,
-
-        /// The positions of the endpoints of the triangle on the sphere.
-        points: [[f64; 3]; 3],
-    }
-
-    impl Projection for Triangle {
-        fn to_sphere(&self, [u, v]: [f64; 2]) -> [f64; 3] {
-            // Special handling for boundaries to improve accuracy and performance
-            if v <= 0.0 {
-                debug_assert_eq!(v, 0.0);
-                return if u <= 0.0 {
-                    debug_assert_eq!(u, 0.0);
-                    self.points[0]
-                } else {
-                    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);
-                return vec::normalize(vec::add(
-                    vec::mul(self.points[0], ((1.0 - v) * self.angle).sin()),
-                    vec::mul(self.points[2], (v * self.angle).sin()),
-                ));
-            }
-
-            // Adapted from "Exact Equations for Fuller’s Map Projection and Inverse"
-            // https://utppublishing.com/doi/pdf/10.3138/carto.43.1.67
-            // "Inverse with Spherical Linear Interpolation"
-
-            // First, solve the equation:
-            // `tan(x + proj_u) + tan(x) + tan(x + proj_v) + t_alpha = 0`
-            let t_alpha = (self.angle / 2.0).tan();
-            let proj_u = (2.0 * u + v - 1.0) * self.angle;
-            let proj_v = (2.0 * v + u - 1.0) * self.angle;
-            let t_half_proj_u = (proj_u / 2.0).tan();
-            let t_half_proj_v = (proj_v / 2.0).tan();
-            let den_u = 1.0 - t_half_proj_u * t_half_proj_u;
-            let den_v = 1.0 - t_half_proj_v * t_half_proj_v;
-            let s = t_half_proj_u * den_v + t_half_proj_v * den_u;
-            let p = t_half_proj_u * t_half_proj_v;
-            let (t_x, _) = math::solve_cubic(
-                4.0 * p,
-                4.0 * t_alpha * p - 4.0 * s,
-                3.0 * den_u * den_v - 8.0 * p - 2.0 * t_alpha * s,
-                t_alpha * den_u * den_v + 2.0 * s,
-            )
-            .map(|t_x| {
-                // There may be up to three solutions to the cubic equation, but only one
-                // corresponds to the correct projection solution. This is the unique solution
-                // which satisfies:
-                //   * `-self.full_angle / 2.0 <= x <= self.full_angle / 2.0`
-                //   * `-self.full_angle / 2.0 <= x + proj_u <= self.full_angle / 2.0`
-                //   * `-self.full_angle / 2.0 <= x + proj_v <= self.full_angle / 2.0`
-                //
-                // or equivalently:
-                //   * `-t_alpha <= tan(x) <= t_alpha`
-                //   * `-t_alpha <= tan(x + proj_u) <= t_alpha`
-                //   * `-t_alpha <= tan(x + proj_v) <= t_alpha`
-
-                // Assuming there is exactly one such solution, it will be the one with the
-                // smallest `max(|tan(x)|, |tan(x + proj_u)|, |tan(x + proj_v)|)`
-                let t_x_proj_u =
-                    (den_u + 2.0 * t_half_proj_u) / (den_u - 2.0 * t_x * t_half_proj_u);
-                let t_x_proj_v =
-                    (den_v + 2.0 * t_half_proj_v) / (den_v - 2.0 * t_x * t_half_proj_v);
-                (t_x, t_x.abs().max(t_x_proj_u.abs()).max(t_x_proj_v.abs()))
-            })
-            .min_by(|(_, a), (_, b)| a.abs().partial_cmp(&b.abs()).unwrap())
-            .unwrap();
-            let x = t_x.atan();
-
-            // Use the solution to compute angle offsets of two planes from the second and
-            // third edges of the triangle
-            let a_0 = self.angle / 2.0 - x;
-            let a_1 = a_0 - (2.0 * u + v - 1.0) * self.angle;
-
-            // Construct planes by offsetting the second and third edges of the triangle
-            let n_0 = vec::normalize(vec::cross(
-                mat::apply(
-                    [self.points[0], self.points[1]],
-                    [(self.angle - a_0).sin(), a_0.sin()],
-                ),
-                vec::sub(self.points[2], self.points[1]),
-            ));
-            let n_1 = vec::normalize(vec::cross(
-                mat::apply(
-                    [self.points[1], self.points[2]],
-                    [(self.angle - a_1).sin(), a_1.sin()],
-                ),
-                vec::sub(self.points[0], self.points[2]),
-            ));
-
-            // The projected point is the intersection of the two planes
-            vec::normalize(vec::cross(n_0, n_1))
-        }
-
-        fn from_sphere(&self, point: [f64; 3]) -> [f64; 2] {
-            // Adapted from "Exact Equations for Fuller’s Map Projection and Inverse"
-            // https://utppublishing.com/doi/pdf/10.3138/carto.43.1.67
-            // "Transformation Equations with Spherical Linear Interpolation"
-
-            let d_0 = mat::det_3([point, self.points[0], self.points[1]]);
-            let d_1 = mat::det_3([point, self.points[1], self.points[2]]);
-            let d_2 = mat::det_3([point, self.points[2], self.points[0]]);
-            let c = vec::dot(self.points[0], self.points[1]);
-            let a_0 = (2.0 * (d_0 * c) / (d_0 * c + d_1 + d_2)).atan();
-            let a_1 = (2.0 * (d_1 * c) / (d_1 * c + d_2 + d_0)).atan();
-            let a_2 = (2.0 * (d_2 * c) / (d_2 * c + d_0 + d_1)).atan();
-            let u = (self.angle + 2.0 * a_2 - a_0 - a_1) / (3.0 * self.angle);
-            let v = (self.angle + 2.0 * a_0 - a_1 - a_2) / (3.0 * self.angle);
-            [u, v]
-        }
-
-        type Transform = Transform<Self>;
-        fn transform(self, offset: [f64; 2], linear: [[f64; 2]; 2]) -> Transform<Self> {
-            Transform::new(self, offset, linear)
-        }
-    }
-
-    #[test]
-    fn test_roundtrip() {
-        use crate::basetri::BaseTriSphere;
-        use crate::prelude::*;
-        const N: usize = 100;
-        for v in 0..N {
-            for u in 0..(N - v) {
-                let u = u as f64 / N as f64;
-                let v = v as f64 / N as f64;
-                let proj = Fuller.inside(BaseTriSphere::Icosa.face(0).side(0));
-                let point = proj.to_sphere([u, v]);
-                let [a_u, a_v] = proj.from_sphere(point);
-                assert!(
-                    (a_u - u).abs() < 1e-12 && (a_v - v).abs() < 1e-12,
-                    "failed roundtrip: got {:?}, expected {:?}",
-                    [a_u, a_v],
-                    [u, v]
-                );
-            }
-        }
-    }
-}
diff --git a/src/proj/fuller.rs b/src/proj/fuller.rs
new file mode 100644
index 0000000..d3d811c
--- /dev/null
+++ b/src/proj/fuller.rs
@@ -0,0 +1,276 @@
+//! Contains types related to the [`Fuller`] projection.
+use std::f64::consts::FRAC_PI_3;
+use super::*;
+
+/// The [Fuller](https://en.wikipedia.org/wiki/Dymaxion_map) projection method.
+///
+/// This projection preserves distances exactly along the boundaries of base triangles.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
+pub struct Fuller;
+
+impl Fuller {
+    /// Constructs a [`Projection`] for a specified spherical triangle. The triangle
+    /// must be equilateral, with the angle between any two endpoints equal to `angle`.
+    pub fn triangle(self, angle: f64, points: [[f64; 3]; 3]) -> Triangle {
+        // TODO: Validate triangle when debug assertions are on
+        Triangle { angle, points }
+    }
+}
+
+impl BaseTriProjector for Fuller {
+    type Triangle = Triangle;
+    fn inside(&self, edge: basetri::HalfEdge) -> Triangle {
+        self.triangle(
+            edge.sphere().edge_length(),
+            [
+                edge.start().pos(),
+                edge.next().start().pos(),
+                edge.prev().start().pos(),
+            ],
+        )
+    }
+}
+
+/// A [`Projection`] for an equilateral triangle using the [`Fuller`] projection.
+#[derive(Clone, Copy, Debug)]
+pub struct Triangle {
+    /// The angle, in radians, between any two endpoints of the triangle.
+    angle: f64,
+    
+    /// The positions of the endpoints of the triangle on the sphere.
+    points: [[f64; 3]; 3],
+}
+
+impl Projection for Triangle {
+    fn to_sphere(&self, [u, v]: [f64; 2]) -> [f64; 3] {
+        // Special handling for boundaries to improve accuracy and performance
+        if v <= 0.0 {
+            debug_assert_eq!(v, 0.0);
+            return if u <= 0.0 {
+                debug_assert_eq!(u, 0.0);
+                self.points[0]
+            } else {
+                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);
+            return vec::normalize(vec::add(
+                vec::mul(self.points[0], ((1.0 - v) * self.angle).sin()),
+                vec::mul(self.points[2], (v * self.angle).sin()),
+            ));
+        }
+        
+        // Adapted from "Exact Equations for Fuller’s Map Projection and Inverse"
+        // https://utppublishing.com/doi/pdf/10.3138/carto.43.1.67
+        // "Inverse with Spherical Linear Interpolation"
+        
+        // First, solve the equation:
+        // `tan(x + proj_u) + tan(x) + tan(x + proj_v) + t_alpha = 0`
+        let t_alpha = (self.angle / 2.0).tan();
+        let proj_u = (2.0 * u + v - 1.0) * self.angle;
+        let proj_v = (2.0 * v + u - 1.0) * self.angle;
+        let t_half_proj_u = (proj_u / 2.0).tan();
+        let t_half_proj_v = (proj_v / 2.0).tan();
+        let den_u = 1.0 - t_half_proj_u * t_half_proj_u;
+        let den_v = 1.0 - t_half_proj_v * t_half_proj_v;
+        let s = t_half_proj_u * den_v + t_half_proj_v * den_u;
+        let p = t_half_proj_u * t_half_proj_v;
+        let (t_x, _) = solve_cubic_fuller(
+            4.0 * p,
+            4.0 * t_alpha * p - 4.0 * s,
+            3.0 * den_u * den_v - 8.0 * p - 2.0 * t_alpha * s,
+            t_alpha * den_u * den_v + 2.0 * s,
+        )
+            .into_iter()
+            .map(|t_x| {
+                // There may be up to three solutions to the cubic equation, but only one
+                // corresponds to the correct projection solution. This is the unique solution
+                // which satisfies:
+                //   * `-self.full_angle / 2.0 <= x <= self.full_angle / 2.0`
+                //   * `-self.full_angle / 2.0 <= x + proj_u <= self.full_angle / 2.0`
+                //   * `-self.full_angle / 2.0 <= x + proj_v <= self.full_angle / 2.0`
+                //
+                // or equivalently:
+                //   * `-t_alpha <= tan(x) <= t_alpha`
+                //   * `-t_alpha <= tan(x + proj_u) <= t_alpha`
+                //   * `-t_alpha <= tan(x + proj_v) <= t_alpha`
+                
+                // Assuming there is exactly one such solution, it will be the one with the
+                // smallest `max(|tan(x)|, |tan(x + proj_u)|, |tan(x + proj_v)|)`
+                let t_x_proj_u =
+                    (den_u + 2.0 * t_half_proj_u) / (den_u - 2.0 * t_x * t_half_proj_u);
+                let t_x_proj_v =
+                    (den_v + 2.0 * t_half_proj_v) / (den_v - 2.0 * t_x * t_half_proj_v);
+                (t_x, t_x.abs().max(t_x_proj_u.abs()).max(t_x_proj_v.abs()))
+            })
+            .min_by(|(_, a), (_, b)| a.abs().partial_cmp(&b.abs()).unwrap())
+            .unwrap();
+        
+        let x = t_x.atan();
+        
+        // Use the solution to compute angle offsets of two planes from the second and
+        // third edges of the triangle
+        let a_0 = self.angle / 2.0 - x;
+        let a_1 = a_0 - (2.0 * u + v - 1.0) * self.angle;
+        
+        // Construct planes by offsetting the second and third edges of the triangle
+        let n_0 = vec::cross(
+            mat::apply(
+                [self.points[0], self.points[1]],
+                [(self.angle - a_0).sin(), a_0.sin()],
+            ),
+            vec::sub(self.points[2], self.points[1]),
+        );
+        let n_1 = vec::cross(
+            mat::apply(
+                [self.points[1], self.points[2]],
+                [(self.angle - a_1).sin(), a_1.sin()],
+            ),
+            vec::sub(self.points[0], self.points[2]),
+        );
+        
+        // The projected point is the intersection of the two planes
+        vec::normalize(vec::cross(n_0, n_1))
+    }
+    
+    fn from_sphere(&self, point: [f64; 3]) -> [f64; 2] {
+        // Adapted from "Exact Equations for Fuller’s Map Projection and Inverse"
+        // https://utppublishing.com/doi/pdf/10.3138/carto.43.1.67
+        // "Transformation Equations with Spherical Linear Interpolation"
+        
+        let d_0 = mat::det_3([point, self.points[0], self.points[1]]);
+        let d_1 = mat::det_3([point, self.points[1], self.points[2]]);
+        let d_2 = mat::det_3([point, self.points[2], self.points[0]]);
+        let c = vec::dot(self.points[0], self.points[1]);
+        let a_0 = (2.0 * (d_0 * c) / (d_0 * c + d_1 + d_2)).atan();
+        let a_1 = (2.0 * (d_1 * c) / (d_1 * c + d_2 + d_0)).atan();
+        let a_2 = (2.0 * (d_2 * c) / (d_2 * c + d_0 + d_1)).atan();
+        let u = (self.angle + 2.0 * a_2 - a_0 - a_1) / (3.0 * self.angle);
+        let v = (self.angle + 2.0 * a_0 - a_1 - a_2) / (3.0 * self.angle);
+        [u, v]
+    }
+    
+    type Transform = Transform<Self>;
+    fn transform(self, offset: [f64; 2], linear: [[f64; 2]; 2]) -> Transform<Self> {
+        Transform::new(self, offset, linear)
+    }
+}
+
+#[test]
+fn test_roundtrip() {
+    use crate::basetri::BaseTriSphere;
+    use crate::prelude::*;
+    const N: usize = 100;
+    for v in 0..N {
+        for u in 0..(N - v) {
+            let u = u as f64 / N as f64;
+            let v = v as f64 / N as f64;
+            let proj = Fuller.inside(BaseTriSphere::Icosa.face(0).side(0));
+            let point = proj.to_sphere([u, v]);
+            let [a_u, a_v] = proj.from_sphere(point);
+            assert!(
+                (a_u - u).abs() < 1e-12 && (a_v - v).abs() < 1e-12,
+                "failed roundtrip: got {:?}, expected {:?}",
+                [a_u, a_v],
+                [u, v]
+            );
+        }
+    }
+}
+
+/// Determines the real roots of a cubic polynomial of the form `a x³ + b x² + c x + d` as used in the Fuller
+/// projection. This is not a general purpose solver and is optimized for the specific types of cubics encountered in
+/// the Fuller projection. As such, multiple branches that are never encountered in that context have been removed for
+/// performance purposes. Most notably, this method only handles polynomials with 2 or 3 real roots. Takes 36.9 FLOPs on
+/// average in the `test` build.
+fn solve_cubic_fuller(a: f64, b: f64, c: f64, d: f64) -> impl IntoIterator<Item = f64> {
+    const SMALL: f64 = 1.0e-6;
+    if a.abs() < SMALL {
+        // Branch needed to avoid loss of precision when `a` is relatively small. Determines the real roots of the
+        // quadratic `b x² + c x + d`. In the Fuller projection, this will always have two real roots, so we avoid
+        // checking if the discriminant is non-negative in non-debug builds.
+        // Branch taken ~10% of the time in testing.
+        let j = 2.0 * d;
+        let k = 2.0 * b;
+        let disc = c * c - j * k;
+        
+        // The polynomial discriminant will never be negative so we don't need to branch here.
+        // TODO: Can we find a proof?
+        debug_assert!(disc >= 0.0, "Quadratic discriminant was negative.");
+        let u = -c - disc.sqrt().copysign(c);
+        [j / u, u / k, 0.0].into_iter().take(2)
+    } else {
+        // Convert to a depressed cubic of the form `t³ p t + q` where `t = x - b/3a` and finds the real roots.
+        // https://en.wikipedia.org/wiki/Cubic_equation#Depressed_cubic
+        // https://mathworld.wolfram.com/CubicFormula.html
+        // In a general solver, a case for when `d` is close to 0 could improve performance. In testing this library,
+        // `d` was never smaller than 10^-6 so the branch would not be useful.
+        let b2 = b * b;
+        let a2 = a * a;
+        let ac = a * c;
+        
+        let p = (3.0 * ac - b2) / (3.0 * a2);
+        // If `b` is close to 0, this expression will be very close to `d/a`. In testing this library `|b|` was less
+        // than 10^-6 in less than 0.0001% of cases so the branch would cost more performance than it saved.
+        let q = (2.0 * b2 * b - 9.0 * ac * b + 27.0 * a2 * d) / (27.0 * a2 * a);
+        
+        let s = b / (3.0 * a);
+        
+        // Solve depressed cubic
+        // In general, a case to just output the negative cube root of `q` when `p` is close to 0 would improve performance.
+        // However, in testing, `|p|` was less than 10^-6 in less than 0.0001% of cases, so branching would cost more than
+        // it gained.
+        
+        // The discriminant will always be negative so we will always have three real roots and don't need to branch here.
+        // TODO: Can we find a proof?
+        debug_assert!(4.0 * p * p * p + 27.0 * q * q < 0.0, "Depressed cubic discriminant was not negative.");
+        let p_3 = -p / 3.0;
+        let k = 2.0 * p_3.sqrt();
+        // Absolute value not necessary after pulling p_3*p_3 out of sqrt because p is always negative.
+        let theta = (-q / (p_3 * k)).acos() / 3.0;
+        const PHI: f64 = 2.0 * FRAC_PI_3;
+        
+        [
+            k * theta.cos() - s,
+            k * (theta + PHI).cos() - s,
+            k * (theta + 2.0 * PHI).cos() - s
+        ].into_iter().take(3)
+    }
+}
+
+#[test]
+fn test_solve_cubic() {
+    // Cubic cases
+    assert_similar(
+        solve_cubic_fuller(1.0, 4.0, 0.0, -5.0).into_iter().collect(),
+        [
+            -(5.0f64.sqrt() + 5.0) / 2.0,
+            (5.0f64.sqrt() - 5.0) / 2.0,
+            1.0,
+        ],
+    );
+    assert_similar(
+        solve_cubic_fuller(1.0, -2.0, 0.0, 1.0).into_iter().collect(),
+        [
+            (1.0 - 5.0f64.sqrt()) / 2.0,
+            1.0,
+            (1.0 + 5.0f64.sqrt()) / 2.0,
+        ],
+    );
+    
+    // Quadratic cases
+    assert_similar(solve_cubic_fuller(0.0, 1.0, 0.0, -1.0).into_iter().collect(), [-1.0, 1.0]);
+}
+
+#[cfg(test)]
+fn assert_similar<const N: usize>(mut a: Vec<f64>, mut b: [f64; N]) {
+    const SMALL: f64 = 1.0e-6;
+    a.sort_by(|u, v| u.partial_cmp(v).unwrap());
+    b.sort_by(|u, v| u.partial_cmp(v).unwrap());
+    if a.len() != b.len() || a.iter().zip(b.iter()).any(|(x, y)| (x - y).abs() > SMALL) {
+        panic!("vectors are not similar, a = {:?}, b = {:?}", a, b);
+    }
+}
\ No newline at end of file
diff --git a/src/tri.rs b/src/tri.rs
index ec9b38d..1845bd5 100644
--- a/src/tri.rs
+++ b/src/tri.rs
@@ -1352,7 +1352,7 @@ impl<Proj: BaseTriProjector> SphereProjection<'_, Proj> {
                 self.sphere
                     .projector()
                     .inside(region.as_edge().twin())
-                    .to_sphere(vec::add([1.0, 0.0], vec::neg(coords)))
+                    .to_sphere(vec::sub([1.0, 0.0], coords))
             }
         } else {
             self.sphere


Reviews:
user: login: dzamkov
body: Good job on the investigation! This is looking like a pretty significant performance improvement. Just left a few comments on the code organization
state: CHANGES_REQUESTED
author_association: OWNER
submitted_at: 2025-05-26T17:23:42Z
user: login: MorganBennetDev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-27T14:48:22Z
user: login: MorganBennetDev
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-27T15:36:39Z
user: login: dzamkov
body: A few small things
state: CHANGES_REQUESTED
author_association: OWNER
submitted_at: 2025-05-27T16:30:05Z
user: login: dzamkov
body: Thank you!
state: APPROVED
author_association: OWNER
submitted_at: 2025-05-29T01:27:06Z

Review comments:
path: src/math/polynomials.rs
user: login: dzamkov
body: Since the code in this module is now hyper-specialized to the Fuller projection, I don't think it makes sense being part of the `math` module anymore. Can you:
1. Pull out the `fuller` sub-module of `proj.rs` into its own file
2. Add this code to that file
created_at: 2025-05-26T16:46:10Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,127 @@
+use std::f64::consts::FRAC_PI_3;
+use crate::math::SMALL;
+
+/// Represents the distinct real roots of a cubic equation used in the Fuller projection. This library will only ever
+/// solve equations with 2 or 3 distinct real roots so we neglect handling other cases in the name of performance.
+#[derive(Copy, Clone)]
+pub struct Roots(f64, f64, Option<f64>);
path: src/math/polynomials.rs
user: login: dzamkov
body: Now that we no longer need to append roots to a list, do we still need `Roots`? I'd prefer to keep the simplicity of the old method, which didn't need any auxiliary types. In terms of performance, this can't be faster than the old method because the iterator for `Roots` is 8 bytes larger and requires an extra branch (though, I'm sure they're optimized to being equivalent).


created_at: 2025-05-26T16:55:55Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,127 @@
+use std::f64::consts::FRAC_PI_3;
+use crate::math::SMALL;
+
+/// Represents the distinct real roots of a cubic equation used in the Fuller projection. This library will only ever
+/// solve equations with 2 or 3 distinct real roots so we neglect handling other cases in the name of performance.
+#[derive(Copy, Clone)]
+pub struct Roots(f64, f64, Option<f64>);
+
+impl Roots {
+    /// Adds `v` to the value of all roots.
+    #[inline]
+    const fn add(self, v: f64) -> Self {
+        match self.2 {
+            None => Self(self.0 + v, self.1 + v, None),
+            Some(c) => Self(self.0 + v, self.1 + v, Some(c + v))
+        }
+    }
+}
+
+impl IntoIterator for Roots {
+    type Item = f64;
+    type IntoIter = std::iter::Take<std::array::IntoIter<f64, 3>>;
+    
+    fn into_iter(self) -> Self::IntoIter {
+        match self.2 {
+            None => [self.0, self.1, 0.0].into_iter().take(2),
+            Some(c) => [self.0, self.1, c].into_iter().take(3)
+        }
+    }
+}
+
+/// Determines the real roots of a quadratic polynomial of the form `a x² + b x + c`. If a root has multiplicity greater
+/// than one, it will only be included once in the output. Will output `NaN` roots if the discriminant is negative, but
+/// this did not occur in testing and is thus ignored. Takes 9 FLOPs.
+fn solve_quadratic(a: f64, b: f64, c: f64) -> Roots {
+    let j = 2.0 * c;
+    let k = 2.0 * a;
+    let disc = b * b - j * k;
+    
+    // The polynomial discriminant will never be negative so we don't need to branch here.
path: src/math/polynomials.rs
user: login: dzamkov
body: Add `debug_assert` to verify assumptions in debug builds
created_at: 2025-05-26T16:56:34Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,127 @@
+use std::f64::consts::FRAC_PI_3;
+use crate::math::SMALL;
+
+/// Represents the distinct real roots of a cubic equation used in the Fuller projection. This library will only ever
+/// solve equations with 2 or 3 distinct real roots so we neglect handling other cases in the name of performance.
+#[derive(Copy, Clone)]
+pub struct Roots(f64, f64, Option<f64>);
+
+impl Roots {
+    /// Adds `v` to the value of all roots.
+    #[inline]
+    const fn add(self, v: f64) -> Self {
+        match self.2 {
+            None => Self(self.0 + v, self.1 + v, None),
+            Some(c) => Self(self.0 + v, self.1 + v, Some(c + v))
+        }
+    }
+}
+
+impl IntoIterator for Roots {
+    type Item = f64;
+    type IntoIter = std::iter::Take<std::array::IntoIter<f64, 3>>;
+    
+    fn into_iter(self) -> Self::IntoIter {
+        match self.2 {
+            None => [self.0, self.1, 0.0].into_iter().take(2),
+            Some(c) => [self.0, self.1, c].into_iter().take(3)
+        }
+    }
+}
+
+/// Determines the real roots of a quadratic polynomial of the form `a x² + b x + c`. If a root has multiplicity greater
+/// than one, it will only be included once in the output. Will output `NaN` roots if the discriminant is negative, but
+/// this did not occur in testing and is thus ignored. Takes 9 FLOPs.
+fn solve_quadratic(a: f64, b: f64, c: f64) -> Roots {
+    let j = 2.0 * c;
+    let k = 2.0 * a;
+    let disc = b * b - j * k;
+    
+    // The polynomial discriminant will never be negative so we don't need to branch here.
+    // TODO: Can we find a proof?
+    let u = -b - disc.sqrt().copysign(b);
+    Roots(j / u, u / k, None)
+}
+
+// https://en.wikipedia.org/wiki/Cubic_equation#Depressed_cubic
+// https://mathworld.wolfram.com/CubicFormula.html
+/// Determines the real roots of a depressed cubic polynomial of the form `x³ + p x + q`. If a root has multiplicity
+/// greater than one, it will only appear once in the output. Takes 17 FLOPs.
+fn solve_depressed_cubic(p: f64, q: f64) -> Roots {
+    // In general, a case to just output the negative cube root of `q` when `p` is close to 0 would improve performance.
+    // However, in testing, `|p|` was less than 10^-6 in less than 0.0001% of cases, so branching would cost more than
+    // it gained.
+    
+    // The discriminant will always be negative so we will always have three real roots and don't need to branch here.
path: src/math/polynomials.rs
user: login: dzamkov
body: Add `debug_assert` to verify assumptions in debug builds
created_at: 2025-05-26T16:57:13Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,127 @@
+use std::f64::consts::FRAC_PI_3;
+use crate::math::SMALL;
+
+/// Represents the distinct real roots of a cubic equation used in the Fuller projection. This library will only ever
+/// solve equations with 2 or 3 distinct real roots so we neglect handling other cases in the name of performance.
+#[derive(Copy, Clone)]
+pub struct Roots(f64, f64, Option<f64>);
+
+impl Roots {
+    /// Adds `v` to the value of all roots.
+    #[inline]
+    const fn add(self, v: f64) -> Self {
+        match self.2 {
+            None => Self(self.0 + v, self.1 + v, None),
+            Some(c) => Self(self.0 + v, self.1 + v, Some(c + v))
+        }
+    }
+}
+
+impl IntoIterator for Roots {
+    type Item = f64;
+    type IntoIter = std::iter::Take<std::array::IntoIter<f64, 3>>;
+    
+    fn into_iter(self) -> Self::IntoIter {
+        match self.2 {
+            None => [self.0, self.1, 0.0].into_iter().take(2),
+            Some(c) => [self.0, self.1, c].into_iter().take(3)
+        }
+    }
+}
+
+/// Determines the real roots of a quadratic polynomial of the form `a x² + b x + c`. If a root has multiplicity greater
+/// than one, it will only be included once in the output. Will output `NaN` roots if the discriminant is negative, but
+/// this did not occur in testing and is thus ignored. Takes 9 FLOPs.
+fn solve_quadratic(a: f64, b: f64, c: f64) -> Roots {
+    let j = 2.0 * c;
+    let k = 2.0 * a;
+    let disc = b * b - j * k;
+    
+    // The polynomial discriminant will never be negative so we don't need to branch here.
+    // TODO: Can we find a proof?
+    let u = -b - disc.sqrt().copysign(b);
+    Roots(j / u, u / k, None)
+}
+
+// https://en.wikipedia.org/wiki/Cubic_equation#Depressed_cubic
+// https://mathworld.wolfram.com/CubicFormula.html
+/// Determines the real roots of a depressed cubic polynomial of the form `x³ + p x + q`. If a root has multiplicity
+/// greater than one, it will only appear once in the output. Takes 17 FLOPs.
+fn solve_depressed_cubic(p: f64, q: f64) -> Roots {
+    // In general, a case to just output the negative cube root of `q` when `p` is close to 0 would improve performance.
+    // However, in testing, `|p|` was less than 10^-6 in less than 0.0001% of cases, so branching would cost more than
+    // it gained.
+    
+    // The discriminant will always be negative so we will always have three real roots and don't need to branch here.
+    // TODO: Can we find a proof?
+    let p_3 = -p / 3.0;
+    let k = 2.0 * p_3.sqrt();
+    // Absolute value not necessary after pulling p_3*p_3 out of sqrt because p is always negative.
+    let theta = (-q / (p_3 * k)).acos() / 3.0;
+    const PHI: f64 = 2.0 * FRAC_PI_3;
+    
+    Roots(
+        k * theta.cos(),
+        k * (theta + PHI).cos(),
+        Some(k * (theta + 2.0 * PHI).cos())
+    )
+}
+
+/// Determines the real roots of a cubic polynomial of the form `a x³ + b x² + c x + d`. If a root has multiplicity
path: src/math/polynomials.rs
user: login: dzamkov
body: The comment/name for `solve_cubic` should no longer imply that this is suitable for general cubic solving. Maybe rename to `solve_cubic_restricted` and mention that this is only intended to handle the cases required by Fuller's `to_sphere`.
created_at: 2025-05-26T17:00:27Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,127 @@
+use std::f64::consts::FRAC_PI_3;
+use crate::math::SMALL;
+
+/// Represents the distinct real roots of a cubic equation used in the Fuller projection. This library will only ever
+/// solve equations with 2 or 3 distinct real roots so we neglect handling other cases in the name of performance.
+#[derive(Copy, Clone)]
+pub struct Roots(f64, f64, Option<f64>);
+
+impl Roots {
+    /// Adds `v` to the value of all roots.
+    #[inline]
+    const fn add(self, v: f64) -> Self {
+        match self.2 {
+            None => Self(self.0 + v, self.1 + v, None),
+            Some(c) => Self(self.0 + v, self.1 + v, Some(c + v))
+        }
+    }
+}
+
+impl IntoIterator for Roots {
+    type Item = f64;
+    type IntoIter = std::iter::Take<std::array::IntoIter<f64, 3>>;
+    
+    fn into_iter(self) -> Self::IntoIter {
+        match self.2 {
+            None => [self.0, self.1, 0.0].into_iter().take(2),
+            Some(c) => [self.0, self.1, c].into_iter().take(3)
+        }
+    }
+}
+
+/// Determines the real roots of a quadratic polynomial of the form `a x² + b x + c`. If a root has multiplicity greater
+/// than one, it will only be included once in the output. Will output `NaN` roots if the discriminant is negative, but
+/// this did not occur in testing and is thus ignored. Takes 9 FLOPs.
+fn solve_quadratic(a: f64, b: f64, c: f64) -> Roots {
+    let j = 2.0 * c;
+    let k = 2.0 * a;
+    let disc = b * b - j * k;
+    
+    // The polynomial discriminant will never be negative so we don't need to branch here.
+    // TODO: Can we find a proof?
+    let u = -b - disc.sqrt().copysign(b);
+    Roots(j / u, u / k, None)
+}
+
+// https://en.wikipedia.org/wiki/Cubic_equation#Depressed_cubic
+// https://mathworld.wolfram.com/CubicFormula.html
+/// Determines the real roots of a depressed cubic polynomial of the form `x³ + p x + q`. If a root has multiplicity
+/// greater than one, it will only appear once in the output. Takes 17 FLOPs.
+fn solve_depressed_cubic(p: f64, q: f64) -> Roots {
+    // In general, a case to just output the negative cube root of `q` when `p` is close to 0 would improve performance.
+    // However, in testing, `|p|` was less than 10^-6 in less than 0.0001% of cases, so branching would cost more than
+    // it gained.
+    
+    // The discriminant will always be negative so we will always have three real roots and don't need to branch here.
+    // TODO: Can we find a proof?
+    let p_3 = -p / 3.0;
+    let k = 2.0 * p_3.sqrt();
+    // Absolute value not necessary after pulling p_3*p_3 out of sqrt because p is always negative.
+    let theta = (-q / (p_3 * k)).acos() / 3.0;
+    const PHI: f64 = 2.0 * FRAC_PI_3;
+    
+    Roots(
+        k * theta.cos(),
+        k * (theta + PHI).cos(),
+        Some(k * (theta + 2.0 * PHI).cos())
+    )
+}
+
+/// Determines the real roots of a cubic polynomial of the form `a x³ + b x² + c x + d`. If a root has multiplicity
+/// greater than one, it will only appear once in the output. Takes 36.9 FLOPs in this library's typical usage.
+/// This is optimized for this library's use case of solving equations with 2 or 3 real roots and will fail when used
+/// in other cases.
+pub fn solve_cubic(a: f64, b: f64, c: f64, d: f64) -> Roots {
+    if a.abs() < SMALL {
+        // Branch taken ~10% of the time in testing.
+        solve_quadratic(b, c, d)
path: src/math/polynomials.rs
user: login: dzamkov
body: Consider inlining `solve_quadratic` and `solve_depressed_cubic` directly into this function. As it is now, these helper functions are called exactly once each and are essentially useless in any other context. I prefer the readability of a single long function to one that jumps around into single-use helper functions. You could still use comments to call out what the sub-tasks are.
created_at: 2025-05-26T17:10:52Z
author_association: OWNER
diff_hunk: @@ -369,20 +372,20 @@ pub mod fuller {
             let a_1 = a_0 - (2.0 * u + v - 1.0) * self.angle;
 
             // Construct planes by offsetting the second and third edges of the triangle
-            let n_0 = vec::normalize(vec::cross(
path: src/proj.rs
user: login: dzamkov
body: Good catch!
created_at: 2025-05-26T17:13:42Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,127 @@
+use std::f64::consts::FRAC_PI_3;
+use crate::math::SMALL;
+
+/// Represents the distinct real roots of a cubic equation used in the Fuller projection. This library will only ever
+/// solve equations with 2 or 3 distinct real roots so we neglect handling other cases in the name of performance.
+#[derive(Copy, Clone)]
+pub struct Roots(f64, f64, Option<f64>);
path: src/math/polynomials.rs
user: login: MorganBennetDev
body: Good catch. I suspect changing back to `impl Iterator<Item = f64>` will either not affect or improve performance, but I'm running some benchmarks before changing to be sure.
created_at: 2025-05-27T14:48:22Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,127 @@
+use std::f64::consts::FRAC_PI_3;
+use crate::math::SMALL;
+
+/// Represents the distinct real roots of a cubic equation used in the Fuller projection. This library will only ever
+/// solve equations with 2 or 3 distinct real roots so we neglect handling other cases in the name of performance.
+#[derive(Copy, Clone)]
+pub struct Roots(f64, f64, Option<f64>);
path: src/math/polynomials.rs
user: login: MorganBennetDev
body: Change in performance is minimal. Pushing this.
created_at: 2025-05-27T15:36:39Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,274 @@
+//! Contains types related to the [`Fuller`] projection.
+use std::f64::consts::FRAC_PI_3;
+use super::*;
+
+/// The [Fuller](https://en.wikipedia.org/wiki/Dymaxion_map) projection method.
+///
+/// This projection preserves distances exactly along the boundaries of base triangles.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
+pub struct Fuller;
+
+impl Fuller {
+    /// Constructs a [`Projection`] for a specified spherical triangle. The triangle
+    /// must be equilateral, with the angle between any two endpoints equal to `angle`.
+    pub fn triangle(self, angle: f64, points: [[f64; 3]; 3]) -> Triangle {
+        // TODO: Validate triangle when debug assertions are on
+        Triangle { angle, points }
+    }
+}
+
+impl BaseTriProjector for Fuller {
+    type Triangle = Triangle;
+    fn inside(&self, edge: basetri::HalfEdge) -> Triangle {
+        self.triangle(
+            edge.sphere().edge_length(),
+            [
+                edge.start().pos(),
+                edge.next().start().pos(),
+                edge.prev().start().pos(),
+            ],
+        )
+    }
+}
+
+/// A [`Projection`] for an equilateral triangle using the [`Fuller`] projection.
+#[derive(Clone, Copy, Debug)]
+pub struct Triangle {
+    /// The angle, in radians, between any two endpoints of the triangle.
+    angle: f64,
+    
+    /// The positions of the endpoints of the triangle on the sphere.
+    points: [[f64; 3]; 3],
+}
+
+impl Projection for Triangle {
+    fn to_sphere(&self, [u, v]: [f64; 2]) -> [f64; 3] {
+        // Special handling for boundaries to improve accuracy and performance
+        if v <= 0.0 {
+            debug_assert_eq!(v, 0.0);
+            return if u <= 0.0 {
+                debug_assert_eq!(u, 0.0);
+                self.points[0]
+            } else {
+                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);
+            return vec::normalize(vec::add(
+                vec::mul(self.points[0], ((1.0 - v) * self.angle).sin()),
+                vec::mul(self.points[2], (v * self.angle).sin()),
+            ));
+        }
+        
+        // Adapted from "Exact Equations for Fuller’s Map Projection and Inverse"
+        // https://utppublishing.com/doi/pdf/10.3138/carto.43.1.67
+        // "Inverse with Spherical Linear Interpolation"
+        
+        // First, solve the equation:
+        // `tan(x + proj_u) + tan(x) + tan(x + proj_v) + t_alpha = 0`
+        let t_alpha = (self.angle / 2.0).tan();
+        let proj_u = (2.0 * u + v - 1.0) * self.angle;
+        let proj_v = (2.0 * v + u - 1.0) * self.angle;
+        let t_half_proj_u = (proj_u / 2.0).tan();
+        let t_half_proj_v = (proj_v / 2.0).tan();
+        let den_u = 1.0 - t_half_proj_u * t_half_proj_u;
+        let den_v = 1.0 - t_half_proj_v * t_half_proj_v;
+        let s = t_half_proj_u * den_v + t_half_proj_v * den_u;
+        let p = t_half_proj_u * t_half_proj_v;
+        let (t_x, _) = solve_cubic_fuller(
+            4.0 * p,
+            4.0 * t_alpha * p - 4.0 * s,
+            3.0 * den_u * den_v - 8.0 * p - 2.0 * t_alpha * s,
+            t_alpha * den_u * den_v + 2.0 * s,
+        )
+            .into_iter()
+            .map(|t_x| {
+                // There may be up to three solutions to the cubic equation, but only one
+                // corresponds to the correct projection solution. This is the unique solution
+                // which satisfies:
+                //   * `-self.full_angle / 2.0 <= x <= self.full_angle / 2.0`
+                //   * `-self.full_angle / 2.0 <= x + proj_u <= self.full_angle / 2.0`
+                //   * `-self.full_angle / 2.0 <= x + proj_v <= self.full_angle / 2.0`
+                //
+                // or equivalently:
+                //   * `-t_alpha <= tan(x) <= t_alpha`
+                //   * `-t_alpha <= tan(x + proj_u) <= t_alpha`
+                //   * `-t_alpha <= tan(x + proj_v) <= t_alpha`
+                
+                // Assuming there is exactly one such solution, it will be the one with the
+                // smallest `max(|tan(x)|, |tan(x + proj_u)|, |tan(x + proj_v)|)`
+                let t_x_proj_u =
+                    (den_u + 2.0 * t_half_proj_u) / (den_u - 2.0 * t_x * t_half_proj_u);
+                let t_x_proj_v =
+                    (den_v + 2.0 * t_half_proj_v) / (den_v - 2.0 * t_x * t_half_proj_v);
+                (t_x, t_x.abs().max(t_x_proj_u.abs()).max(t_x_proj_v.abs()))
+            })
+            .min_by(|(_, a), (_, b)| a.abs().partial_cmp(&b.abs()).unwrap())
+            .unwrap();
+        
+        let x = t_x.atan();
+        
+        // Use the solution to compute angle offsets of two planes from the second and
+        // third edges of the triangle
+        let a_0 = self.angle / 2.0 - x;
+        let a_1 = a_0 - (2.0 * u + v - 1.0) * self.angle;
+        
+        // Construct planes by offsetting the second and third edges of the triangle
+        let n_0 = vec::cross(
+            mat::apply(
+                [self.points[0], self.points[1]],
+                [(self.angle - a_0).sin(), a_0.sin()],
+            ),
+            vec::sub(self.points[2], self.points[1]),
+        );
+        let n_1 = vec::cross(
+            mat::apply(
+                [self.points[1], self.points[2]],
+                [(self.angle - a_1).sin(), a_1.sin()],
+            ),
+            vec::sub(self.points[0], self.points[2]),
+        );
+        
+        // The projected point is the intersection of the two planes
+        vec::normalize(vec::cross(n_0, n_1))
+    }
+    
+    fn from_sphere(&self, point: [f64; 3]) -> [f64; 2] {
+        // Adapted from "Exact Equations for Fuller’s Map Projection and Inverse"
+        // https://utppublishing.com/doi/pdf/10.3138/carto.43.1.67
+        // "Transformation Equations with Spherical Linear Interpolation"
+        
+        let d_0 = mat::det_3([point, self.points[0], self.points[1]]);
+        let d_1 = mat::det_3([point, self.points[1], self.points[2]]);
+        let d_2 = mat::det_3([point, self.points[2], self.points[0]]);
+        let c = vec::dot(self.points[0], self.points[1]);
+        let a_0 = (2.0 * (d_0 * c) / (d_0 * c + d_1 + d_2)).atan();
+        let a_1 = (2.0 * (d_1 * c) / (d_1 * c + d_2 + d_0)).atan();
+        let a_2 = (2.0 * (d_2 * c) / (d_2 * c + d_0 + d_1)).atan();
+        let u = (self.angle + 2.0 * a_2 - a_0 - a_1) / (3.0 * self.angle);
+        let v = (self.angle + 2.0 * a_0 - a_1 - a_2) / (3.0 * self.angle);
+        [u, v]
+    }
+    
+    type Transform = Transform<Self>;
+    fn transform(self, offset: [f64; 2], linear: [[f64; 2]; 2]) -> Transform<Self> {
+        Transform::new(self, offset, linear)
+    }
+}
+
+#[test]
+fn test_roundtrip() {
+    use crate::basetri::BaseTriSphere;
+    use crate::prelude::*;
+    const N: usize = 100;
+    for v in 0..N {
+        for u in 0..(N - v) {
+            let u = u as f64 / N as f64;
+            let v = v as f64 / N as f64;
+            let proj = Fuller.inside(BaseTriSphere::Icosa.face(0).side(0));
+            let point = proj.to_sphere([u, v]);
+            let [a_u, a_v] = proj.from_sphere(point);
+            assert!(
+                (a_u - u).abs() < 1e-12 && (a_v - v).abs() < 1e-12,
+                "failed roundtrip: got {:?}, expected {:?}",
+                [a_u, a_v],
+                [u, v]
+            );
+        }
+    }
+}
+
+const SMALL: f64 = 1.0e-6;
+
+/// Determines the real roots of the variety of cubic polynomials which appear in the Fuller projection. If a root has
path: src/proj/fuller.rs
user: login: dzamkov
body: Please write out the polynomial that is being solved (a x³ + b x² + c x + d). This will eliminating any confusing about what the variables means (since there are several different variable naming conventions used for polynomials).

Also do this for the "Solve quadratic" and "Solve depressed cubic" comments below
created_at: 2025-05-27T16:26:50Z
author_association: OWNER
diff_hunk: @@ -129,99 +128,4 @@ pub(crate) mod mat {
         [[d, -b], [-c, a]]
     }
 }
-
-/// Determines the real roots of a cubic polynomial of the form `a x³ + b x² + c x + d`.
-pub fn solve_cubic(a: f64, b: f64, c: f64, d: f64) -> impl Iterator<Item = f64> {
-    // Handle quadratic case explicitly.
-    const SMALL: f64 = 1.0e-6;
-    // TODO: This is pretty hacky. Is there a numerically stable continuation of the cubic
-    // formula for small leading coefficients?
-    if a.abs() < SMALL {
-        let disc = c * c - 4.0 * b * d;
-        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();
-            }
-            iter
-        } else {
-            let mut iter = [0.0, 0.0, 0.0].into_iter();
-            iter.next().unwrap();
-            iter.next().unwrap();
-            iter.next().unwrap();
-            iter
-        }
-    }
-
-    // See https://mathworld.wolfram.com/CubicFormula.html for derivation
-    let b_i_3a = (b / 3.0) / a;
-    let c_i_a = c / a;
-    let d_i_a = d / a;
-
-    // Let `x = t - b / 3 a`. Then the equation becomes `t³ + 3 q t - 2 r = 0` where:
-    let q = c_i_a / 3.0 - b_i_3a * b_i_3a;
-    let r = (b_i_3a * c_i_a - d_i_a) / 2.0 - b_i_3a * b_i_3a * b_i_3a;
-    let disc = q * q * q + r * r;
-    if disc >= 0.0 {
-        // Equation has one real
-        let w = (r + disc.sqrt()).cbrt();
-        let t = w - q / w;
-        let mut iter = [0.0, 0.0, t - b_i_3a].into_iter();
-        iter.next().unwrap();
-        iter.next().unwrap();
-        iter
-    } else {
-        // Equation has three real roots
-        let h = (r * r - disc).sqrt();
-        let s_r = h.cbrt();
-        // TODO: Evaluation of `acos` and `cos` shouldn't actually be necessary here.
-        let s_theta_0 = (r / h).acos() / 3.0;
-        let t_0 = 2.0 * s_r * (s_theta_0 + 2.0 * PI / 3.0).cos();
-        let x_0 = t_0 - b_i_3a;
-        let t_1 = 2.0 * s_r * (s_theta_0 - 2.0 * PI / 3.0).cos();
-        let x_1 = t_1 - b_i_3a;
-        let t_2 = 2.0 * s_r * s_theta_0.cos();
-        let x_2 = t_2 - b_i_3a;
-        [x_0, x_1, x_2].into_iter()
-    }
-}
-
-#[test]
-fn test_solve_cubic() {
-    // Cubic cases
-    assert_similar(solve_cubic(1.0, -1.0, 1.0, -1.0).collect(), [1.0]);
-    assert_similar(solve_cubic(1.0, 0.0, 0.0, -27.0).collect(), [3.0]);
-    assert_similar(solve_cubic(8.0, 8.0, 0.0, -3.0).collect(), [0.5]);
-    assert_similar(
-        solve_cubic(1.0, 4.0, 0.0, -5.0).collect(),
-        [
-            -(5.0f64.sqrt() + 5.0) / 2.0,
-            (5.0f64.sqrt() - 5.0) / 2.0,
-            1.0,
-        ],
-    );
-    assert_similar(
-        solve_cubic(1.0, -2.0, 0.0, 1.0).collect(),
-        [
-            (1.0 - 5.0f64.sqrt()) / 2.0,
-            1.0,
-            (1.0 + 5.0f64.sqrt()) / 2.0,
-        ],
-    );
-
-    // Quadratic cases
-    assert_similar(solve_cubic(0.0, 1.0, 0.0, -1.0).collect(), [-1.0, 1.0]);
-    assert_similar(solve_cubic(0.0, 1.0, 0.0, 1.0).collect(), []);
-
-    // Linear cases
-    assert_similar(solve_cubic(0.0, 0.0, 1.0, -1.0).collect(), [1.0]);
-}
-
-#[cfg(test)]
-fn assert_similar<const N: usize>(a: Vec<f64>, b: [f64; N]) {
-    if a.len() != b.len() || a.iter().zip(b.iter()).any(|(x, y)| (x - y).abs() > 1e-6) {
-        panic!("vectors are not similar, a = {:?}, b = {:?}", a, b);
-    }
-}
+const SMALL: f64 = 1.0e-6;
path: src/math.rs
user: login: dzamkov
body: Remove unused constant
created_at: 2025-05-27T16:27:29Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,274 @@
+//! Contains types related to the [`Fuller`] projection.
+use std::f64::consts::FRAC_PI_3;
+use super::*;
+
+/// The [Fuller](https://en.wikipedia.org/wiki/Dymaxion_map) projection method.
+///
+/// This projection preserves distances exactly along the boundaries of base triangles.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
+pub struct Fuller;
+
+impl Fuller {
+    /// Constructs a [`Projection`] for a specified spherical triangle. The triangle
+    /// must be equilateral, with the angle between any two endpoints equal to `angle`.
+    pub fn triangle(self, angle: f64, points: [[f64; 3]; 3]) -> Triangle {
+        // TODO: Validate triangle when debug assertions are on
+        Triangle { angle, points }
+    }
+}
+
+impl BaseTriProjector for Fuller {
+    type Triangle = Triangle;
+    fn inside(&self, edge: basetri::HalfEdge) -> Triangle {
+        self.triangle(
+            edge.sphere().edge_length(),
+            [
+                edge.start().pos(),
+                edge.next().start().pos(),
+                edge.prev().start().pos(),
+            ],
+        )
+    }
+}
+
+/// A [`Projection`] for an equilateral triangle using the [`Fuller`] projection.
+#[derive(Clone, Copy, Debug)]
+pub struct Triangle {
+    /// The angle, in radians, between any two endpoints of the triangle.
+    angle: f64,
+    
+    /// The positions of the endpoints of the triangle on the sphere.
+    points: [[f64; 3]; 3],
+}
+
+impl Projection for Triangle {
+    fn to_sphere(&self, [u, v]: [f64; 2]) -> [f64; 3] {
+        // Special handling for boundaries to improve accuracy and performance
+        if v <= 0.0 {
+            debug_assert_eq!(v, 0.0);
+            return if u <= 0.0 {
+                debug_assert_eq!(u, 0.0);
+                self.points[0]
+            } else {
+                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);
+            return vec::normalize(vec::add(
+                vec::mul(self.points[0], ((1.0 - v) * self.angle).sin()),
+                vec::mul(self.points[2], (v * self.angle).sin()),
+            ));
+        }
+        
+        // Adapted from "Exact Equations for Fuller’s Map Projection and Inverse"
+        // https://utppublishing.com/doi/pdf/10.3138/carto.43.1.67
+        // "Inverse with Spherical Linear Interpolation"
+        
+        // First, solve the equation:
+        // `tan(x + proj_u) + tan(x) + tan(x + proj_v) + t_alpha = 0`
+        let t_alpha = (self.angle / 2.0).tan();
+        let proj_u = (2.0 * u + v - 1.0) * self.angle;
+        let proj_v = (2.0 * v + u - 1.0) * self.angle;
+        let t_half_proj_u = (proj_u / 2.0).tan();
+        let t_half_proj_v = (proj_v / 2.0).tan();
+        let den_u = 1.0 - t_half_proj_u * t_half_proj_u;
+        let den_v = 1.0 - t_half_proj_v * t_half_proj_v;
+        let s = t_half_proj_u * den_v + t_half_proj_v * den_u;
+        let p = t_half_proj_u * t_half_proj_v;
+        let (t_x, _) = solve_cubic_fuller(
+            4.0 * p,
+            4.0 * t_alpha * p - 4.0 * s,
+            3.0 * den_u * den_v - 8.0 * p - 2.0 * t_alpha * s,
+            t_alpha * den_u * den_v + 2.0 * s,
+        )
+            .into_iter()
+            .map(|t_x| {
+                // There may be up to three solutions to the cubic equation, but only one
+                // corresponds to the correct projection solution. This is the unique solution
+                // which satisfies:
+                //   * `-self.full_angle / 2.0 <= x <= self.full_angle / 2.0`
+                //   * `-self.full_angle / 2.0 <= x + proj_u <= self.full_angle / 2.0`
+                //   * `-self.full_angle / 2.0 <= x + proj_v <= self.full_angle / 2.0`
+                //
+                // or equivalently:
+                //   * `-t_alpha <= tan(x) <= t_alpha`
+                //   * `-t_alpha <= tan(x + proj_u) <= t_alpha`
+                //   * `-t_alpha <= tan(x + proj_v) <= t_alpha`
+                
+                // Assuming there is exactly one such solution, it will be the one with the
+                // smallest `max(|tan(x)|, |tan(x + proj_u)|, |tan(x + proj_v)|)`
+                let t_x_proj_u =
+                    (den_u + 2.0 * t_half_proj_u) / (den_u - 2.0 * t_x * t_half_proj_u);
+                let t_x_proj_v =
+                    (den_v + 2.0 * t_half_proj_v) / (den_v - 2.0 * t_x * t_half_proj_v);
+                (t_x, t_x.abs().max(t_x_proj_u.abs()).max(t_x_proj_v.abs()))
+            })
+            .min_by(|(_, a), (_, b)| a.abs().partial_cmp(&b.abs()).unwrap())
+            .unwrap();
+        
+        let x = t_x.atan();
+        
+        // Use the solution to compute angle offsets of two planes from the second and
+        // third edges of the triangle
+        let a_0 = self.angle / 2.0 - x;
+        let a_1 = a_0 - (2.0 * u + v - 1.0) * self.angle;
+        
+        // Construct planes by offsetting the second and third edges of the triangle
+        let n_0 = vec::cross(
+            mat::apply(
+                [self.points[0], self.points[1]],
+                [(self.angle - a_0).sin(), a_0.sin()],
+            ),
+            vec::sub(self.points[2], self.points[1]),
+        );
+        let n_1 = vec::cross(
+            mat::apply(
+                [self.points[1], self.points[2]],
+                [(self.angle - a_1).sin(), a_1.sin()],
+            ),
+            vec::sub(self.points[0], self.points[2]),
+        );
+        
+        // The projected point is the intersection of the two planes
+        vec::normalize(vec::cross(n_0, n_1))
+    }
+    
+    fn from_sphere(&self, point: [f64; 3]) -> [f64; 2] {
+        // Adapted from "Exact Equations for Fuller’s Map Projection and Inverse"
+        // https://utppublishing.com/doi/pdf/10.3138/carto.43.1.67
+        // "Transformation Equations with Spherical Linear Interpolation"
+        
+        let d_0 = mat::det_3([point, self.points[0], self.points[1]]);
+        let d_1 = mat::det_3([point, self.points[1], self.points[2]]);
+        let d_2 = mat::det_3([point, self.points[2], self.points[0]]);
+        let c = vec::dot(self.points[0], self.points[1]);
+        let a_0 = (2.0 * (d_0 * c) / (d_0 * c + d_1 + d_2)).atan();
+        let a_1 = (2.0 * (d_1 * c) / (d_1 * c + d_2 + d_0)).atan();
+        let a_2 = (2.0 * (d_2 * c) / (d_2 * c + d_0 + d_1)).atan();
+        let u = (self.angle + 2.0 * a_2 - a_0 - a_1) / (3.0 * self.angle);
+        let v = (self.angle + 2.0 * a_0 - a_1 - a_2) / (3.0 * self.angle);
+        [u, v]
+    }
+    
+    type Transform = Transform<Self>;
+    fn transform(self, offset: [f64; 2], linear: [[f64; 2]; 2]) -> Transform<Self> {
+        Transform::new(self, offset, linear)
+    }
+}
+
+#[test]
+fn test_roundtrip() {
+    use crate::basetri::BaseTriSphere;
+    use crate::prelude::*;
+    const N: usize = 100;
+    for v in 0..N {
+        for u in 0..(N - v) {
+            let u = u as f64 / N as f64;
+            let v = v as f64 / N as f64;
+            let proj = Fuller.inside(BaseTriSphere::Icosa.face(0).side(0));
+            let point = proj.to_sphere([u, v]);
+            let [a_u, a_v] = proj.from_sphere(point);
+            assert!(
+                (a_u - u).abs() < 1e-12 && (a_v - v).abs() < 1e-12,
+                "failed roundtrip: got {:?}, expected {:?}",
+                [a_u, a_v],
+                [u, v]
+            );
+        }
+    }
+}
+
+const SMALL: f64 = 1.0e-6;
path: src/proj/fuller.rs
user: login: dzamkov
body: Maybe move this constant inside `solve_cubic_fuller` since it's situation-dependent and not used anywhere else
created_at: 2025-05-27T16:29:23Z
author_association: OWNER

PR comments:
user: login: MorganBennetDev
created_at: 2025-05-24T01:45:54Z
body: This appears to have some commits from the previous PR included. Will fix that tomorrow morning.
author_association: COLLABORATOR
user: login: dzamkov
created_at: 2025-05-24T07:18:47Z
body: Wow! This is actually a pretty impressive performance improvement. Based on my testing, Fuller's `to_sphere` seems to be around 20% faster. Well done!

However, I'm not a fan of all the refactoring done to `solve_cubic`. My main concerns are:
* It's adding a lot of complexity to handle edge cases that never happen: `to_sphere` almost always hits the 3-root case. It hits the quadratic case only in the exact center of the base face, and it never hits the 1-root or 2-root cubic cases.
* It's adding more instances of `x < SMALL` checks, which I consider to be a hack. I had one instance¹ of this pattern in the original code, but only because I couldn't find an alternative. This pattern is bad because it causes a massive increase in numerical error when `x` is just below `SMALL`. Subsphere doesn't yet have any tests of numerical accuracy, but I would expect it to compute values to within ~10 [ULPs](https://en.wikipedia.org/wiki/Unit_in_the_last_place) of the true values. The `SMALL` value we're using corresponds to 4503599627 ULPs around 1.0 (more around smaller numbers). When we truncate `x` to `0` when it is `SMALL`, we're losing out on a lot of information. I can't say for certain how badly this will affect the final accuracy, but I'm guessing it will make it at least a million times worse than the normal.
* Eventually we will have to add support for `f32` precision in addition to `f64`. That will involve macros and/or generics, which are both easier if we keep the math functions compact and self-contained.

¹: Yes, the condition `x < SMALL` appeared two times, but `b.abs() < SMALL` doesn't count because it happens after all roots are computed. Thus, it doesn't affect their accuracy. This check is only used to discard implausibly large roots that wouldn't have been used by `to_sphere` anyway.

So... would it be possible to apply your performance improvements to `solve_cubic` while keeping its general structure intact? Really, the only case you need to worry about is the 3-root cubic case, which accounts for 99%+ of the runtime.
author_association: OWNER
user: login: MorganBennetDev
created_at: 2025-05-24T17:47:55Z
body: Let me look into making the changes less dramatic. I recall seeing performance improvements when I added extra cases, but that was before I took the allocations out of the benchmarks so it may have been noise.
author_association: COLLABORATOR
user: login: MorganBennetDev
created_at: 2025-05-24T19:32:47Z
body: So after some very rigorous testing (calling `println!` in each branch and grepping the output of `cargo test` to count them) I've been able to eliminate some unused/rarely used branches. Included an explanation of why the branches aren't there so no one tries to add them back in the future.
I'm going to do some math on how `SMALL` relates to error in the code as it stands so we can potentially optimize that.
author_association: COLLABORATOR
user: login: MorganBennetDev
created_at: 2025-05-24T23:27:12Z
body: Stripped `solve_cubic` and `Roots` down to strictly the essentials to eke out a little more performance. Had to get rid of some tests for cases that weren't getting hit in actual use.
author_association: COLLABORATOR
user: login: MorganBennetDev
created_at: 2025-05-26T23:13:27Z
body: Will make these changes tomorrow morning.
author_association: COLLABORATOR
user: login: MorganBennetDev
created_at: 2025-05-27T15:37:39Z
body: Requested changes have been implemented.
author_association: COLLABORATOR
user: login: MorganBennetDev
created_at: 2025-05-28T16:03:27Z
body: Comments should be updated to be more clear now.
author_association: COLLABORATOR