mod app_settings;
mod head_shadow;
mod import_preview;
mod lighting_preset;
mod local_compute;
mod recent_files;
mod remote_endpoint;
mod settings_file;
mod surface_glare;
#[cfg(test)]
mod tests_batch_lanes;
#[cfg(test)]
mod tests_payload_encoding;
#[cfg(test)]
mod tests_remote;
mod ui_preferences;
mod worker;
pub use app_settings::clamp_remote_batch_lanes;
pub use head_shadow::{clamp_head_shadow_deg, head_shadow_deg_from_slider};
pub use import_preview::ImportPreviewChoice;
pub use lighting_preset::LightingPreset;
pub use local_compute::LocalComputeTarget;
pub use remote_endpoint::{ExportTransfer, LiveTransfer, RemoteEndpoint, TiltVideoCompute};
pub use settings_file::SettingsFile;
pub use surface_glare::{
clamp_surface_glare, percent_from_surface_glare, surface_glare_from_percent,
};
pub use ui_preferences::{
UI_SCALE_CHOICES, UiMode, normalize_ui_scale_percent, ui_scale_factor_text,
};
pub use worker::{LiveComputeTarget, LocalPreviewScale, PreviewScale, WorkerSettings};
#[allow(
unused_imports,
reason = "used only via model:: from #[cfg(test)] code"
)]
pub use app_settings::{
AppSettings, Backdrop, DEFAULT_LIGHT_PITCH_DEG, DEFAULT_LIGHT_YAW_DEG, DEFAULT_LIGHTING_RIG,
DEFAULT_PREVIEW_SIZE, DEFAULT_PREVIEW_SPP, DEFAULT_RENDER_HEIGHT, DEFAULT_RENDER_WIDTH,
DEFAULT_TARGET_SAMPLES,
};
#[allow(
unused_imports,
reason = "used only via model:: from #[cfg(test)] code"
)]
pub use lighting_preset::built_in_presets;
#[allow(
unused_imports,
reason = "used only via model:: from #[cfg(test)] code"
)]
pub use worker::DEFAULT_WORKER_CADENCE_MS;
#[cfg(test)]
mod tests {
use super::*;
use indicatrix_net::messages::TransferMode;
#[test]
fn default_settings_file_carries_all_built_ins() {
let file = SettingsFile::default();
assert_eq!(file.presets.len(), built_in_presets().len());
assert!(file.presets.iter().all(|p| p.built_in));
}
#[test]
fn ensure_built_in_presets_is_idempotent_and_additive() {
let mut file = SettingsFile {
settings: AppSettings::default(),
presets: vec![],
rig_profiles: Vec::new(),
};
file.ensure_built_in_presets();
let after_first = file.presets.clone();
file.ensure_built_in_presets();
assert_eq!(
file.presets, after_first,
"calling twice must not duplicate entries"
);
assert_eq!(file.presets.len(), built_in_presets().len());
}
#[test]
fn ensure_built_in_presets_preserves_user_presets() {
let mut file = SettingsFile {
settings: AppSettings::default(),
presets: vec![LightingPreset {
name: "My Custom Look".to_string(),
built_in: false,
light_yaw_deg: 10.0,
light_pitch_deg: 20.0,
exposure: 0.9,
lighting_rig: "Incandescent (3200K)".to_string(),
camera_distance: 3.0,
camera_yaw: None,
camera_pitch: None,
env_map_path: None,
export_usable: false,
}],
rig_profiles: Vec::new(),
};
file.ensure_built_in_presets();
assert!(file.presets.iter().any(|p| p.name == "My Custom Look"));
assert_eq!(file.presets.len(), built_in_presets().len() + 1);
}
#[test]
fn upsert_user_preset_creates_and_overwrites() {
let mut file = SettingsFile::default();
let preset = LightingPreset {
name: "Evening Glow".to_string(),
built_in: false,
light_yaw_deg: 200.0,
light_pitch_deg: 40.0,
exposure: 0.85,
lighting_rig: "Incandescent (3200K)".to_string(),
camera_distance: 2.8,
camera_yaw: None,
camera_pitch: None,
env_map_path: None,
export_usable: false,
};
file.upsert_user_preset(preset.clone()).unwrap();
assert_eq!(file.find_preset("Evening Glow"), Some(&preset));
let mut updated = preset;
updated.exposure = 1.1;
file.upsert_user_preset(updated.clone()).unwrap();
assert_eq!(file.find_preset("Evening Glow"), Some(&updated));
assert_eq!(
file.presets
.iter()
.filter(|p| p.name == "Evening Glow")
.count(),
1
);
}
#[test]
fn upsert_user_preset_refuses_to_shadow_a_built_in() {
let mut file = SettingsFile::default();
let clash = LightingPreset {
name: "Studio Softbox".to_string(),
built_in: false,
light_yaw_deg: 0.0,
light_pitch_deg: 0.0,
exposure: 1.0,
lighting_rig: "Gem Studio Ring Lights".to_string(),
camera_distance: 2.0,
camera_yaw: None,
camera_pitch: None,
env_map_path: None,
export_usable: false,
};
let result = file.upsert_user_preset(clash);
assert!(result.is_err());
assert_eq!(
file.find_preset("Studio Softbox").unwrap().light_pitch_deg,
54.0
);
}
#[test]
fn upsert_user_preset_rejects_empty_name() {
let mut file = SettingsFile::default();
let preset = LightingPreset {
name: " ".to_string(),
built_in: false,
light_yaw_deg: 0.0,
light_pitch_deg: 0.0,
exposure: 1.0,
lighting_rig: "Gem Studio Ring Lights".to_string(),
camera_distance: 2.0,
camera_yaw: None,
camera_pitch: None,
env_map_path: None,
export_usable: false,
};
assert!(file.upsert_user_preset(preset).is_err());
}
#[test]
fn rename_preset_renames_user_preset() {
let mut file = SettingsFile::default();
file.upsert_user_preset(LightingPreset {
name: "Draft".to_string(),
built_in: false,
light_yaw_deg: 0.0,
light_pitch_deg: 0.0,
exposure: 1.0,
lighting_rig: "Gem Studio Ring Lights".to_string(),
camera_distance: 2.0,
camera_yaw: None,
camera_pitch: None,
env_map_path: None,
export_usable: false,
})
.unwrap();
file.rename_preset("Draft", "Final Cut").unwrap();
assert!(file.find_preset("Draft").is_none());
assert!(file.find_preset("Final Cut").is_some());
}
#[test]
fn rename_preset_refuses_built_ins_and_name_collisions() {
let mut file = SettingsFile::default();
assert!(file.rename_preset("Studio Softbox", "New Name").is_err());
file.upsert_user_preset(LightingPreset {
name: "Second".to_string(),
built_in: false,
light_yaw_deg: 0.0,
light_pitch_deg: 0.0,
exposure: 1.0,
lighting_rig: "Gem Studio Ring Lights".to_string(),
camera_distance: 2.0,
camera_yaw: None,
camera_pitch: None,
env_map_path: None,
export_usable: false,
})
.unwrap();
assert!(file.rename_preset("Second", "Studio Softbox").is_err());
}
#[test]
fn delete_preset_removes_user_preset_but_refuses_built_ins() {
let mut file = SettingsFile::default();
file.upsert_user_preset(LightingPreset {
name: "Temp".to_string(),
built_in: false,
light_yaw_deg: 0.0,
light_pitch_deg: 0.0,
exposure: 1.0,
lighting_rig: "Gem Studio Ring Lights".to_string(),
camera_distance: 2.0,
camera_yaw: None,
camera_pitch: None,
env_map_path: None,
export_usable: false,
})
.unwrap();
file.delete_preset("Temp").unwrap();
assert!(file.find_preset("Temp").is_none());
assert!(file.delete_preset("Studio Softbox").is_err());
assert!(file.find_preset("Studio Softbox").is_some());
}
#[test]
fn toml_round_trip_preserves_settings_and_presets() {
let mut file = SettingsFile::default();
file.settings.exposure = 1.42;
file.settings.selected_material = "Sapphire".to_string();
file.upsert_user_preset(LightingPreset {
name: "Round Trip Test".to_string(),
built_in: false,
light_yaw_deg: 123.0,
light_pitch_deg: 45.0,
exposure: 1.2,
lighting_rig: "Incandescent (3200K)".to_string(),
camera_distance: 3.3,
camera_yaw: Some(0.6),
camera_pitch: Some(0.45),
env_map_path: Some("C:/env/studio.hdr".to_string()),
export_usable: true,
})
.unwrap();
let toml_str = toml::to_string_pretty(&file).expect("serialize");
let parsed: SettingsFile = toml::from_str(&toml_str).expect("deserialize");
assert_eq!(parsed, file);
}
#[test]
fn preview_scale_percentages_match_their_names() {
assert_eq!(PreviewScale::Full.percent(), 100);
assert_eq!(PreviewScale::Half.percent(), 50);
assert_eq!(PreviewScale::Quarter.percent(), 25);
assert_eq!(PreviewScale::Custom(10).percent(), 10);
}
#[test]
fn preview_scale_custom_clamps_to_one_through_one_hundred() {
assert_eq!(PreviewScale::Custom(0).percent(), 1);
assert_eq!(PreviewScale::Custom(500).percent(), 100);
assert_eq!(PreviewScale::Custom(1).percent(), 1);
assert_eq!(PreviewScale::Custom(100).percent(), 100);
}
#[test]
fn preview_scale_resolve_scales_dimensions_and_floors_at_one_by_one() {
assert_eq!(PreviewScale::Full.resolve(1920, 1080), (1920, 1080));
assert_eq!(PreviewScale::Half.resolve(200, 100), (100, 50));
assert_eq!(PreviewScale::Quarter.resolve(200, 100), (50, 25));
assert_eq!(PreviewScale::Quarter.resolve(2, 2), (1, 1));
assert_eq!(PreviewScale::Custom(1).resolve(10, 10), (1, 1));
}
#[test]
fn worker_settings_default_is_live_progressive_500ms_quarter_preview() {
let w = WorkerSettings::default();
assert_eq!(w.transfer_mode, TransferMode::LiveProgressive);
assert_eq!(w.cadence_ms, DEFAULT_WORKER_CADENCE_MS);
assert_eq!(w.preview_scale, PreviewScale::Quarter);
}
#[test]
fn worker_settings_cert_paths_join_the_bundle_directory() {
let w = WorkerSettings {
cert_dir: "C:/certs/laptop".to_string(),
..WorkerSettings::default()
};
assert_eq!(w.ca_path(), std::path::Path::new("C:/certs/laptop/ca.pem"));
assert_eq!(
w.client_cert_path(),
std::path::Path::new("C:/certs/laptop/client.pem")
);
assert_eq!(
w.client_key_path(),
std::path::Path::new("C:/certs/laptop/client.key")
);
}
#[test]
fn effective_cadence_ms_never_goes_below_the_workers_advertised_floor() {
let fast_request = WorkerSettings {
cadence_ms: 50,
..WorkerSettings::default()
};
assert_eq!(fast_request.effective_cadence_ms(100), 100);
let slow_request = WorkerSettings {
cadence_ms: 2000,
..WorkerSettings::default()
};
assert_eq!(slow_request.effective_cadence_ms(100), 2000);
}
#[test]
fn stream_config_reflects_transfer_mode_clamped_cadence_and_scaled_preview() {
let worker = WorkerSettings {
transfer_mode: TransferMode::FinalOnly,
cadence_ms: 10,
preview_scale: PreviewScale::Half,
..WorkerSettings::default()
};
let cfg = worker.stream_config(100, 800, 600);
assert_eq!(cfg.transfer_mode, TransferMode::FinalOnly);
assert_eq!(cfg.cadence_ms, 100);
assert_eq!(
cfg.preview,
Some(indicatrix_net::messages::PreviewConfig {
width: 400,
height: 300
})
);
}
#[test]
fn app_settings_default_has_denoise_enabled_and_no_remote() {
let settings = AppSettings::default();
assert!(settings.denoise_enabled);
assert_eq!(settings.remote, None);
assert_eq!(settings.remote_worker(), None);
}
#[test]
fn toml_round_trip_preserves_the_remote_endpoint_and_denoise_toggle() {
let mut file = SettingsFile::default();
file.settings.denoise_enabled = false;
file.settings.remote = Some(RemoteEndpoint {
connection: WorkerSettings {
name: "Workstation".to_string(),
address: "10.0.0.5:9443".to_string(),
cert_dir: "C:/Users/me/indicatrix-certs".to_string(),
transfer_mode: TransferMode::FinalOnly,
cadence_ms: 250,
preview_scale: PreviewScale::Custom(33),
},
export_transfer: ExportTransfer::FinalPicture,
live_transfer: LiveTransfer::FinalPicture,
});
let toml_str = toml::to_string_pretty(&file).expect("serialize");
let parsed: SettingsFile = toml::from_str(&toml_str).expect("deserialize");
assert_eq!(parsed, file);
assert!(!parsed.settings.denoise_enabled);
assert_eq!(
parsed.settings.remote_worker().map(|w| w.cadence_ms),
Some(250)
);
assert!(
!toml_str.contains("remote_workers"),
"the retired list is never written: {toml_str}"
);
}
#[test]
fn a_settings_file_predating_remote_rendering_still_parses_with_sensible_defaults() {
let toml_str = "[settings]\nexposure = 1.1\n";
let parsed: SettingsFile = toml::from_str(toml_str).expect("deserialize");
assert!(parsed.settings.denoise_enabled);
assert_eq!(parsed.settings.remote, None);
}
#[test]
fn a_settings_file_with_the_old_quality_preset_key_still_loads_with_target_samples_defaulted() {
let toml_str =
"[settings]\nquality_preset = \"High / Quality\"\nexposure = 1.75\nmax_bounces = 20\n";
let parsed: SettingsFile = toml::from_str(toml_str).expect("deserialize");
assert_eq!(parsed.settings.target_samples, DEFAULT_TARGET_SAMPLES);
assert_eq!(parsed.settings.exposure, 1.75);
assert_eq!(parsed.settings.max_bounces, 20);
}
#[test]
fn a_settings_file_predating_render_resolution_still_loads_with_800x600_defaulted() {
let toml_str = "[settings]\nexposure = 1.2\n";
let parsed: SettingsFile = toml::from_str(toml_str).expect("deserialize");
assert_eq!(parsed.settings.render_width, DEFAULT_RENDER_WIDTH);
assert_eq!(parsed.settings.render_height, DEFAULT_RENDER_HEIGHT);
assert_eq!(parsed.settings.exposure, 1.2);
}
#[test]
fn toml_round_trip_preserves_render_resolution() {
let mut file = SettingsFile::default();
file.settings.render_width = 1920;
file.settings.render_height = 1080;
let toml_str = toml::to_string_pretty(&file).expect("serialize");
let parsed: SettingsFile = toml::from_str(&toml_str).expect("deserialize");
assert_eq!(parsed.settings.render_width, 1920);
assert_eq!(parsed.settings.render_height, 1080);
}
#[test]
fn a_partially_specified_remote_entry_defaults_its_missing_fields() {
let toml_str = r#"
[settings]
exposure = 1.0
[settings.remote.connection]
name = "Partial"
address = "10.0.0.9:9443"
"#;
let parsed: SettingsFile = toml::from_str(toml_str).expect("deserialize");
let remote = parsed.settings.remote.as_ref().expect("remote parsed");
assert_eq!(remote.export_transfer, ExportTransfer::FullData);
assert_eq!(remote.live_transfer, LiveTransfer::FullData);
let worker = &remote.connection;
assert_eq!(worker.name, "Partial");
assert_eq!(worker.transfer_mode, TransferMode::LiveProgressive);
assert_eq!(worker.cadence_ms, DEFAULT_WORKER_CADENCE_MS);
assert_eq!(worker.preview_scale, PreviewScale::Quarter);
}
#[test]
fn local_preview_scale_divisors_match_their_names() {
assert_eq!(LocalPreviewScale::Off.divisor(), 1);
assert_eq!(LocalPreviewScale::Half.divisor(), 2);
assert_eq!(LocalPreviewScale::Quarter.divisor(), 4);
}
#[test]
fn app_settings_default_has_local_preview_off() {
assert_eq!(
AppSettings::default().local_preview_scale,
LocalPreviewScale::Off
);
}
#[test]
fn a_settings_file_predating_local_preview_still_loads_with_it_off() {
let toml_str = "[settings]\nexposure = 1.2\n";
let parsed: SettingsFile = toml::from_str(toml_str).expect("deserialize");
assert_eq!(parsed.settings.local_preview_scale, LocalPreviewScale::Off);
}
#[test]
fn toml_round_trip_preserves_local_preview_scale() {
let mut file = SettingsFile::default();
file.settings.local_preview_scale = LocalPreviewScale::Quarter;
let toml_str = toml::to_string_pretty(&file).expect("serialize");
let parsed: SettingsFile = toml::from_str(&toml_str).expect("deserialize");
assert_eq!(
parsed.settings.local_preview_scale,
LocalPreviewScale::Quarter
);
}
#[test]
fn an_old_settings_file_with_remote_render_samples_still_loads() {
let toml_str =
"[settings]\ntarget_samples = 128\nremote_render_samples = 4096\nexposure = 1.2\n";
let parsed: SettingsFile = toml::from_str(toml_str).expect("deserialize");
assert_eq!(parsed.settings.target_samples, 128);
assert!((parsed.settings.exposure - 1.2).abs() < 1e-6);
let rewritten = toml::to_string_pretty(&parsed).expect("serialize");
assert!(
!rewritten.contains("remote_render_samples"),
"the retired key must be dropped on the next save"
);
}
#[test]
fn app_settings_default_has_no_env_map_loaded() {
assert_eq!(AppSettings::default().env_map_path, "");
}
#[test]
fn a_settings_file_predating_env_map_still_loads_with_none_loaded() {
let toml_str = "[settings]\nexposure = 1.2\n";
let parsed: SettingsFile = toml::from_str(toml_str).expect("deserialize");
assert_eq!(parsed.settings.env_map_path, "");
}
#[test]
fn app_settings_default_has_local_compute_target_cpu_gpu() {
assert_eq!(
AppSettings::default().local_compute_target,
LocalComputeTarget::CpuGpu
);
}
#[test]
fn a_settings_file_predating_local_compute_target_still_loads_with_cpu_gpu_defaulted() {
let toml_str = "[settings]\nexposure = 1.2\n";
let parsed: SettingsFile = toml::from_str(toml_str).expect("deserialize");
assert_eq!(
parsed.settings.local_compute_target,
LocalComputeTarget::CpuGpu
);
}
#[test]
fn toml_round_trip_preserves_local_compute_target() {
let mut file = SettingsFile::default();
file.settings.local_compute_target = LocalComputeTarget::Gpu;
let toml_str = toml::to_string_pretty(&file).expect("serialize");
let parsed: SettingsFile = toml::from_str(&toml_str).expect("deserialize");
assert_eq!(
parsed.settings.local_compute_target,
LocalComputeTarget::Gpu
);
}
#[test]
fn toml_round_trip_preserves_tilt_video_compute_target() {
for choice in [
TiltVideoCompute::Local,
TiltVideoCompute::Remote,
TiltVideoCompute::Both,
] {
let mut file = SettingsFile::default();
file.settings.tilt_video_compute_target = choice;
let toml_str = toml::to_string_pretty(&file).expect("serialize");
let parsed: SettingsFile = toml::from_str(&toml_str).expect("deserialize");
assert_eq!(parsed.settings.tilt_video_compute_target, choice);
}
}
#[test]
fn a_settings_file_predating_the_tilt_video_compute_target_loads_as_both() {
let toml_str = "[settings]\nexposure = 1.2\n";
let parsed: SettingsFile = toml::from_str(toml_str).expect("deserialize");
assert_eq!(
parsed.settings.tilt_video_compute_target,
TiltVideoCompute::Both
);
assert_eq!(
AppSettings::default().tilt_video_compute_target,
TiltVideoCompute::Both
);
}
#[test]
fn tilt_video_compute_indices_round_trip_and_unknown_is_both() {
for choice in [
TiltVideoCompute::Local,
TiltVideoCompute::Remote,
TiltVideoCompute::Both,
] {
assert_eq!(TiltVideoCompute::from_index(choice.index()), choice);
}
assert_eq!(TiltVideoCompute::from_index(-1), TiltVideoCompute::Both);
assert_eq!(TiltVideoCompute::from_index(7), TiltVideoCompute::Both);
}
#[test]
fn toml_round_trip_preserves_env_map_path() {
let mut file = SettingsFile::default();
file.settings.env_map_path = "C:/hdri/studio.hdr".to_string();
let toml_str = toml::to_string_pretty(&file).expect("serialize");
let parsed: SettingsFile = toml::from_str(&toml_str).expect("deserialize");
assert_eq!(parsed.settings.env_map_path, "C:/hdri/studio.hdr");
}
#[test]
fn app_settings_default_has_160px_256spp_previews() {
let settings = AppSettings::default();
assert_eq!(settings.preview_size, DEFAULT_PREVIEW_SIZE);
assert_eq!(settings.preview_spp, DEFAULT_PREVIEW_SPP);
}
#[test]
fn a_settings_file_predating_preview_generation_still_loads_with_defaults() {
let toml_str = "[settings]\nexposure = 1.2\n";
let parsed: SettingsFile = toml::from_str(toml_str).expect("deserialize");
assert_eq!(parsed.settings.preview_size, DEFAULT_PREVIEW_SIZE);
assert_eq!(parsed.settings.preview_spp, DEFAULT_PREVIEW_SPP);
}
#[test]
fn toml_round_trip_preserves_preview_size_and_spp() {
let mut file = SettingsFile::default();
file.settings.preview_size = 128;
file.settings.preview_spp = 64;
let toml_str = toml::to_string_pretty(&file).expect("serialize");
let parsed: SettingsFile = toml::from_str(&toml_str).expect("deserialize");
assert_eq!(parsed.settings.preview_size, 128);
assert_eq!(parsed.settings.preview_spp, 64);
}
#[test]
fn contribute_to_final_picture_defaults_true_when_absent_and_round_trips() {
assert!(AppSettings::default().contribute_to_final_picture);
let toml_str = "[settings]\nexposure = 1.2\n";
let parsed: SettingsFile = toml::from_str(toml_str).expect("deserialize");
assert!(parsed.settings.contribute_to_final_picture);
let mut file = SettingsFile::default();
file.settings.contribute_to_final_picture = false;
let toml_str = toml::to_string_pretty(&file).expect("serialize");
let parsed: SettingsFile = toml::from_str(&toml_str).expect("deserialize");
assert!(!parsed.settings.contribute_to_final_picture);
}
}