use std::num::NonZeroUsize;
use super::{
evaluate::{MetricsWanted, evaluate_pose, placeholder_metrics},
finished::FinishedStone,
prepared::PreparedEval,
profile::{EVALUATIONS_PER_AXIS, HALF_AXIS_PITCH_DEG},
};
use crate::optics::{materials::GemMaterial, raytracer::EnvironmentSource};
pub(super) const MAX_SWEEP_THREADS: usize = 16;
const GATE_CHUNK_PER_THREAD: usize = 4;
pub(super) type AxisCurves = [[f32; EVALUATIONS_PER_AXIS]; 3];
pub(super) fn resolve_threads(max_threads: Option<usize>) -> usize {
if cfg!(target_arch = "wasm32") {
return 1;
}
let available = std::thread::available_parallelism().map_or(1, NonZeroUsize::get);
max_threads.unwrap_or(available).clamp(1, MAX_SWEEP_THREADS)
}
fn consumed_at(prepared: Option<&PreparedEval<'_>>, cam_yaw: f32, cam_pitch: f32) -> [f32; 3] {
let metrics = prepared.map_or_else(placeholder_metrics, |prepared| {
evaluate_pose(
prepared,
cam_yaw,
cam_pitch,
MetricsWanted::CONSUMED_ONLY,
None,
)
});
[
metrics.brilliance_pct,
metrics.extinction_pct,
metrics.windowing_pct,
]
}
pub(super) fn run_poses(
prepared: Option<&PreparedEval<'_>>,
poses: &[(f32, f32)],
threads: usize,
) -> Vec<[f32; 3]> {
let threads = threads.clamp(1, poses.len().max(1));
if threads == 1 {
return poses
.iter()
.map(|&(yaw, pitch)| consumed_at(prepared, yaw, pitch))
.collect();
}
let per_thread: Vec<Vec<[f32; 3]>> = std::thread::scope(|scope| {
let mut handles = Vec::with_capacity(threads);
for first in 0..threads {
handles.push(scope.spawn(move || {
poses
.iter()
.skip(first)
.step_by(threads)
.map(|&(yaw, pitch)| consumed_at(prepared, yaw, pitch))
.collect::<Vec<_>>()
}));
}
handles
.into_iter()
.map(|handle| {
handle
.join()
.unwrap_or_else(|panic| std::panic::resume_unwind(panic))
})
.collect()
});
let mut out = vec![[0.0f32; 3]; poses.len()];
for (first, values) in per_thread.iter().enumerate() {
for (k, value) in values.iter().enumerate() {
out[first + k * threads] = *value;
}
}
out
}
fn axis_poses(positive_azimuth_deg: f32) -> [(f32, f32); EVALUATIONS_PER_AXIS] {
let positive = positive_azimuth_deg.to_radians();
let negative = (positive_azimuth_deg + 180.0).to_radians();
let mut poses = [(positive, 90.0f32.to_radians()); EVALUATIONS_PER_AXIS];
for (index, °) in HALF_AXIS_PITCH_DEG.iter().enumerate() {
let pitch = deg.to_radians();
poses[1 + index] = (positive, pitch);
poses[1 + HALF_AXIS_PITCH_DEG.len() + index] = (negative, pitch);
}
poses
}
const fn tilt_slot(job: usize) -> usize {
let half = HALF_AXIS_PITCH_DEG.len();
if job == 0 {
half
} else if job <= half {
2 * half - (job - 1)
} else {
job - 1 - half
}
}
fn assemble_axis(triples: &[[f32; 3]]) -> AxisCurves {
let mut curves = [[0.0f32; EVALUATIONS_PER_AXIS]; 3];
for (job, triple) in triples.iter().enumerate() {
let slot = tilt_slot(job);
for (curve, &value) in curves.iter_mut().zip(triple) {
curve[slot] = value;
}
}
curves
}
fn evaluate_axes(
prepared: Option<&PreparedEval<'_>>,
azimuths_deg: &[f32],
threads: usize,
chunk: usize,
gate: &mut dyn FnMut(usize) -> bool,
) -> Option<Vec<AxisCurves>> {
let poses: Vec<(f32, f32)> = azimuths_deg
.iter()
.flat_map(|&azimuth_deg| axis_poses(azimuth_deg))
.collect();
let total = poses.len();
let chunk = chunk.clamp(1, total.max(1));
let mut triples = vec![[0.0f32; 3]; total];
let mut start = 0;
while start < total {
let end = (start + chunk).min(total);
for job in start..end {
if !gate(job / EVALUATIONS_PER_AXIS) {
return None;
}
}
triples[start..end].copy_from_slice(&run_poses(prepared, &poses[start..end], threads));
start = end;
}
Some(
triples
.as_chunks::<EVALUATIONS_PER_AXIS>()
.0
.iter()
.map(|chunk| assemble_axis(chunk))
.collect(),
)
}
pub(super) fn sweep_axes(
stone: FinishedStone<'_>,
material: &GemMaterial,
environment: EnvironmentSource<'_>,
azimuths_deg: &[f32],
max_threads: Option<usize>,
) -> Vec<AxisCurves> {
let prepared = PreparedEval::new_finished(stone.geom, stone.finishes, material, environment);
evaluate_axes(
prepared.as_ref(),
azimuths_deg,
resolve_threads(max_threads),
usize::MAX,
&mut |_| true,
)
.unwrap_or_default()
}
pub(super) fn sweep_axes_gated(
stone: FinishedStone<'_>,
material: &GemMaterial,
environment: EnvironmentSource<'_>,
azimuths_deg: &[f32],
max_threads: Option<usize>,
gate: &mut dyn FnMut(usize) -> bool,
) -> Option<Vec<AxisCurves>> {
let prepared = PreparedEval::new_finished(stone.geom, stone.finishes, material, environment);
let threads = resolve_threads(max_threads);
let chunk = if threads > 1 {
threads * GATE_CHUNK_PER_THREAD
} else {
1
};
evaluate_axes(prepared.as_ref(), azimuths_deg, threads, chunk, gate)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geometry::tool::StoneGeometry;
#[test]
fn every_pose_lands_in_its_own_tilt_slot() {
let mut seen = [false; EVALUATIONS_PER_AXIS];
for job in 0..EVALUATIONS_PER_AXIS {
let slot = tilt_slot(job);
assert!(!seen[slot], "slot {slot} taken twice");
seen[slot] = true;
}
assert!(seen.iter().all(|&taken| taken));
assert_eq!(tilt_slot(0), 90);
assert_eq!(tilt_slot(1), 180);
assert_eq!(tilt_slot(91), 0);
assert_eq!(tilt_slot(90), 91);
assert_eq!(tilt_slot(180), 89);
}
#[test]
fn the_threaded_batch_is_the_serial_batch() {
use crate::{geometry::cuts::StandardGemCuts, optics::raytracer::LightingPreset};
let planes = StandardGemCuts::standard_round_brilliant();
let material = GemMaterial::diamond();
let environment = LightingPreset::RingLights.studio(1.0, 0.85, 0.95);
let prepared =
PreparedEval::new(StoneGeometry::planes_only(&planes), &material, environment);
let poses: Vec<(f32, f32)> = (0..23u8)
.map(|i| (f32::from(i) * 0.3, f32::from(i) * 0.06))
.collect();
let serial = run_poses(prepared.as_ref(), &poses, 1);
for threads in [2, 3, 7, 64] {
let threaded = run_poses(prepared.as_ref(), &poses, threads);
let bits = |rows: &[[f32; 3]]| -> Vec<u32> {
rows.iter().flatten().map(|v| v.to_bits()).collect()
};
assert_eq!(bits(&serial), bits(&threaded), "threads = {threads}");
}
}
}