use super::{
brep::{BrepError, GemPolyhedron},
plane::GpuFacetPlane,
};
use glam::Vec3;
use indicatrix_formats::asc::AscSchedule;
use std::{f32::consts::PI, fmt};
use tracing::warn;
#[derive(Debug, Clone, PartialEq)]
pub enum CutError {
Brep(BrepError),
OverConstrained {
untouched_count: usize,
plane_count: usize,
untouched: Vec<usize>,
},
}
impl fmt::Display for CutError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Brep(e) => write!(f, "{e}"),
Self::OverConstrained {
untouched_count,
plane_count,
untouched,
} => write!(
f,
"{untouched_count} of {plane_count} planes from the .asc schedule contribute no facet (untouched indices: \
{untouched:?}); the schedule is over-constrained -- most often a near-duplicate tier revision \
left in the source file (e.g. the same facet listed twice at slightly different masts), \
occasionally a crown/pavilion sign misclassification on a zero-angle tier"
),
}
}
}
impl std::error::Error for CutError {}
impl From<BrepError> for CutError {
fn from(e: BrepError) -> Self {
Self::Brep(e)
}
}
#[derive(Debug, Clone, Default)]
pub struct FacetSpec {
pub facet: String,
pub angle: String,
pub index: String,
pub notes: String,
}
pub const STANDARD_ROUND_BRILLIANT_GIRDLE_FACETS: std::ops::Range<usize> = 33..49;
pub struct StandardGemCuts;
impl StandardGemCuts {
#[must_use]
pub fn index_to_azimuth(index: f32, gear_teeth: f32, gear_reference_angle: f32) -> f32 {
2.0 * PI * (index + gear_reference_angle) / gear_teeth
}
#[must_use]
pub fn standard_round_brilliant() -> Vec<GpuFacetPlane> {
let mut planes = Vec::with_capacity(57);
let gear_teeth = 96.0f32;
planes.push(GpuFacetPlane::new(Vec3::new(0.0, 1.0, 0.0), -0.32));
let star_angle = 15.0f32.to_radians();
for &g in &[6.0, 18.0, 30.0, 42.0, 54.0, 66.0, 78.0, 90.0] {
let phi = Self::index_to_azimuth(g, gear_teeth, 0.0);
let n = Vec3::new(
star_angle.sin() * phi.cos(),
star_angle.cos(),
star_angle.sin() * phi.sin(),
);
planes.push(GpuFacetPlane::new(n, -0.45));
}
let crown_main_angle = 34.5f32.to_radians();
for &g in &[0.0, 12.0, 24.0, 36.0, 48.0, 60.0, 72.0, 84.0] {
let phi = Self::index_to_azimuth(g, gear_teeth, 0.0);
let n = Vec3::new(
crown_main_angle.sin() * phi.cos(),
crown_main_angle.cos(),
crown_main_angle.sin() * phi.sin(),
);
planes.push(GpuFacetPlane::new(n, -0.59));
}
let upper_girdle_angle = 41.0f32.to_radians();
for &g in &[
95.0, 1.0, 11.0, 13.0, 23.0, 25.0, 35.0, 37.0, 47.0, 49.0, 59.0, 61.0, 71.0, 73.0,
83.0, 85.0,
] {
let phi = Self::index_to_azimuth(g, gear_teeth, 0.0);
let n = Vec3::new(
upper_girdle_angle.sin() * phi.cos(),
upper_girdle_angle.cos(),
upper_girdle_angle.sin() * phi.sin(),
);
planes.push(GpuFacetPlane::new(n, -0.67));
}
for i in 0..16 {
let phi = 2.0 * PI * (i as f32) / 16.0;
let n = Vec3::new(phi.cos(), 0.0, phi.sin());
planes.push(GpuFacetPlane::new(n, -1.0));
}
let pav_main_angle = 41.0f32.to_radians();
for &g in &[0.0, 12.0, 24.0, 36.0, 48.0, 60.0, 72.0, 84.0] {
let phi = Self::index_to_azimuth(g, gear_teeth, 0.0);
let n = Vec3::new(
pav_main_angle.sin() * phi.cos(),
-pav_main_angle.cos(),
pav_main_angle.sin() * phi.sin(),
);
planes.push(GpuFacetPlane::new(n, -0.67));
}
let lower_girdle_angle = 42.5f32.to_radians();
for &g in &[
95.0, 1.0, 11.0, 13.0, 23.0, 25.0, 35.0, 37.0, 47.0, 49.0, 59.0, 61.0, 71.0, 73.0,
83.0, 85.0,
] {
let phi = Self::index_to_azimuth(g, gear_teeth, 0.0);
let n = Vec3::new(
lower_girdle_angle.sin() * phi.cos(),
-lower_girdle_angle.cos(),
lower_girdle_angle.sin() * phi.sin(),
);
planes.push(GpuFacetPlane::new(n, -0.68));
}
planes.push(GpuFacetPlane::new(Vec3::new(0.0, -1.0, 0.0), -0.88));
planes
}
#[must_use]
pub fn emerald_cut() -> Vec<GpuFacetPlane> {
const TABLE_Y: f32 = 0.30; const GIRDLE_HALF_BAND: f32 = 0.03; const CROWN_CREASE_Y: f32 = 0.15; const PAVILION_CREASE_MAG: f32 = 0.35; const GIRDLE_R_Z: f32 = 0.80;
const GIRDLE_R_X: f32 = 1.10;
const GIRDLE_R_DIAG: f32 = 1.00;
const PAVILION_KEEL_Y: f32 = -0.86;
let diag = std::f32::consts::FRAC_1_SQRT_2;
let mut planes = Vec::new();
planes.push(GpuFacetPlane::new(Vec3::new(0.0, 1.0, 0.0), -TABLE_Y));
let a1 = 35.0f32.to_radians(); let a2 = 45.0f32.to_radians(); push_crown_tiers(
&mut planes,
a1,
a2,
GIRDLE_R_Z,
GIRDLE_R_X,
GIRDLE_HALF_BAND,
CROWN_CREASE_Y,
);
let ac = 40.0f32.to_radians();
push_corner_facets(&mut planes, ac, GIRDLE_R_DIAG, GIRDLE_HALF_BAND, diag, 1.0);
push_girdle_facets(&mut planes, GIRDLE_R_Z, GIRDLE_R_X, GIRDLE_R_DIAG, diag);
let p1 = 53.0f32.to_radians(); let p2 = 43.0f32.to_radians(); push_pavilion_tiers(
&mut planes,
p1,
p2,
GIRDLE_R_Z,
GIRDLE_R_X,
GIRDLE_HALF_BAND,
PAVILION_CREASE_MAG,
);
let pc = 48.0f32.to_radians();
push_corner_facets(&mut planes, pc, GIRDLE_R_DIAG, GIRDLE_HALF_BAND, diag, -1.0);
planes.push(GpuFacetPlane::new(
Vec3::new(0.0, -1.0, 0.0),
PAVILION_KEEL_Y,
));
planes
}
fn classify_is_crown(item: &FacetSpec, angle_deg: f32, tier_idx: usize, total: usize) -> bool {
let index_val = item.index.trim();
if index_val.eq_ignore_ascii_case("table") {
return true;
}
if index_val.eq_ignore_ascii_case("culet") {
return false;
}
let facet = item.facet.trim();
let mut chars = facet.chars();
if let Some(first) = chars.next() {
let rest = chars.as_str();
let rest_is_all_digits = !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit());
match first.to_ascii_lowercase() {
'p' if rest_is_all_digits => return false,
'c' if rest_is_all_digits || rest.is_empty() => return true,
_ => {}
}
}
if facet.eq_ignore_ascii_case("t") && angle_deg.abs() < 0.5 {
return true;
}
if item.notes.to_lowercase().contains("crown") {
return true;
}
tier_idx > total / 2
}
fn parse_angle_deg(s: &str) -> Option<f32> {
let trimmed = s.trim();
let bytes = trimmed.as_bytes();
let mut end = 0;
if end < bytes.len() && (bytes[end] == b'+' || bytes[end] == b'-') {
end += 1;
}
let mut saw_digit = false;
let mut saw_dot = false;
while end < bytes.len() {
match bytes[end] {
b'0'..=b'9' => {
saw_digit = true;
end += 1;
}
b'.' if !saw_dot => {
saw_dot = true;
end += 1;
}
_ => break,
}
}
if !saw_digit {
return None;
}
trimmed[..end].parse::<f32>().ok()
}
fn parse_girdle_facet_count(index_val: &str) -> Option<u32> {
let trimmed = index_val.trim();
if !trimmed.to_lowercase().contains("girdle facet") {
return None;
}
trimmed.split_whitespace().next()?.parse::<u32>().ok()
}
pub fn from_database_angles(angles: &[FacetSpec], gear_teeth: u32) -> Vec<GpuFacetPlane> {
const MAX_UNPARSEABLE_FRACTION: f32 = 0.1;
if angles.is_empty() {
return Self::standard_round_brilliant();
}
let gear_teeth_f = if gear_teeth > 0 {
gear_teeth as f32
} else {
96.0
};
let unparseable = angles
.iter()
.filter(|item| Self::parse_angle_deg(&item.angle).is_none())
.count();
if unparseable as f32 > angles.len() as f32 * MAX_UNPARSEABLE_FRACTION {
warn!(
"from_database_angles: {}/{} angle values are unparseable; refusing to fabricate a solid, falling back to standard_round_brilliant()",
unparseable,
angles.len()
);
return Self::standard_round_brilliant();
}
let mut planes = Vec::new();
for (tier_idx, item) in angles.iter().enumerate() {
let Some(angle_deg) = Self::parse_angle_deg(&item.angle) else {
warn!(
"from_database_angles: skipping facet '{}' with unparseable angle '{}'",
item.facet, item.angle
);
continue;
};
let theta = angle_deg.to_radians();
let is_crown = Self::classify_is_crown(item, angle_deg, tier_idx, angles.len());
let offset = if angle_deg >= 88.0 {
-1.0
} else if is_crown {
if angle_deg < 5.0 {
-0.32 } else {
-0.04f32.mul_add(
-(1.0 - theta.sin()),
0.03f32.mul_add(theta.cos(), 1.0 * theta.sin()),
)
}
} else if angle_deg < 5.0 {
-0.88 } else {
-0.02f32.mul_add(theta.cos(), 1.0 * theta.sin())
};
let indices: Vec<f32> = Self::parse_girdle_facet_count(&item.index).map_or_else(
|| {
item.index
.split([',', '-', ' ', ';'])
.filter_map(|s| s.trim().parse::<f32>().ok())
.collect()
},
|n| {
(0..n)
.map(|i| (i as f32) * gear_teeth_f / (n as f32))
.collect()
},
);
if indices.is_empty() {
let n = if is_crown {
Vec3::new(0.0, theta.cos(), theta.sin())
} else {
Vec3::new(0.0, -theta.cos(), theta.sin())
};
planes.push(GpuFacetPlane::new(n, offset));
} else {
for g in indices {
let phi = Self::index_to_azimuth(g, gear_teeth_f, 0.0);
let n = if is_crown {
Vec3::new(
theta.sin() * phi.cos(),
theta.cos(),
theta.sin() * phi.sin(),
)
} else {
Vec3::new(
theta.sin() * phi.cos(),
-theta.cos(),
theta.sin() * phi.sin(),
)
};
planes.push(GpuFacetPlane::new(n, offset));
}
}
}
if planes.len() < 4 {
return Self::standard_round_brilliant();
}
planes
}
#[must_use]
pub fn reconstruct_validated_brep(angles: &[FacetSpec], gear_teeth: u32) -> GemPolyhedron {
let planes = Self::from_database_angles(angles, gear_teeth);
let plane_count = planes.len();
match GemPolyhedron::from_planes(planes) {
Ok(hull) => {
let untouched = hull.untouched_planes();
if untouched.is_empty() {
return hull;
}
warn!(
"reconstruct_validated_brep: {} of {plane_count} reconstructed planes contribute no facet \
(over-constrained/redundant schedule, plane indices {untouched:?}); falling back to \
standard_round_brilliant()",
untouched.len()
);
}
Err(e) => {
warn!(
"reconstruct_validated_brep: B-Rep reconstruction failed ({e}); falling back to \
standard_round_brilliant()"
);
}
}
GemPolyhedron::from_planes(Self::standard_round_brilliant()).expect(
"standard_round_brilliant() must always reconstruct into a valid, finite polyhedron",
)
}
#[must_use]
pub fn from_asc_schedule(schedule: &AscSchedule) -> Vec<GpuFacetPlane> {
let gear_teeth = (schedule.gear_teeth_abs().max(1)) as f32;
let gear_reference_angle = schedule.gear_reference_angle as f32;
let mut planes = Vec::with_capacity(schedule.facet_plane_count());
let mut last_side_is_crown = true;
for tier in &schedule.tiers {
let is_crown = if tier.angle_deg == 0.0 {
if tier.angle_deg.is_sign_negative() {
false
} else {
last_side_is_crown
}
} else {
tier.angle_deg > 0.0
};
last_side_is_crown = is_crown;
let theta = (tier.angle_deg.abs() as f32).to_radians();
let sin_theta = theta.sin();
let cos_theta = theta.cos();
let d = -(tier.mast.abs() as f32);
if tier.indices.is_empty() {
let n = if is_crown {
Vec3::new(0.0, cos_theta, sin_theta)
} else {
Vec3::new(0.0, -cos_theta, sin_theta)
};
planes.push(GpuFacetPlane::new(n, d));
continue;
}
for &idx in &tier.indices {
let phi = Self::index_to_azimuth(idx as f32, gear_teeth, gear_reference_angle);
let (sin_phi, cos_phi) = (phi.sin(), phi.cos());
let n = if is_crown {
Vec3::new(sin_theta * cos_phi, cos_theta, sin_theta * sin_phi)
} else {
Vec3::new(sin_theta * cos_phi, -cos_theta, sin_theta * sin_phi)
};
planes.push(GpuFacetPlane::new(n, d));
}
}
dedup_planes(planes)
}
pub fn reconstruct_validated_brep_from_asc(
schedule: &AscSchedule,
) -> Result<GemPolyhedron, CutError> {
let planes = Self::from_asc_schedule(schedule);
let plane_count = planes.len();
let hull = GemPolyhedron::from_planes(planes)?;
let untouched = hull.untouched_planes();
if untouched.is_empty() {
Ok(hull)
} else {
Err(CutError::OverConstrained {
untouched_count: untouched.len(),
plane_count,
untouched,
})
}
}
}
fn tier_d_at_girdle(angle: f32, r: f32, half_band: f32) -> f32 {
-angle.sin().mul_add(r, angle.cos() * half_band)
}
fn tier_d_at_crease(
girdle_angle: f32,
crease_angle: f32,
r: f32,
crease_mag: f32,
half_band: f32,
) -> f32 {
let z_c = r - girdle_angle.cos() * (crease_mag - half_band) / girdle_angle.sin();
-crease_angle
.sin()
.mul_add(z_c, crease_angle.cos() * crease_mag)
}
fn corner_d(angle: f32, r_diag: f32, half_band: f32) -> f32 {
-angle.cos().mul_add(half_band, angle.sin() * r_diag)
}
fn push_crown_tiers(
planes: &mut Vec<GpuFacetPlane>,
a1: f32,
a2: f32,
r_z: f32,
r_x: f32,
half_band: f32,
crease_y: f32,
) {
let d_a1_z = tier_d_at_crease(a2, a1, r_z, crease_y, half_band);
let d_a1_x = tier_d_at_crease(a2, a1, r_x, crease_y, half_band);
let d_a2_z = tier_d_at_girdle(a2, r_z, half_band);
let d_a2_x = tier_d_at_girdle(a2, r_x, half_band);
planes.push(GpuFacetPlane::new(
Vec3::new(0.0, a1.cos(), a1.sin()),
d_a1_z,
));
planes.push(GpuFacetPlane::new(
Vec3::new(0.0, a1.cos(), -a1.sin()),
d_a1_z,
));
planes.push(GpuFacetPlane::new(
Vec3::new(a1.sin(), a1.cos(), 0.0),
d_a1_x,
));
planes.push(GpuFacetPlane::new(
Vec3::new(-a1.sin(), a1.cos(), 0.0),
d_a1_x,
));
planes.push(GpuFacetPlane::new(
Vec3::new(0.0, a2.cos(), a2.sin()),
d_a2_z,
));
planes.push(GpuFacetPlane::new(
Vec3::new(0.0, a2.cos(), -a2.sin()),
d_a2_z,
));
planes.push(GpuFacetPlane::new(
Vec3::new(a2.sin(), a2.cos(), 0.0),
d_a2_x,
));
planes.push(GpuFacetPlane::new(
Vec3::new(-a2.sin(), a2.cos(), 0.0),
d_a2_x,
));
}
fn push_pavilion_tiers(
planes: &mut Vec<GpuFacetPlane>,
p1: f32,
p2: f32,
r_z: f32,
r_x: f32,
half_band: f32,
crease_mag: f32,
) {
let d_p1_z = tier_d_at_girdle(p1, r_z, half_band);
let d_p1_x = tier_d_at_girdle(p1, r_x, half_band);
let d_p2_z = tier_d_at_crease(p1, p2, r_z, crease_mag, half_band);
let d_p2_x = tier_d_at_crease(p1, p2, r_x, crease_mag, half_band);
planes.push(GpuFacetPlane::new(
Vec3::new(0.0, -p1.cos(), p1.sin()),
d_p1_z,
));
planes.push(GpuFacetPlane::new(
Vec3::new(0.0, -p1.cos(), -p1.sin()),
d_p1_z,
));
planes.push(GpuFacetPlane::new(
Vec3::new(p1.sin(), -p1.cos(), 0.0),
d_p1_x,
));
planes.push(GpuFacetPlane::new(
Vec3::new(-p1.sin(), -p1.cos(), 0.0),
d_p1_x,
));
planes.push(GpuFacetPlane::new(
Vec3::new(0.0, -p2.cos(), p2.sin()),
d_p2_z,
));
planes.push(GpuFacetPlane::new(
Vec3::new(0.0, -p2.cos(), -p2.sin()),
d_p2_z,
));
planes.push(GpuFacetPlane::new(
Vec3::new(p2.sin(), -p2.cos(), 0.0),
d_p2_x,
));
planes.push(GpuFacetPlane::new(
Vec3::new(-p2.sin(), -p2.cos(), 0.0),
d_p2_x,
));
}
fn push_corner_facets(
planes: &mut Vec<GpuFacetPlane>,
angle: f32,
r_diag: f32,
half_band: f32,
diag: f32,
y_sign: f32,
) {
let d = corner_d(angle, r_diag, half_band);
let y = y_sign * angle.cos();
let s = angle.sin() * diag;
planes.push(GpuFacetPlane::new(Vec3::new(s, y, s), d));
planes.push(GpuFacetPlane::new(Vec3::new(-s, y, s), d));
planes.push(GpuFacetPlane::new(Vec3::new(s, y, -s), d));
planes.push(GpuFacetPlane::new(Vec3::new(-s, y, -s), d));
}
fn push_girdle_facets(planes: &mut Vec<GpuFacetPlane>, r_z: f32, r_x: f32, r_diag: f32, diag: f32) {
planes.push(GpuFacetPlane::new(Vec3::new(0.0, 0.0, 1.0), -r_z));
planes.push(GpuFacetPlane::new(Vec3::new(0.0, 0.0, -1.0), -r_z));
planes.push(GpuFacetPlane::new(Vec3::new(1.0, 0.0, 0.0), -r_x));
planes.push(GpuFacetPlane::new(Vec3::new(-1.0, 0.0, 0.0), -r_x));
planes.push(GpuFacetPlane::new(Vec3::new(diag, 0.0, diag), -r_diag));
planes.push(GpuFacetPlane::new(Vec3::new(-diag, 0.0, diag), -r_diag));
planes.push(GpuFacetPlane::new(Vec3::new(diag, 0.0, -diag), -r_diag));
planes.push(GpuFacetPlane::new(Vec3::new(-diag, 0.0, -diag), -r_diag));
}
fn dedup_planes(planes: Vec<GpuFacetPlane>) -> Vec<GpuFacetPlane> {
const QUANT: f32 = 1.0 / 2048.0;
const OFFSET_EPSILON: f32 = QUANT;
const NORMAL_DOT_EPSILON: f32 = 1.0 - QUANT;
let mut kept: Vec<GpuFacetPlane> = Vec::with_capacity(planes.len());
for plane in planes {
let is_duplicate = kept.iter().any(|k| {
let dot = k.normal[0].mul_add(
plane.normal[0],
k.normal[1].mul_add(plane.normal[1], k.normal[2] * plane.normal[2]),
);
dot >= NORMAL_DOT_EPSILON && (k.d - plane.d).abs() <= OFFSET_EPSILON
});
if !is_duplicate {
kept.push(plane);
}
}
kept
}
#[cfg(test)]
mod tests {
use super::*;
use indicatrix_formats::asc::AscTier;
#[test]
fn index_to_azimuth_zero_reference_angle_is_bit_for_bit_identical_to_the_plain_formula() {
for &gear_teeth in &[16.0f32, 32.0, 64.0, 80.0, 96.0, 120.0] {
for idx in 0..gear_teeth as i32 {
let index = idx as f32;
let old = 2.0 * PI * index / gear_teeth;
let new = StandardGemCuts::index_to_azimuth(index, gear_teeth, 0.0);
assert_eq!(
old.to_bits(),
new.to_bits(),
"index {index}, gear {gear_teeth}: old={old}, new={new}"
);
}
}
}
#[test]
fn index_to_azimuth_reference_angle_is_additive_on_the_index() {
let gear_teeth = 96.0f32;
for &(index, reference_angle) in &[(0.0, 48.0), (5.0, 20.0), (84.0, -6.0)] {
let via_reference_angle =
StandardGemCuts::index_to_azimuth(index, gear_teeth, reference_angle);
let via_shifted_index =
StandardGemCuts::index_to_azimuth(index + reference_angle, gear_teeth, 0.0);
assert!(
(via_reference_angle - via_shifted_index).abs() < 1e-6,
"index {index}, reference angle {reference_angle}"
);
}
}
#[test]
fn from_asc_schedule_zero_reference_angle_reproduces_the_previous_mapping_bit_for_bit() {
let gear_teeth = 96.0f32;
let mast = 0.6f64;
let indices = vec![3.0, 17.0, 55.0, 90.0];
let schedule = AscSchedule {
gear_teeth: 96,
gear_reference_angle: 0.0,
symmetry_order: 1,
refractive_index: 1.0,
tiers: vec![AscTier {
angle_deg: -41.0,
mast,
indices: indices.clone(),
..Default::default()
}],
..Default::default()
};
let planes = StandardGemCuts::from_asc_schedule(&schedule);
assert_eq!(planes.len(), indices.len());
let theta = 41.0f32.to_radians();
let d = -(mast.abs() as f32);
for (&idx, plane) in indices.iter().zip(&planes) {
let phi = 2.0 * PI * (idx as f32) / gear_teeth; let expected = GpuFacetPlane::new(
Vec3::new(
theta.sin() * phi.cos(),
-theta.cos(),
theta.sin() * phi.sin(),
),
d,
);
assert_eq!(*plane, expected, "index {idx}");
}
}
#[test]
fn from_asc_schedule_asymmetric_indices_map_to_expected_azimuths_with_reference_angle() {
let gear_teeth = 96.0f32;
let reference_angle = 48.0f64; let mast = 0.6f64;
let indices = vec![5.0, 20.0, 47.0];
let schedule = AscSchedule {
gear_teeth: 96,
gear_reference_angle: reference_angle,
symmetry_order: 1,
refractive_index: 1.0,
tiers: vec![AscTier {
angle_deg: 40.0,
mast,
indices: indices.clone(),
..Default::default()
}],
..Default::default()
};
let planes = StandardGemCuts::from_asc_schedule(&schedule);
assert_eq!(planes.len(), indices.len());
let theta = 40.0f32.to_radians();
for (&idx, plane) in indices.iter().zip(&planes) {
let phi = 2.0 * PI * (idx as f32 + reference_angle as f32) / gear_teeth;
let expected = [
theta.sin() * phi.cos(),
theta.cos(),
theta.sin() * phi.sin(),
];
for (component, &value) in plane.normal.iter().zip(expected.iter()) {
assert!(
(component - value).abs() < 1e-5,
"index {idx}: got {component}, expected {value}"
);
}
}
}
#[test]
fn from_asc_schedule_symmetric_design_is_globally_rotated_by_reference_angle() {
let gear_teeth = 96.0f32;
let mast = 0.59f64;
let indices = vec![0.0, 12.0, 24.0, 36.0, 48.0, 60.0, 72.0, 84.0];
let reference_angle = 6.0f64;
let base = AscSchedule {
gear_teeth: 96,
gear_reference_angle: 0.0,
symmetry_order: 8,
refractive_index: 1.0,
tiers: vec![AscTier {
angle_deg: 34.5,
mast,
indices: indices.clone(),
..Default::default()
}],
..Default::default()
};
let mut rotated = base.clone();
rotated.gear_reference_angle = reference_angle;
let planes_base = StandardGemCuts::from_asc_schedule(&base);
let planes_rotated = StandardGemCuts::from_asc_schedule(&rotated);
assert_eq!(planes_base.len(), planes_rotated.len());
assert_eq!(planes_base.len(), indices.len());
let delta = 2.0 * PI * reference_angle as f32 / gear_teeth;
let (sin_delta, cos_delta) = (delta.sin(), delta.cos());
for base_plane in &planes_base {
let n = base_plane.normal;
let rotated_normal = [
n[0].mul_add(cos_delta, -(n[2] * sin_delta)),
n[1],
n[0].mul_add(sin_delta, n[2] * cos_delta),
];
let matches_one = planes_rotated.iter().any(|p| {
(p.normal[0] - rotated_normal[0]).abs() < 1e-5
&& (p.normal[1] - rotated_normal[1]).abs() < 1e-5
&& (p.normal[2] - rotated_normal[2]).abs() < 1e-5
&& (p.d - base_plane.d).abs() < 1e-5
});
assert!(
matches_one,
"base normal {n:?} rotated by {delta} rad must reappear in the \
reference-angle-shifted schedule's plane set"
);
}
}
#[test]
fn dedup_merges_planes_straddling_a_quantization_bin_edge() {
const QUANT: f32 = 1.0 / 2048.0;
let bin_edge = 0.5 * QUANT;
let normal = Vec3::new(0.0, 0.0, 1.0);
let planes = vec![
GpuFacetPlane::new(normal, bin_edge - 1e-7),
GpuFacetPlane::new(normal, bin_edge + 1e-7),
];
let deduped = dedup_planes(planes);
assert_eq!(
deduped.len(),
1,
"two near-identical offsets straddling a bin edge must merge into one plane"
);
}
#[test]
fn dedup_keeps_the_first_occurrence() {
const QUANT: f32 = 1.0 / 2048.0;
let bin_edge = 0.5 * QUANT;
let normal = Vec3::new(0.0, 0.0, 1.0);
let first = GpuFacetPlane::new(normal, bin_edge - 1e-7);
let second = GpuFacetPlane::new(normal, bin_edge + 1e-7);
let deduped = dedup_planes(vec![first, second]);
assert_eq!(deduped, vec![first]);
}
#[test]
fn dedup_keeps_genuinely_distinct_planes() {
const QUANT: f32 = 1.0 / 2048.0;
let normal = Vec3::new(0.0, 0.0, 1.0);
let planes = vec![
GpuFacetPlane::new(normal, 0.0),
GpuFacetPlane::new(normal, 10.0 * QUANT),
];
let deduped = dedup_planes(planes.clone());
assert_eq!(
deduped, planes,
"genuinely distinct planes must both be kept"
);
}
#[test]
fn dedup_keeps_planes_with_different_normals_even_at_the_same_offset() {
let planes = vec![
GpuFacetPlane::new(Vec3::new(0.0, 0.0, 1.0), 1.0),
GpuFacetPlane::new(Vec3::new(1.0, 0.0, 0.0), 1.0),
];
let deduped = dedup_planes(planes.clone());
assert_eq!(deduped, planes);
}
}