indicatrix 0.9.0

A physically-based spectral gemstone renderer that turns GemCAD-style cutting instructions into rendered output.
Documentation
//! The Tilt Performance dialog's whole sweep: the full-axis profile
//! ([`super::profile::evaluate_full_axis_profile_at_azimuth`]) at every [`PROFILE_AZIMUTHS_DEG`] axis.
//!
//! This is the loop the desktop's `gui::tilt::tilt_profile` used to hold itself; both apps
//! now call it from here. [`evaluate_all_axes_profiles_stepped`] is the same sweep with a
//! progress/cancel hook between the raytrace evaluations, for the browser app's Worker.

use super::{
    finished::FinishedStone,
    pose_batch::{AxisCurves, sweep_axes, sweep_axes_gated},
    profile::{EVALUATIONS_PER_AXIS, PROFILE_AZIMUTHS_DEG},
};
use crate::{
    geometry::{plane::GpuFacetPlane, tool::StoneGeometry},
    optics::{materials::GemMaterial, raytracer::EnvironmentSource},
};

/// One axis's three curves, each `TILT_ANGLES_DEG`-indexed (181 points, -90 to +90 degrees).
#[derive(Debug, Clone, PartialEq)]
pub struct AxisProfile {
    /// Brilliance %.
    pub brilliance: [f32; 181],
    /// Extinction %.
    pub extinction: [f32; 181],
    /// Windowing %.
    pub windowing: [f32; 181],
}

impl From<([f32; 181], [f32; 181], [f32; 181])> for AxisProfile {
    fn from((brilliance, extinction, windowing): ([f32; 181], [f32; 181], [f32; 181])) -> Self {
        Self {
            brilliance,
            extinction,
            windowing,
        }
    }
}

/// Where a running sweep is, reported before each raytrace evaluation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SweepProgress {
    /// The axis being swept, an index into [`PROFILE_AZIMUTHS_DEG`].
    pub axis: usize,
    /// Evaluations finished so far, over all axes.
    pub done: usize,
    /// Evaluations the whole sweep takes ([`total_evaluations`]).
    pub total: usize,
}

impl From<AxisCurves> for AxisProfile {
    fn from([brilliance, extinction, windowing]: AxisCurves) -> Self {
        Self {
            brilliance,
            extinction,
            windowing,
        }
    }
}

/// Raytrace evaluations the whole sweep takes: 181 per axis, four axes (724).
#[must_use]
pub const fn total_evaluations() -> usize {
    PROFILE_AZIMUTHS_DEG.len() * EVALUATIONS_PER_AXIS
}

/// Sweeps every [`PROFILE_AZIMUTHS_DEG`] axis, each a full 181-point +-90 degree profile,
/// in axis order.
///
/// Axis 0 is swept too, rather than reusing the live render's own value: that one covers
/// only the positive half and cannot supply the negative half a full-axis sweep needs.
/// About 724 raytrace evaluations; ~1.4 s on one core, spread over the machine's cores
/// (at most 16) since the sweep became parallel.
#[must_use]
pub fn evaluate_all_axes_profiles(
    planes: &[GpuFacetPlane],
    material: &GemMaterial,
    environment: EnvironmentSource<'_>,
) -> Vec<AxisProfile> {
    evaluate_all_axes_profiles_geom(StoneGeometry::planes_only(planes), material, environment)
}

/// [`evaluate_all_axes_profiles`] for a stone with tools; bit-identical to it when
/// `geom.tools` is empty.
#[must_use]
pub fn evaluate_all_axes_profiles_geom(
    geom: StoneGeometry<'_>,
    material: &GemMaterial,
    environment: EnvironmentSource<'_>,
) -> Vec<AxisProfile> {
    evaluate_all_axes_profiles_geom_threaded(geom, material, environment, None)
}

/// [`evaluate_all_axes_profiles_geom`] with an explicit worker cap.
///
/// All 724 poses share one prepared stone and run as one parallel batch. `max_threads` of
/// `None` uses the machine's parallelism (at most 16); a caller that already parallelises
/// across designs (the batch tilt curves) passes its share, `Some(1)` being the serial
/// sweep on the calling thread. Each pose writes the curve slot it owns, so the curves are
/// bit-identical for any thread count, and the callers' serial f64 means over them are
/// untouched.
#[must_use]
pub fn evaluate_all_axes_profiles_geom_threaded(
    geom: StoneGeometry<'_>,
    material: &GemMaterial,
    environment: EnvironmentSource<'_>,
    max_threads: Option<usize>,
) -> Vec<AxisProfile> {
    evaluate_all_axes_profiles_finished_threaded(
        FinishedStone::polished(geom),
        material,
        environment,
        max_threads,
    )
}

/// [`evaluate_all_axes_profiles_geom_threaded`] for a stone with facet finishes.
///
/// A frosted facet scatters light diffusely, so a frosted design's tilt curves differ from its polished
/// ones (see [`evaluate_full_axis_profile_at_azimuth_finished_threaded`]). Without a frosted
/// facet in `stone.finishes` this is the polished sweep, bit for bit.
///
/// [`evaluate_full_axis_profile_at_azimuth_finished_threaded`]: super::profile::evaluate_full_axis_profile_at_azimuth_finished_threaded
#[must_use]
pub fn evaluate_all_axes_profiles_finished_threaded(
    stone: FinishedStone<'_>,
    material: &GemMaterial,
    environment: EnvironmentSource<'_>,
    max_threads: Option<usize>,
) -> Vec<AxisProfile> {
    sweep_axes(
        stone,
        material,
        environment,
        &PROFILE_AZIMUTHS_DEG,
        max_threads,
    )
    .into_iter()
    .map(AxisProfile::from)
    .collect()
}

/// [`evaluate_all_axes_profiles`] with a hook before every evaluation.
///
/// `step` is called with the sweep's [`SweepProgress`] and can stop the sweep by
/// answering `false`, which returns `None`. When it never does, the curves are
/// bit-identical to [`evaluate_all_axes_profiles`]'s.
#[must_use]
pub fn evaluate_all_axes_profiles_stepped(
    planes: &[GpuFacetPlane],
    material: &GemMaterial,
    environment: EnvironmentSource<'_>,
    step: &mut dyn FnMut(SweepProgress) -> bool,
) -> Option<Vec<AxisProfile>> {
    evaluate_all_axes_profiles_stepped_geom(
        StoneGeometry::planes_only(planes),
        material,
        environment,
        step,
    )
}

/// [`evaluate_all_axes_profiles_stepped`] for a stone with tools; bit-identical to it
/// when `geom.tools` is empty.
#[must_use]
pub fn evaluate_all_axes_profiles_stepped_geom(
    geom: StoneGeometry<'_>,
    material: &GemMaterial,
    environment: EnvironmentSource<'_>,
    step: &mut dyn FnMut(SweepProgress) -> bool,
) -> Option<Vec<AxisProfile>> {
    evaluate_all_axes_profiles_stepped_geom_threaded(geom, material, environment, None, step)
}

/// [`evaluate_all_axes_profiles_stepped_geom`] with an explicit worker cap (see
/// [`evaluate_all_axes_profiles_geom_threaded`]).
///
/// `step` runs on the calling thread only, never concurrently, in evaluation order. On a
/// parallel run it is asked a few evaluations per worker ahead of the work, so a reported
/// `done` leads the finished poses by a few milliseconds; the first `false` stops the
/// sweep before the next batch starts. Serial runs (`Some(1)`, `wasm32`) interleave ask and
/// evaluate exactly as the pre-parallel sweep did.
#[must_use]
pub fn evaluate_all_axes_profiles_stepped_geom_threaded(
    geom: StoneGeometry<'_>,
    material: &GemMaterial,
    environment: EnvironmentSource<'_>,
    max_threads: Option<usize>,
    step: &mut dyn FnMut(SweepProgress) -> bool,
) -> Option<Vec<AxisProfile>> {
    evaluate_all_axes_profiles_stepped_finished_threaded(
        FinishedStone::polished(geom),
        material,
        environment,
        max_threads,
        step,
    )
}

/// [`evaluate_all_axes_profiles_stepped_geom_threaded`] for a stone with facet finishes.
///
/// See [`evaluate_all_axes_profiles_finished_threaded`]. Without a frosted facet in
/// `stone.finishes` this is the polished stepped sweep, bit for bit.
#[must_use]
pub fn evaluate_all_axes_profiles_stepped_finished_threaded(
    stone: FinishedStone<'_>,
    material: &GemMaterial,
    environment: EnvironmentSource<'_>,
    max_threads: Option<usize>,
    step: &mut dyn FnMut(SweepProgress) -> bool,
) -> Option<Vec<AxisProfile>> {
    let total = total_evaluations();
    let mut done = 0usize;
    let axes = sweep_axes_gated(
        stone,
        material,
        environment,
        &PROFILE_AZIMUTHS_DEG,
        max_threads,
        &mut |axis| {
            let go = step(SweepProgress { axis, done, total });
            done += 1;
            go
        },
    )?;
    Some(axes.into_iter().map(AxisProfile::from).collect())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{
        color::metrics::{
            TILT_ANGLES_DEG, evaluate_full_axis_profile_at_azimuth,
            evaluate_full_axis_profile_at_azimuth_stepped, evaluate_gem_optical_metrics,
        },
        geometry::cuts::StandardGemCuts,
        optics::raytracer::LightingPreset,
    };

    /// The light pose the sweep tests run under.
    const fn studio() -> EnvironmentSource<'static> {
        LightingPreset::RingLights.studio(1.0, 0.85, 0.95)
    }

    fn bits(curves: &([f32; 181], [f32; 181], [f32; 181])) -> Vec<u32> {
        curves
            .0
            .iter()
            .chain(&curves.1)
            .chain(&curves.2)
            .map(|v| v.to_bits())
            .collect()
    }

    /// The full-axis function, re-derived from the metric it is built on (tilt `t` looks
    /// from pitch `90 - |t|`, at the axis azimuth for `t >= 0` and its opposite for
    /// `t < 0`), and the stepped variant: bit-identical, one gate call per evaluation.
    #[test]
    fn a_full_axis_profile_is_the_metric_at_each_tilt_pose_and_the_stepped_one_matches() {
        let planes = StandardGemCuts::standard_round_brilliant();
        let material = GemMaterial::diamond();
        let plain = evaluate_full_axis_profile_at_azimuth(&planes, &material, 45.0, studio());
        for &index in &[0usize, 37, 90, 101, 180] {
            let tilt = TILT_ANGLES_DEG[index];
            let azimuth = if tilt >= 0.0 { 45.0f32 } else { 225.0 };
            let m = evaluate_gem_optical_metrics(
                &planes,
                &material,
                azimuth.to_radians(),
                (90.0 - tilt.abs()).to_radians(),
                studio(),
            );
            assert_eq!(plain.0[index].to_bits(), m.brilliance_pct.to_bits());
            assert_eq!(plain.1[index].to_bits(), m.extinction_pct.to_bits());
            assert_eq!(plain.2[index].to_bits(), m.windowing_pct.to_bits());
        }
        let mut gate_calls = 0usize;
        let stepped = evaluate_full_axis_profile_at_azimuth_stepped(
            &planes,
            &material,
            45.0,
            studio(),
            &mut || {
                gate_calls += 1;
                true
            },
        )
        .expect("never stopped");
        assert_eq!(bits(&stepped), bits(&plain));
        assert_eq!(gate_calls, EVALUATIONS_PER_AXIS);
    }

    #[test]
    fn the_stepped_sweep_reports_every_evaluation_and_stops_when_told() {
        let planes = StandardGemCuts::standard_round_brilliant();
        let material = GemMaterial::diamond();
        let mut seen = Vec::new();
        let stopped = evaluate_all_axes_profiles_stepped(&planes, &material, studio(), &mut |p| {
            seen.push(p);
            seen.len() < 190
        });
        assert!(stopped.is_none());
        assert_eq!(seen.len(), 190, "nothing is asked for after the refusal");
        let first = SweepProgress {
            axis: 0,
            done: 0,
            total: 724,
        };
        assert_eq!(seen[0], first);
        assert_eq!(seen[180].axis, 0);
        assert_eq!(
            seen[181].axis, 1,
            "the second axis starts after 181 evaluations"
        );
        assert_eq!(seen[181].done, 181);
        assert_eq!(total_evaluations(), 724);
    }
}