use crate::bridge::{
frame_cache::stone_width::StoneWidthCache,
render_thread::{MaterialOverrides, RenderContext, apply_material_overrides, resolve_material},
};
use indicatrix::{
geometry::{girdle_facet_finishes, plane::GpuFacetPlane},
optics::{
materials::GemMaterial,
raytracer::{FacetFinish, LightingPreset},
},
renderer::env_map::EnvironmentMap,
};
use std::sync::{Arc, Mutex};
#[derive(Clone)]
pub struct SceneSnapshot {
pub yaw: f32,
pub pitch: f32,
pub distance: f32,
pub light_yaw: f32,
pub light_pitch: f32,
pub material: GemMaterial,
pub lighting_preset: LightingPreset,
pub max_bounces: u32,
pub exposure: f32,
pub active_planes: Vec<GpuFacetPlane>,
pub facet_finishes: Vec<FacetFinish>,
pub env_map: Option<Arc<EnvironmentMap>>,
}
impl SceneSnapshot {
#[must_use]
pub fn capture(ctx: &Mutex<RenderContext>) -> Self {
let guard = ctx
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let materials = GemMaterial::all_materials();
let material = resolve_material(&materials, &guard.custom_materials, &guard.material_name);
let material = apply_material_overrides(
material,
&MaterialOverrides {
inclusion_sigma_s: guard.inclusion_sigma_s,
c_axis_override: guard.c_axis_override,
edge_rounding_radius: guard.edge_rounding_radius,
stone_width_mm: guard.stone_width_mm,
},
&guard.active_planes,
&mut StoneWidthCache::new(),
);
let facet_finishes = if guard.girdle_frosted {
girdle_facet_finishes(&guard.active_planes)
} else {
Vec::new()
};
Self {
yaw: guard.yaw,
pitch: guard.pitch,
distance: guard.distance,
light_yaw: guard.light_yaw,
light_pitch: guard.light_pitch,
material,
lighting_preset: guard.lighting_preset,
max_bounces: guard.max_bounces,
exposure: guard.exposure,
active_planes: guard.active_planes.to_vec(),
facet_finishes,
env_map: guard.env_map.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use glam::Vec3;
#[test]
fn capture_carries_the_inclusion_setting_into_the_exported_scene() {
let off = SceneSnapshot::capture(&Mutex::new(RenderContext {
inclusion_sigma_s: 0.0,
..Default::default()
}));
assert_eq!(
off.material.scattering_sigma_s, 0.0,
"the off position must leave the material untouched"
);
let on = SceneSnapshot::capture(&Mutex::new(RenderContext {
inclusion_sigma_s: 1.25,
..Default::default()
}));
assert_eq!(
on.material.scattering_sigma_s, 1.25,
"a dialled-in inclusion amount must reach the exported scene"
);
assert_eq!(
on.material.scattering_g,
GemMaterial::DEFAULT_SCATTERING_G,
"anisotropy comes from the crate's default, matching the live path"
);
}
#[test]
fn capture_carries_the_c_axis_override_into_the_exported_scene() {
let off = SceneSnapshot::capture(&Mutex::new(RenderContext {
c_axis_override: None,
..Default::default()
}));
assert_eq!(
off.material.c_axis,
GemMaterial::diamond().c_axis,
"the off (\"as cut\") position must leave the material's own c_axis untouched"
);
let on = SceneSnapshot::capture(&Mutex::new(RenderContext {
material_name: "Sapphire".to_string(),
c_axis_override: Some(Vec3::X),
..Default::default()
}));
assert_eq!(
on.material.c_axis,
Vec3::X,
"a dialled-in override on an anisotropic material must reach the exported scene"
);
let isotropic_guarded = SceneSnapshot::capture(&Mutex::new(RenderContext {
material_name: "Diamond".to_string(),
c_axis_override: Some(Vec3::X),
..Default::default()
}));
assert_eq!(
isotropic_guarded.material.c_axis,
GemMaterial::diamond().c_axis,
"an override dialled in for an isotropic material must be ignored, matching \
apply_material_overrides's own guard"
);
}
#[test]
fn capture_carries_the_edge_rounding_setting_into_the_exported_scene() {
let off = SceneSnapshot::capture(&Mutex::new(RenderContext {
edge_rounding_radius: 0.0,
..Default::default()
}));
assert_eq!(
off.material.edge_rounding_radius, 0.0,
"the off position must leave the material untouched"
);
let on = SceneSnapshot::capture(&Mutex::new(RenderContext {
edge_rounding_radius: 0.02,
..Default::default()
}));
assert_eq!(
on.material.edge_rounding_radius, 0.02,
"a dialled-in edge-rounding radius must reach the exported scene"
);
}
#[test]
fn capture_carries_the_stone_width_setting_into_the_exported_scene() {
let off = SceneSnapshot::capture(&Mutex::new(RenderContext {
stone_width_mm: 0.0,
..Default::default()
}));
assert_eq!(
off.material.absorption_path_scale, 1.0,
"the off position must leave the material's absorption_path_scale untouched"
);
let default_ctx = RenderContext::default();
let model_width = indicatrix::geometry::stone_metrics::measure_solid(
&default_ctx
.active_planes
.iter()
.map(|p| {
(
glam::DVec3::new(
f64::from(p.normal[0]),
f64::from(p.normal[1]),
f64::from(p.normal[2]),
),
-f64::from(p.d),
)
})
.collect::<Vec<_>>(),
)
.expect("default active_planes must measure")
.width_axis;
let on = SceneSnapshot::capture(&Mutex::new(RenderContext {
stone_width_mm: 6.5,
..Default::default()
}));
let expected_scale = (6.5 / model_width) as f32;
assert!(
(on.material.absorption_path_scale - expected_scale).abs() < 1e-4,
"a dialled-in stone width must reach the exported scene as the expected \
absorption_path_scale: got {}, expected {expected_scale}",
on.material.absorption_path_scale
);
}
#[test]
fn capture_carries_the_girdle_frosted_setting_into_the_exported_scene() {
let off = SceneSnapshot::capture(&Mutex::new(RenderContext {
girdle_frosted: false,
..Default::default()
}));
assert!(
off.facet_finishes.is_empty(),
"the off position must carry no per-facet finish data"
);
let on = SceneSnapshot::capture(&Mutex::new(RenderContext {
girdle_frosted: true,
..Default::default()
}));
assert_eq!(
on.facet_finishes,
girdle_facet_finishes(&RenderContext::default().active_planes),
"the on position must carry the same classification the live viewport uses"
);
}
}