indicatrix-cut 0.2.0

Desktop faceting-design editor: library browsing, spectral 3D rendering, material retargeting, and a solid inspection view.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
//! Applying settings loaded from disk into the render context and the UI's own
//! mirrored properties at startup ([`apply_loaded_settings`]), and the small
//! settings-value <-> UI-pill-index conversions that go with it (`bounces_index`,
//! `resolution_index`, `local_preview_scale_index`/`from_index`,
//! `local_compute_target_index`/`from_index`, `color_space_from_index`,
//! `find_option_index`), plus the two option-list refreshers
//! ([`refresh_lighting_preset_options`], [`refresh_material_options`]) and
//! [`env_map_status_text`]/[`is_c_axis_override_available`], both shared with a
//! later, user-initiated callback. Moved out of `gui::mod` purely to keep that file
//! from growing further -- same reasoning as `gui`'s other submodules.

use crate::{
    EditorModel, LibraryModel, LightingPresetItem, MainWindow, RemoteWorkerModel, SettingsModel,
    SolidPreviewModel, ViewportModel,
    bridge::render_thread::{RenderContext, load_env_map, resolve_material},
    gui::{
        optics::c_axis::angles_to_c_axis,
        remote::{live_compute_target_index, remote_samples_count_to_exponent},
        render::sample_scale::count_to_exponent,
        show_toast,
    },
    settings::{
        LightingPreset as SavedLightingPreset, LocalComputeTarget, LocalPreviewScale, SettingsFile,
    },
};
use indicatrix::{
    color::ColorSpace,
    optics::{
        LightingPreset,
        materials::{GemMaterial, OpticalCharacter},
    },
};
use slint::{ComponentHandle, Model, ModelRc, SharedString, VecModel};
use std::sync::{Arc, Mutex};

/// Applies settings loaded from disk into the render context and into the UI's own
/// mirrored properties (`target_samples_exponent`, `resolution_index`, `bounce_index`,
/// `exposure_val`, `light_yaw_deg`, `light_pitch_deg`, `inclusion_sigma_s`,
/// `c_axis_override_enabled`/`c_axis_tilt_deg`/`c_axis_azimuth_deg`, `girdle_frosted`,
/// `edge_rounding_radius`, `stone_width_mm` -- hoisted onto `MainWindow` for exactly this reason, see the
/// comment beside them in `app.slint`). Called once at startup, before the render
/// thread or any callback is wired up, so there is no risk of a callback firing
/// mid-application and racing this.
pub(super) fn apply_loaded_settings(
    ui: &MainWindow,
    render_ctx: &Arc<Mutex<RenderContext>>,
    loaded: &SettingsFile,
) {
    let s = &loaded.settings;
    // Parse the persisted lighting-rig label back into its enum at this one
    // boundary -- gracefully migrating any legacy or unrecognized label (including the
    // old, mislabelled `"D65 Daylight (5500K)"` string) via `from_label`'s own
    // fallback, rather than resetting the user's choice. See
    // `indicatrix::optics::LightingPreset::from_label`.
    let lighting_preset = LightingPreset::from_label(&s.lighting_rig);
    apply_loaded_render_context(render_ctx, s, lighting_preset);
    apply_loaded_ui_mirrors(ui, s, lighting_preset);

    if let Some(idx) = find_option_index(
        &ui.global::<ViewportModel>().get_material_options(),
        &s.selected_material,
    ) {
        ui.global::<ViewportModel>()
            .set_selected_material_index(idx);
    }
    // Whether the crystal-axis control is interactive at all depends on the
    // STARTING material -- must be set here too, not just from `on_material_changed`,
    // or a session restored on an isotropic material (e.g. the "Diamond" default) would
    // show the slider as enabled until the user touched the material dropdown once.
    let starting_material = resolve_material(
        &GemMaterial::all_materials(),
        &render_ctx.lock().unwrap().custom_materials,
        &s.selected_material,
    );
    ui.global::<SettingsModel>()
        .set_c_axis_override_available(is_c_axis_override_available(&starting_material));
    // The RI-tolerance filter default -- same "must be set here too" reasoning as
    // `c_axis_override_available` immediately above: `on_material_changed` (wired later,
    // in `material_quality::setup_material_changed_callback`) only fires on a SUBSEQUENT
    // change, so the STARTING material's RI must be pushed once here or the filter
    // panel would show a stale `2.417` (this property's `.slint` placeholder default)
    // for a session restored on any other material. `ri_tolerance_center` is seeded to
    // the same value -- it is `in-out` (the user can drag it away afterward), but a
    // freshly opened session should start with the tolerance band centred on whatever
    // is actually loaded, not on the placeholder.
    let ri_default = crate::bridge::preview_render::material_ri_at_sodium_d(&starting_material);
    // `ri_default` is an f64; Slint's `float` property type is f32 -- a lossy but
    // harmless cast for a display/filter-default value in the 1.0-3.0 RI range,
    // `cast_possible_truncation` is workspace-`allow`ed (`Cargo.toml`).
    ui.global::<LibraryModel>()
        .set_ri_material_default(ri_default as f32);
    ui.global::<LibraryModel>()
        .set_ri_tolerance_center(ri_default as f32);

    // Reload the last-loaded HDR environment map, if any. Mirrors
    // `on_load_env_map` (in `setup_environment_map_callbacks`) but runs before any
    // callback is wired up, so a load failure here can only toast, not race a
    // simultaneous user-initiated load. `s.env_map_path` is left untouched either way
    // -- a transient failure (file on a currently-unmounted drive, say) shouldn't
    // silently forget the path the next successful launch could still use.
    ui.global::<SettingsModel>()
        .set_env_map_path(s.env_map_path.clone().into());
    if s.env_map_path.is_empty() {
        ui.global::<SettingsModel>().set_env_map_loaded(false);
        ui.global::<SettingsModel>()
            .set_env_map_status(String::new().into());
    } else {
        match load_env_map(&s.env_map_path) {
            Ok(map) => {
                ui.global::<SettingsModel>()
                    .set_env_map_status(env_map_status_text(&map, &s.env_map_path).into());
                ui.global::<SettingsModel>().set_env_map_loaded(true);
                render_ctx.lock().unwrap().env_map = Some(map);
            }
            Err(err) => {
                ui.global::<SettingsModel>().set_env_map_loaded(false);
                ui.global::<SettingsModel>()
                    .set_env_map_status(String::new().into());
                show_toast(
                    ui,
                    &format!("Could not reload saved HDR environment: {err}"),
                    "error",
                );
            }
        }
    }
}

/// The `RenderContext` half of [`apply_loaded_settings`]: writes every render-context
/// field the settings load restores, under one lock hold. Split out purely to keep
/// that function under clippy's function-length lint; see its own doc comment for
/// which UI-mirrored properties correspond to these.
fn apply_loaded_render_context(
    render_ctx: &Arc<Mutex<RenderContext>>,
    s: &crate::settings::model::AppSettings,
    lighting_preset: LightingPreset,
) {
    let mut ctx = render_ctx.lock().unwrap();
    ctx.target_samples = s.target_samples;
    ctx.width = s.render_width;
    ctx.height = s.render_height;
    ctx.max_bounces = s.max_bounces;
    ctx.exposure = s.exposure;
    ctx.inclusion_sigma_s = s.inclusion_sigma_s;
    // The settings dialog drags degrees, `RenderContext` stores the already-
    // resolved `Vec3` -- see `RenderContext::c_axis_override`'s own doc comment for
    // why this crossing happens here rather than downstream in `bridge`.
    ctx.c_axis_override = s
        .c_axis_override_enabled
        .then(|| angles_to_c_axis(s.c_axis_tilt_deg, s.c_axis_azimuth_deg));
    ctx.girdle_frosted = s.girdle_frosted;
    ctx.edge_rounding_radius = s.edge_rounding_radius;
    ctx.stone_width_mm = s.stone_width_mm;
    ctx.light_yaw = s.light_yaw_deg.to_radians();
    ctx.light_pitch = s.light_pitch_deg.to_radians().clamp(0.15, 1.55);
    ctx.lighting_preset = lighting_preset;
    ctx.yaw = s.camera_yaw;
    ctx.pitch = s.camera_pitch.clamp(-1.48, 1.48);
    ctx.distance = s.camera_distance.clamp(1.2, 8.0);
    ctx.material_name.clone_from(&s.selected_material);
    ctx.denoise_enabled = s.denoise_enabled;
    // Local preview-then-settle rendering / remote render sample
    // budget -- both live-update `RenderContext` at startup exactly like every
    // other setting in this block, `camera_moving` deliberately left at its
    // `Default` (`false`): it's re-derived from live camera-pose polling by
    // `gui::remote::poll_tick` within the first tick after the window opens, never
    // something a settings FILE has an opinion on.
    ctx.local_preview_scale = s.local_preview_scale;
    ctx.remote_render_samples = s.remote_render_samples;
    ctx.live_compute_target = s.live_compute_target;
    ctx.local_compute_target = s.local_compute_target;
    ctx.dirty = true;
}

/// The UI-mirrored-properties half of [`apply_loaded_settings`]: pushes every
/// `SettingsModel`/`RemoteWorkerModel`/`LibraryModel`/`SolidPreviewModel`/
/// `EditorModel`/`ViewportModel` property this settings load restores. Split out
/// purely to keep that function under clippy's function-length lint.
fn apply_loaded_ui_mirrors(
    ui: &MainWindow,
    s: &crate::settings::model::AppSettings,
    lighting_preset: LightingPreset,
) {
    // The slider stores an EXPONENT, the settings file stores a COUNT -- see
    // `gui::sample_scale`'s module doc comment for why, and for this conversion's
    // inverse (`exponent_to_count`, used when the slider itself changes).
    ui.global::<SettingsModel>()
        .set_target_samples_exponent(count_to_exponent(s.target_samples) as f32);
    ui.global::<SettingsModel>()
        .set_resolution_index(resolution_index(s.render_width, s.render_height));
    ui.global::<SettingsModel>()
        .set_preview_size_val(s.preview_size as f32);
    ui.global::<SettingsModel>()
        .set_preview_spp_val(s.preview_spp as f32);
    ui.global::<SettingsModel>()
        .set_bounce_index(bounces_index(s.max_bounces));
    ui.global::<SettingsModel>().set_exposure_val(s.exposure);
    ui.global::<SettingsModel>()
        .set_inclusion_sigma_s(s.inclusion_sigma_s);
    ui.global::<SettingsModel>()
        .set_c_axis_override_enabled(s.c_axis_override_enabled);
    ui.global::<SettingsModel>()
        .set_c_axis_tilt_deg(s.c_axis_tilt_deg);
    ui.global::<SettingsModel>()
        .set_c_axis_azimuth_deg(s.c_axis_azimuth_deg);
    ui.global::<SettingsModel>()
        .set_girdle_frosted(s.girdle_frosted);
    ui.global::<SettingsModel>()
        .set_edge_rounding_radius(s.edge_rounding_radius);
    ui.global::<SettingsModel>()
        .set_stone_width_mm(s.stone_width_mm);
    ui.global::<SettingsModel>()
        .set_local_preview_scale_index(local_preview_scale_index(s.local_preview_scale));
    ui.global::<SettingsModel>()
        .set_live_compute_target_index(live_compute_target_index(s.live_compute_target));
    ui.global::<SettingsModel>()
        .set_local_compute_target_index(local_compute_target_index(s.local_compute_target));
    ui.global::<RemoteWorkerModel>()
        .set_render_samples_exponent(
            remote_samples_count_to_exponent(s.remote_render_samples) as f32
        );
    ui.global::<SettingsModel>()
        .set_light_yaw_deg(s.light_yaw_deg);
    ui.global::<SettingsModel>()
        .set_light_pitch_deg(s.light_pitch_deg);
    // Restore the library panel's collapsed/expanded state.
    ui.global::<LibraryModel>()
        .set_panel_collapsed(s.library_panel_collapsed);
    // Restore the Solid viewport's remembered view mode (0 Solid / 1
    // Path-traced / 2 Both) -- see `AppSettings::solid_view_mode`'s own doc
    // comment. This may fire `app.slint`'s `changed editor_solid_view_mode`
    // handler (hence `editor_solid_view_mode_changed`) before
    // `setup_editor_solid_view_mode_changed_callback` below ever connects a
    // handler to it -- harmless: a Slint callback with nothing connected yet is
    // simply a no-op, not a panic, and the debounced writer would only be asked
    // to persist the exact value it just loaded anyway.
    ui.global::<SolidPreviewModel>()
        .set_view_mode(i32::from(s.solid_view_mode));
    // The "Edit" sub-tab's auto-solve budget (`gui::editor::auto_solve`) -- same
    // harmless-early-`changed`-fire caveat as `editor_solid_view_mode` above applies
    // here too.
    ui.global::<EditorModel>()
        .set_auto_solve_budget_ms(i32::try_from(s.editor_auto_solve_budget_ms).unwrap_or(i32::MAX));
    ui.global::<ViewportModel>()
        .set_selected_lighting_index(lighting_preset.index());
}

/// The settings dialog's "Loaded: <file> (`WxH`)" status line for a decoded
/// [`indicatrix::renderer::env_map::EnvironmentMap`] -- shared by the startup
/// reload in `apply_loaded_settings` and the user-initiated load in
/// `setup_environment_map_callbacks` so both report the map identically. Shows the
/// file name alone (not the full path, which can be long and is already visible in the
/// path field above it).
pub(in crate::gui) fn env_map_status_text(
    map: &indicatrix::renderer::env_map::EnvironmentMap,
    path: &str,
) -> String {
    let name = std::path::Path::new(path)
        .file_name()
        .map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned());
    format!("Loaded: {name} ({}\u{d7}{})", map.width(), map.height())
}

/// Whether the crystal-axis orientation override has any effect on
/// `material` -- `false` for an isotropic material (Diamond, Spinel, Cubic Zirconia,
/// and any custom material with `birefringence_delta == 0.0`), whose optic axis is
/// physically meaningless: there is no birefringence to orient. Drives
/// `MainWindow.c_axis_override_available`, which `settings_dialog.slint` uses to grey
/// out the control and explain why, rather than silently letting the user drag a
/// slider that does nothing.
pub(in crate::gui) fn is_c_axis_override_available(material: &GemMaterial) -> bool {
    material.optical_character != OpticalCharacter::Isotropic
}

/// Reverse of the mapping baked into `settings_dialog.slint`'s bounce-count pill
/// selector (4/8/12/24/64/128 at indices 0-5, raised from the old 4/8/12/16/24 ladder
/// per the `bounce_cost.rs` benchmark -- see that pill block's own doc comment for the
/// measurements). Exact matches map directly; anything else -- a settings file written
/// by an older build (e.g. the retired 16-bounce rung) or a hand-edited value -- picks
/// the *nearest* rung by absolute distance rather than a single hardcoded fallback like
/// `resolution_index`/`local_preview_scale_index` below use. Those get away with
/// "assume the default option" because their lists are short and evenly spaced; this
/// ladder now spans 4..128 unevenly (24 -> 64 is a 40-bounce gap), so a blanket
/// fallback would misplace a value like 40 or 96 by a wide margin. Never used to
/// reject or clamp the actual stored/rendered `max_bounces` -- see `on_bounces_changed`
/// in `material_quality.rs`, which honours the raw persisted value untouched regardless
/// of which pill this highlights.
const fn bounces_index(bounces: u32) -> i32 {
    const RUNGS: [u32; 6] = [4, 8, 12, 24, 64, 128];
    let mut best_idx = 0usize;
    let mut best_dist = u32::MAX;
    let mut i = 0usize;
    while i < RUNGS.len() {
        let dist = RUNGS[i].abs_diff(bounces);
        if dist < best_dist {
            best_dist = dist;
            best_idx = i;
        }
        i += 1;
    }
    best_idx as i32
}

/// Reverse of the mapping baked into `settings_dialog.slint`'s "Render Resolution"
/// pill selector (640x480/800x600/1280x720/1920x1080 at indices 0-3) -- a blanket
/// fixed-list-with-fallback treatment, used only to highlight the closest pill at
/// startup. Falls back to the 800x600 index (1) for any pair that isn't one of the
/// four presets (a hand-edited settings file, or one saved before this control existed
/// -- see `AppSettings::render_width`'s doc comment), matching
/// `RenderContext::default()`/`DEFAULT_RENDER_WIDTH`/`DEFAULT_RENDER_HEIGHT`. Never
/// used to reject or clamp the actual stored/rendered value -- `render_width`/
/// `render_height` themselves pass through untouched regardless of what this returns.
const fn resolution_index(width: u32, height: u32) -> i32 {
    match (width, height) {
        (640, 480) => 0,
        (1280, 720) => 2,
        (1920, 1080) => 3,
        _ => 1, // 800x600 and anything unrecognized
    }
}

/// Reverse of the mapping baked into `settings_dialog.slint`'s "Motion Preview
/// Resolution" pill selector (Off/Half/Quarter at indices 0-2) -- same fixed-list
/// treatment as `resolution_index` above, used to seed the pill at startup from a
/// persisted `LocalPreviewScale`.
const fn local_preview_scale_index(scale: LocalPreviewScale) -> i32 {
    match scale {
        LocalPreviewScale::Off => 0,
        LocalPreviewScale::Half => 1,
        LocalPreviewScale::Quarter => 2,
    }
}

/// Inverse of [`local_preview_scale_index`]: what `on_local_preview_scale_changed`
/// (in `setup_material_and_quality_callbacks`) converts the pill's clicked index back
/// into. Falls back to `Off` for anything outside `0..=2` (a value the fixed pill
/// selector itself can never actually send), matching `resolution_index`'s own
/// unrecognized-value fallback convention.
pub(in crate::gui) const fn local_preview_scale_from_index(index: i32) -> LocalPreviewScale {
    match index {
        1 => LocalPreviewScale::Half,
        2 => LocalPreviewScale::Quarter,
        _ => LocalPreviewScale::Off,
    }
}

/// Reverse of the mapping baked into `settings_dialog.slint`'s "Local Compute" pill
/// selector (CPU/CPU+GPU/GPU only at indices 0-2) -- same fixed-list treatment as
/// `local_preview_scale_index` above, used to seed the pill at startup from a
/// persisted `LocalComputeTarget`.
const fn local_compute_target_index(target: LocalComputeTarget) -> i32 {
    match target {
        LocalComputeTarget::Cpu => 0,
        LocalComputeTarget::CpuGpu => 1,
        LocalComputeTarget::Gpu => 2,
    }
}

/// Inverse of [`local_compute_target_index`]: what `on_local_compute_target_changed`
/// (in `material_quality::setup_material_and_quality_callbacks`) converts the pill's
/// clicked index back into. Falls back to `CpuGpu` for anything outside `0..=2` (a
/// value the fixed pill selector itself can never actually send), matching
/// `LocalComputeTarget::default()` -- the same "unset behaves like the default, not
/// like the first variant" convention `gui::remote::live_compute_target_from_index`
/// already uses for its own three-way pill.
pub(in crate::gui) const fn local_compute_target_from_index(index: i32) -> LocalComputeTarget {
    match index {
        0 => LocalComputeTarget::Cpu,
        2 => LocalComputeTarget::Gpu,
        _ => LocalComputeTarget::CpuGpu,
    }
}

/// Finds `needle`'s index in a Slint `[string]` model, for restoring a `ComboBox`
/// selection (material, lighting rig) from a persisted name. Returns `None` (leaving
/// the current selection untouched) rather than guessing if the name isn't present --
/// e.g. a lighting rig from an older options list, or a custom material that was
/// deleted since the settings file was last saved.
fn find_option_index(options: &ModelRc<SharedString>, needle: &str) -> Option<i32> {
    (0..options.row_count()).find_map(|i| {
        let matches = options.row_data(i).is_some_and(|s| s.as_str() == needle);
        matches.then_some(i as i32)
    })
}

/// Rebuilds the `MainWindow.lighting_presets` model from the settings store's current
/// preset list. Called after startup load and after every create/rename/delete so the
/// settings dialog's preset rows stay in sync with what's actually persisted.
pub(in crate::gui) fn refresh_lighting_preset_options(
    ui: &MainWindow,
    presets: &[SavedLightingPreset],
) {
    let items: Vec<LightingPresetItem> = presets
        .iter()
        .map(|p| LightingPresetItem {
            name: p.name.clone().into(),
            built_in: p.built_in,
            export_usable: p.export_usable,
            has_env_map: p.env_map_path.is_some(),
            // Not export-dialog state -- see `LightingPresetItem.selected`'s own doc
            // comment; this list is the settings dialog's, which never reads it.
            selected: false,
        })
        .collect();
    ui.global::<ViewportModel>()
        .set_lighting_presets(ModelRc::new(VecModel::from(items)));
}

/// Wires up the high-resolution export flow: validates the request, captures
/// a `SceneSnapshot` independent of the live viewport's `RenderContext.width`/`height`
/// and accumulation buffer (see `export_thread`'s module doc comment for why that
/// separation matters), and spawns it on its own worker thread via
/// `export_thread::spawn_export`. The returned `ExportHandle` is kept in a
/// `Rc<RefCell<Option<_>>>` -- plain UI-thread-only state, not `Arc<Mutex<_>>>`, since
/// both callbacks here only ever run on the Slint event loop -- so `cancel_export` can
/// reach the in-flight export. Split out of `run_gui` purely to keep that function
/// under clippy's function-length lint.
/// Inverse of `export_dialog.slint`'s "Colour Space" pill selector
/// (sRGB/Display P3/Rec.2020 at indices 0-2) -- same fixed-list-with-fallback treatment
/// as `local_preview_scale_from_index` above. Falls back to `ColorSpace::Srgb` (index
/// 0, the required default -- see `bridge::export_thread`'s module doc comment on why
/// that space's output must stay byte-identical to before this control existed) for
/// any value the fixed pill selector itself can never actually send.
///
/// `ColorSpace::AcesCg` has no index here at all -- it is not offered by the picker,
/// see `export_dialog.slint`'s own doc comment for why a scene-linear space doesn't
/// belong in an 8-bit PNG export.
pub(in crate::gui) const fn color_space_from_index(index: i32) -> ColorSpace {
    match index {
        1 => ColorSpace::DisplayP3,
        2 => ColorSpace::Rec2020,
        _ => ColorSpace::Srgb,
    }
}

/// Every built-in material the Render Material `ComboBox` should offer, sorted
/// alphabetically (case-insensitive).
///
/// `GemMaterial::all_materials()` -- not a second, hand-maintained name list -- is the
/// single source of truth for "every built-in species exists"; this function only
/// decides the display order. Alphabetical is safe for persisted settings because
/// `AppSettings::selected_material` stores the NAME, not an index (custom materials
/// are appended after this list by `refresh_material_options`).
fn built_in_material_option_names() -> Vec<String> {
    let mut names: Vec<String> = GemMaterial::all_materials()
        .into_iter()
        .map(|m| m.name)
        .collect();
    names.sort_by_key(|a| a.to_ascii_lowercase());
    names
}

pub(in crate::gui) fn refresh_material_options(ui: &MainWindow, custom_mats: &[GemMaterial]) {
    let mut names = built_in_material_option_names();
    for m in custom_mats {
        if !names.iter().any(|n| n.eq_ignore_ascii_case(&m.name)) {
            names.push(m.name.clone());
        }
    }
    let model: Vec<SharedString> = names.into_iter().map(std::convert::Into::into).collect();
    ui.global::<ViewportModel>()
        .set_material_options(std::rc::Rc::new(slint::VecModel::from(model)).into());
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn bounces_index_round_trips_all_six_pill_values() {
        assert_eq!(bounces_index(4), 0);
        assert_eq!(bounces_index(8), 1);
        assert_eq!(bounces_index(12), 2);
        assert_eq!(bounces_index(24), 3);
        assert_eq!(bounces_index(64), 4);
        assert_eq!(bounces_index(128), 5);
    }

    #[test]
    fn bounces_index_picks_the_nearest_rung_for_unknown_values() {
        // The retired 16-bounce rung from the old ladder: 4 away from 12, 8 away
        // from 24, so it lands on the same index (2) the old blanket fallback used
        // to give it -- but via nearest-distance, not a hardcoded default.
        assert_eq!(bounces_index(16), 2);
        // Below the lowest rung and above the highest rung both clamp to the nearest
        // end of the ladder rather than falling back to the default (12).
        assert_eq!(bounces_index(1), 0);
        assert_eq!(bounces_index(999), 5);
        // Roughly equidistant between 24 and 64 (20 either way) -- ties resolve to
        // whichever rung is checked first in `RUNGS`, i.e. the lower one.
        assert_eq!(bounces_index(44), 3);
    }

    #[test]
    fn resolution_index_round_trips_all_four_pill_values() {
        assert_eq!(resolution_index(640, 480), 0);
        assert_eq!(resolution_index(800, 600), 1);
        assert_eq!(resolution_index(1280, 720), 2);
        assert_eq!(resolution_index(1920, 1080), 3);
    }

    #[test]
    fn resolution_index_falls_back_to_800x600_for_unknown_values() {
        assert_eq!(resolution_index(1, 1), 1);
        assert_eq!(resolution_index(3840, 2160), 1);
        // A mismatched pair (e.g. a hand-edited file with one dimension changed but not
        // the other) must not accidentally match a pill via one coordinate alone.
        assert_eq!(resolution_index(640, 600), 1);
    }

    #[test]
    fn local_preview_scale_index_round_trips_all_three_pill_values() {
        for scale in [
            LocalPreviewScale::Off,
            LocalPreviewScale::Half,
            LocalPreviewScale::Quarter,
        ] {
            let idx = local_preview_scale_index(scale);
            assert_eq!(
                local_preview_scale_from_index(idx),
                scale,
                "scale={scale:?}"
            );
        }
        assert_eq!(local_preview_scale_index(LocalPreviewScale::Off), 0);
        assert_eq!(local_preview_scale_index(LocalPreviewScale::Half), 1);
        assert_eq!(local_preview_scale_index(LocalPreviewScale::Quarter), 2);
    }

    #[test]
    fn local_preview_scale_from_index_falls_back_to_off_for_unknown_values() {
        assert_eq!(local_preview_scale_from_index(-1), LocalPreviewScale::Off);
        assert_eq!(local_preview_scale_from_index(99), LocalPreviewScale::Off);
    }

    #[test]
    fn local_compute_target_index_round_trips_all_three_pill_values() {
        for target in [
            LocalComputeTarget::Cpu,
            LocalComputeTarget::CpuGpu,
            LocalComputeTarget::Gpu,
        ] {
            let idx = local_compute_target_index(target);
            assert_eq!(
                local_compute_target_from_index(idx),
                target,
                "target={target:?}"
            );
        }
        assert_eq!(local_compute_target_index(LocalComputeTarget::Cpu), 0);
        assert_eq!(local_compute_target_index(LocalComputeTarget::CpuGpu), 1);
        assert_eq!(local_compute_target_index(LocalComputeTarget::Gpu), 2);
    }

    #[test]
    fn local_compute_target_from_index_falls_back_to_cpu_gpu_for_unknown_values() {
        assert_eq!(
            local_compute_target_from_index(-1),
            LocalComputeTarget::CpuGpu
        );
        assert_eq!(
            local_compute_target_from_index(99),
            LocalComputeTarget::CpuGpu
        );
    }

    #[test]
    fn color_space_from_index_maps_all_three_pill_values() {
        assert_eq!(color_space_from_index(0), ColorSpace::Srgb);
        assert_eq!(color_space_from_index(1), ColorSpace::DisplayP3);
        assert_eq!(color_space_from_index(2), ColorSpace::Rec2020);
    }

    #[test]
    fn color_space_from_index_falls_back_to_srgb_for_unknown_values() {
        assert_eq!(color_space_from_index(-1), ColorSpace::Srgb);
        assert_eq!(color_space_from_index(99), ColorSpace::Srgb);
    }

    #[test]
    fn find_option_index_locates_an_exact_match() {
        let options: ModelRc<SharedString> = ModelRc::new(VecModel::from(vec![
            SharedString::from("Diamond"),
            SharedString::from("Sapphire"),
            SharedString::from("Ruby"),
        ]));
        assert_eq!(find_option_index(&options, "Sapphire"), Some(1));
        assert_eq!(find_option_index(&options, "Ruby"), Some(2));
    }

    #[test]
    fn find_option_index_returns_none_when_absent_rather_than_guessing() {
        let options: ModelRc<SharedString> =
            ModelRc::new(VecModel::from(vec![SharedString::from("Diamond")]));
        assert_eq!(find_option_index(&options, "Moissanite"), None);
    }

    #[test]
    fn find_option_index_on_an_empty_model_returns_none() {
        let options: ModelRc<SharedString> =
            ModelRc::new(VecModel::from(Vec::<SharedString>::new()));
        assert_eq!(find_option_index(&options, "anything"), None);
    }
}