indicatrix-cut 0.5.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
//! The design settings panel's refresh ([`refresh_design_settings`]), the
//! "linked to design" viewport material sync ([`sync_viewport_material_link`],
//! [`traced_material_for`]), the material-guess badge, and the Tier tab's
//! per-facet chip row ([`selected_tier_chips`]/[`push_selected_tier_chips`]).

use super::state::{
    EditorState, ScratchDelta, builtin_preset_names, design_material_index_from_name,
    gear_index_from_teeth, index_chip_items, push_rows, ri_source_text,
};
use crate::{
    EditorModel, IndexChipItem, MainWindow, ViewportModel,
    bridge::render_thread::RenderContext,
    gui::editor::material_lookup::{
        EditorMaterialLookup, MATERIAL_MATCH_TOLERANCE, material_for_refractive_index,
        material_guess_candidates, nearest_built_in_material, traced_gem_material,
    },
};
use indicatrix_cut_core::{Design, MaterialLookup, critical_angle_deg};
use slint::{ComponentHandle, Model, ModelRc, SharedString, VecModel};
use std::sync::{Arc, Mutex};

/// The material name the tracer should use for `design`, and -- when there is no
/// honest answer -- the sentence saying why it will not trace at all.
///
/// A design built from an `.asc`, or a brand-new one, carries
/// `MaterialSelection::none()`: the schedule records a refractive index but never a
/// species. Substituting `"Diamond"` in that case would let a quartz design get
/// traced, tilt-swept and HUD-scored at n=2.417 while MARGIN and the critical angle
/// beside them use its real n=1.5442 -- two numbers on screen contradicting each
/// other with no hint why, and angles that window badly in quartz looking fine in
/// the render.
///
/// The rule, preferred over silently substituting anything: use the
/// design's own named material when it has one; otherwise the nearest built-in
/// within [`MATERIAL_MATCH_TOLERANCE`] of its actual refractive index; and when
/// nothing is that close, refuse. A `Some(reason)` suspends both tracing and metrics
/// (see `bridge::render_thread::frame_helpers::SuspensionFlags`), and the reason is
/// shown in place of a simulation nobody should trust.
fn traced_material_for(
    design: &Design,
    custom: &[indicatrix::optics::materials::GemMaterial],
) -> (String, Option<String>) {
    if let Some(name) = &design.material.name {
        // Checked against the SAME catalogue [`EditorMaterialLookup`] resolves
        // through, so this can never disagree with what actually gets traced.
        // Returning `(name.clone(), None)` unconditionally for ANY named selection
        // would let a design naming a custom material since deleted (or an old
        // `.asc`'s hand-typed/typo'd name) trace as "resolved" with no override
        // set -- `sync_viewport_material_link` would then fall through to
        // `resolve_material`'s own by-name lookup, which silently substitutes
        // `materials[0]` (Diamond) for an unrecognized name.
        return if EditorMaterialLookup::new(custom).lookup(name).is_some() {
            (name.clone(), None)
        } else {
            (
                String::new(),
                Some(format!(
                    "This design's material '{name}' is not a built-in preset or a \
                     saved custom material, so there is nothing to trace -- pick a \
                     material in Design Settings, or re-save the missing custom \
                     material, rather than rendering it as something else."
                )),
            )
        };
    }
    let n_d = design.effective_refractive_index();
    if let Some((name, _)) = nearest_built_in_material(n_d, MATERIAL_MATCH_TOLERANCE) {
        return (name, None);
    }
    // The name is left empty deliberately: nothing should trace, so there is no
    // material to name, and a plausible-looking placeholder here is exactly the bug.
    (
        String::new(),
        Some(format!(
            "This design names no material, and its refractive index ({n_d:.4}) matches \
             no built-in preset within {MATERIAL_MATCH_TOLERANCE:.2}. Pick a material in \
             Design Settings -- rendering it as something else would give you the optics \
             of a different stone."
        )),
    )
}

/// The material-guess badge's push half of [`refresh_design_settings`] --
/// split out purely to keep that function under clippy's function-length
/// lint. Inferred material is shown as a guess, never as a fact: when
/// `design.material.name` is `None`
/// (every untouched `.asc` import carries only an `I`-line refractive index,
/// no species), this looks up the nearest built-in preset within tolerance
/// ([`material_for_refractive_index`]) and pushes a guess label plus the
/// OTHER close candidates ([`material_guess_candidates`]) for the tooltip.
/// Once a name IS set, every one of these three properties goes back to
/// `""` -- there is nothing left to guess, and [`super::state::
/// proportion_verdicts`]-style "guess vs fact" confusion is exactly what this
/// exists to prevent.
fn push_material_guess(ui: &MainWindow, design: &Design, n_d: f64) {
    let clear = || {
        ui.global::<EditorModel>()
            .set_material_guess_text(SharedString::new());
        ui.global::<EditorModel>()
            .set_material_guess_name(SharedString::new());
        ui.global::<EditorModel>()
            .set_material_guess_other_candidates_text(SharedString::new());
    };
    if design.material.name.is_some() {
        clear();
        return;
    }
    let Some((name, _)) = material_for_refractive_index(n_d) else {
        clear();
        return;
    };
    ui.global::<EditorModel>()
        .set_material_guess_text(format!("{name}? (from RI {n_d:.2})").into());
    ui.global::<EditorModel>()
        .set_material_guess_name(name.clone().into());
    let others: Vec<String> = material_guess_candidates(n_d, MATERIAL_MATCH_TOLERANCE)
        .into_iter()
        .filter(|(candidate, _)| candidate != &name)
        .map(|(candidate, ri)| format!("{candidate} ({ri:.3})"))
        .collect();
    ui.global::<EditorModel>()
        .set_material_guess_other_candidates_text(if others.is_empty() {
            SharedString::new()
        } else {
            format!("Also within tolerance: {}", others.join(", ")).into()
        });
}

/// Pushes the design settings panel's state (material combo options and index,
/// RI-override/effective-RI/critical-angle readouts, gear/symmetry/mirror) and,
/// while "linked to design" is on, syncs the shared viewport's render material
/// (and its displayed selection/index) to match -- a display override made while
/// unlinked must never be silently overridden. Shared by
/// [`super::panel::refresh_editor_panel_from_solve`]/
/// [`super::panel_stale::push_stale_content`].
///
/// `viewport_material_linked` alone is the real gate a display override needs.
/// Also requiring `render_view_tab == 1` (the Edit tab itself being shown)
/// would tie the render's material to tab history rather than to the design:
/// switching to Live Render and back would never resync anything until the
/// next edit happened to run this function again.
///
/// Returns this design's effective refractive index so
/// [`super::state::tier_items`]/[`super::state::tier_items_stale`] can reuse the
/// identical value for their per-tier margin/risk column rather than
/// re-deriving it.
///
/// `delta` (from [`EditorState::record_scratch_push`], computed once by the
/// caller) gates the material/gear/symmetry scratch pushes independently, so
/// an edit that only changed one of the three never re-seeds -- and so
/// silently discards any in-progress typing/selection in -- the other two's
/// fields.
///
/// `pub(super)`, not private: shared by `panel.rs`/`panel_stale.rs` (both
/// siblings within `view`), neither of which touches the design settings panel
/// directly otherwise.
pub(super) fn refresh_design_settings(
    ui: &MainWindow,
    render_ctx: &Arc<Mutex<RenderContext>>,
    state: &EditorState,
    delta: &ScratchDelta,
) -> f64 {
    let design = &state.design;

    // Scoped so the `RenderContext` lock (a shared resource the render thread
    // also wants every frame) is held only for the work that actually needs
    // it, and is a real `Drop` guard released at the end of this block rather
    // than sitting locked across the `delta`-gated pushes below, which touch
    // only `design`/`ui` -- clippy::nursery's `significant_drop_tightening`.
    let (n_d, options) = {
        let ctx = render_ctx
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        // Custom-catalogue-aware: unlike `effective_refractive_index`,
        // this also resolves a custom material by name before falling back to a built-in
        // or the design's `I` line -- see `ri_source_text` below for the matching
        // "where did this number come from" explanation shown in the inspector.
        let n_d = design.effective_refractive_index_with(&ctx.custom_materials);
        ui.global::<EditorModel>()
            .set_ri_source_text(ri_source_text(&design.material, &ctx.custom_materials).into());
        // Custom materials can change any time, independently of `design` -- this
        // option LIST is always refreshed; only the in-out `*_index`/`*_text`
        // selections below are gated on `delta`.
        let options = state.material_combo_options(&ctx.custom_materials);
        drop(ctx);
        (n_d, options)
    };
    // A second, separate lock acquisition (rather than reusing the guard
    // above): its own last use sits right at this block's own end, so the
    // lock is never held across work -- the `delta`-gated pushes below -- that
    // does not need it either.
    let selected_material_index = {
        let mut ctx = render_ctx
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        sync_viewport_material_link(ui, &mut ctx, design)
    };
    // Set only after the `render_ctx` guard above is dropped -- see
    // `sync_viewport_material_link`'s own doc comment.
    if let Some(idx) = selected_material_index {
        ui.global::<ViewportModel>()
            .set_selected_material_index(idx);
    }
    // `options` is already cached at the `Vec<String>` level
    // (`EditorState::material_combo_options`'s own `MaterialComboCache`, rebuilt
    // only when the catalogue's custom-material names change). Comparing against
    // what is already pushed skips rebuilding a brand-new `ModelRc<VecModel<_>>`
    // from that cached `Vec` (and the Slint-side model reset it triggers) unless
    // the catalogue itself actually changed -- rebuilding unconditionally on every
    // refresh would force the combo box to fully reset its rows on every angle
    // nudge just as much as on an actual material save.
    let current_options = ui.global::<EditorModel>().get_material_combo_options();
    let unchanged = current_options.row_count() == options.len()
        && current_options
            .iter()
            .zip(options.iter())
            .all(|(current, new)| current == new.as_str());
    if !unchanged {
        ui.global::<EditorModel>()
            .set_material_combo_options(ModelRc::new(VecModel::from(
                options
                    .iter()
                    .cloned()
                    .map(SharedString::from)
                    .collect::<Vec<_>>(),
            )));
    }
    // Pushed the same way, so `new_design_dialog.slint`'s New
    // Design material `ComboBox` binds its `model` to `EditorModel.new_material_options`
    // instead of hand-maintaining a literal list in the SAME order as
    // `builtin_preset_names` -- see that function's own doc comment.
    // `builtin_preset_names` never changes at runtime, so this is redundant work on
    // every refresh, not a correctness issue -- matching `material_combo_options`
    // just above rather than adding a one-shot-at-startup special case for one more
    // static list.
    ui.global::<EditorModel>()
        .set_new_material_options(ModelRc::new(VecModel::from(
            builtin_preset_names()
                .into_iter()
                .map(SharedString::from)
                .collect::<Vec<_>>(),
        )));
    if delta.material {
        ui.global::<EditorModel>()
            .set_material_combo_index(design_material_index_from_name(
                design.material.name.as_deref(),
                &options,
            ));
        ui.global::<EditorModel>().set_ri_override_text(
            design
                .material
                .refractive_index_override
                .map_or_else(String::new, |v| format!("{v:.4}"))
                .into(),
        );
    }
    ui.global::<EditorModel>()
        .set_effective_ri_text(format!("{n_d:.4}").into());
    ui.global::<EditorModel>()
        .set_critical_angle_text(format!("{:.2}\u{b0}", critical_angle_deg(n_d)).into());
    push_material_guess(ui, design, n_d);
    if delta.gear {
        ui.global::<EditorModel>()
            .set_gear_index(gear_index_from_teeth(design.meta.gear_teeth));
        ui.global::<EditorModel>()
            .set_gear_custom_text(design.meta.gear_teeth.to_string().into());
    }
    if delta.symmetry {
        ui.global::<EditorModel>()
            .set_symmetry_order_text(design.meta.symmetry_order.to_string().into());
        ui.global::<EditorModel>().set_mirror(design.meta.mirror);
        // Whatever the last typed-but-not-yet-applied preview
        // said is no longer meaningful once the fields are freshly reseeded from
        // the real (just-applied, or freshly loaded) design -- a value that
        // matches the live design has nothing left to preview.
        ui.global::<EditorModel>()
            .set_symmetry_preview_text("".into());
    }
    if delta.meta {
        // `design.meta.headers`' first entry is the design's
        // title (the catalogue round-trip convention -- see `EditorModel.
        // design_title`'s own doc comment, `ui/models/editor.slint`), every
        // further entry an "extra" header line, `';'`-joined back for the
        // form's single-line field -- `apply_design_meta`'s own inverse split.
        let (title, extra_headers) = design
            .meta
            .headers
            .split_first()
            .map_or((String::new(), String::new()), |(title, rest)| {
                (title.clone(), rest.join(";"))
            });
        ui.global::<EditorModel>().set_design_title(title.into());
        ui.global::<EditorModel>()
            .set_design_extra_headers(extra_headers.into());
        ui.global::<EditorModel>()
            .set_design_footnotes(design.meta.footnotes.join(";").into());
        ui.global::<EditorModel>().set_design_gear_reference_angle(
            format!("{}", design.meta.gear_reference_angle).into(),
        );
    }

    n_d
}

/// The design-settings panel's "linked to design" viewport sync -- see
/// [`refresh_design_settings`]'s doc comment for why there is no separate
/// `render_view_tab` gate. Split out purely to keep that function under clippy's
/// function-length lint.
///
/// Returns the Render Material dropdown's
/// resolved index instead of setting `ViewportModel.selected_material_index`
/// itself. That setter's `changed` handler runs a DB query
/// (`custom_materials.rs`), and every caller holds `ctx` -- the `render_ctx`
/// mutex the render thread also locks every frame -- across this function's
/// call; setting the property from inside that guard stalls the render
/// thread on every refresh. Callers must set the returned index themselves,
/// after the guard on `ctx` has been dropped.
#[must_use]
pub(in crate::gui::editor) fn sync_viewport_material_link(
    ui: &MainWindow,
    ctx: &mut RenderContext,
    design: &Design,
) -> Option<i32> {
    if !ui.global::<ViewportModel>().get_viewport_material_linked() {
        return None;
    }
    let (name, unresolved) = traced_material_for(design, &ctx.custom_materials);
    if ctx.material_unresolved != unresolved {
        ctx.material_unresolved.clone_from(&unresolved);
        ctx.dirty = true;
    }
    // The resolved material itself, not merely its name:
    // `context::resolve_material` can only look a name up in the built-in table,
    // so a catalogue custom material or a typed RI override never reached the
    // trace at all. `material_override` is what the tracer, the HUD metrics, the
    // tilt sweep and the hover preview all prefer over that name lookup.
    //
    // Keyed on `name` -- the material this design is traced AS, decided by
    // `traced_material_for` just above -- and NOT on `design.material`. Passing the
    // raw selection here sent it through `MaterialSelection::resolve`, whose
    // no-name fallback is `GemMaterial::diamond()`: every `.asc` import and every
    // new design names no material, so the override was diamond, the override beats
    // the name, and the stone rendered with diamond's (empty) absorption bands --
    // colourless whatever the schedule said. `traced_gem_material` returns `None`
    // instead of substituting, and an absent override falls through to
    // `context::resolve_material`'s own by-name lookup, so the name and the override
    // can no longer describe two different stones.
    let resolved = unresolved
        .is_none()
        .then(|| {
            traced_gem_material(
                &name,
                &design.material,
                &EditorMaterialLookup::new(&ctx.custom_materials),
            )
        })
        .flatten();
    if ctx.material_override != resolved {
        ctx.material_override = resolved;
        ctx.dirty = true;
    }
    if ctx.material_name != name {
        // Cloned, not moved -- `name` is looked up again just below to sync
        // the dropdown's own selected index. `clone_from` reuses the existing
        // allocation instead of dropping it for a fresh one.
        ctx.material_name.clone_from(&name);
        ctx.dirty = true;
    }
    // Keeps the Render Material dropdown's own displayed selection in sync with
    // the material actually being traced on every refresh. Writing this only
    // once, at startup (`startup_settings::apply_saved_settings`), would let the
    // dropdown go on showing e.g. "Diamond" long after `ctx.material_name` (and
    // so the trace, and the tilt dialog's staleness check, both of which read
    // `ViewportModel.selected_material_index`/`material_options`) had moved to
    // something else entirely.
    let options = ui.global::<ViewportModel>().get_material_options();
    let selected_material_index = crate::gui::startup_settings::find_option_index(&options, &name);
    // The design's own real girdle diameter, under the same link gate as the
    // material above -- without this write, `context::apply_material_overrides`
    // would always skip absorption-path scaling (treating every design as if it
    // had no physical size), and the Yield panel's millimetre carat estimate
    // would have no counterpart in the render's own colour depth.
    let stone_width_mm = design.girdle_diameter_mm.unwrap_or(0.0) as f32;
    if (ctx.stone_width_mm - stone_width_mm).abs() > f32::EPSILON {
        ctx.stone_width_mm = stone_width_mm;
        ctx.dirty = true;
    }
    selected_material_index
}

/// Builds the chip row for whichever tier the inspector's Tier tab currently has
/// loaded (`EditorModel.selected_tier_index`; `None` for "Add Tier" or an
/// out-of-range value) -- the per-facet editing surface. Computed
/// fresh from `design` on the UI thread every refresh, for only the one tier
/// being edited, rather than for every row: see [`IndexChipItem`]'s own doc
/// comment for why this can never be a field on the (background-thread-built,
/// `Send`) [`crate::EditorTierItem`] instead.
#[must_use]
fn selected_tier_chips(design: &Design, selected_tier_index: i32) -> Vec<IndexChipItem> {
    usize::try_from(selected_tier_index)
        .ok()
        .and_then(|idx| design.tiers.get(idx))
        .map_or_else(Vec::new, |tier| {
            index_chip_items(&tier.indices, &tier.detached)
        })
}

/// Pushes [`selected_tier_chips`]'s result into `EditorModel.selected_tier_chips`,
/// plus the currently selected tier's own cheater-offset text
/// into `EditorModel.selected_tier_cheater_offset_text` -- shared by
/// [`super::panel::refresh_editor_panel`]/[`super::panel_stale::push_stale_content`]/
/// `callbacks::tier_actions::apply_selected_tier_change` (a plain row-click/
/// facet-click selection change, with no edit of its own) so both stay current
/// after every one of the three ways the selected tier can change, exactly like
/// `tiers` itself.
pub(in crate::gui::editor) fn push_selected_tier_chips(ui: &MainWindow, state: &EditorState) {
    let selected = ui.global::<EditorModel>().get_selected_tier_index();
    let cheater_offset_text = usize::try_from(selected)
        .ok()
        .and_then(|index| state.design.cheater_offset_deg(index))
        .map_or_else(String::new, |deg| format!("{deg:.2}"));
    ui.global::<EditorModel>()
        .set_selected_tier_cheater_offset_text(cheater_offset_text.into());
    let note_text = usize::try_from(selected)
        .ok()
        .and_then(|index| state.design.tier_note(index))
        .map_or_else(String::new, str::to_string);
    ui.global::<EditorModel>()
        .set_selected_tier_note_text(note_text.into());
    let chips = selected_tier_chips(&state.design, selected);
    push_rows(
        &ui.global::<EditorModel>().get_selected_tier_chips(),
        chips,
        |model| ui.global::<EditorModel>().set_selected_tier_chips(model),
    );
}

#[cfg(test)]
mod tests {
    use super::*;
    use indicatrix::geometry::meet_solver::MeetConstraint;
    use indicatrix_cut_core::ConstraintTier;

    fn design_with_named_tiers(names: &[&str]) -> Design {
        let mut state = EditorState::fresh();
        for &name in names {
            state.design.tiers.push(ConstraintTier {
                angle_deg: -40.0,
                name: name.to_string(),
                indices: vec![0.0],
                constraint: MeetConstraint::MeetExisting,
                imported_meet: None,
                original_notes: None,
                detached: Vec::new(),
            });
        }
        state.design
    }

    // --- selected_tier_chips ---

    #[test]
    fn selected_tier_chips_is_empty_when_nothing_is_selected() {
        let design = design_with_named_tiers(&["G1"]);
        assert_eq!(selected_tier_chips(&design, -1).len(), 0);
    }

    #[test]
    fn selected_tier_chips_is_empty_for_an_out_of_range_index() {
        let design = design_with_named_tiers(&["G1"]);
        assert_eq!(selected_tier_chips(&design, 5).len(), 0);
    }

    #[test]
    fn selected_tier_chips_reads_the_selected_tiers_own_indices_and_detached_set() {
        let mut design = design_with_named_tiers(&["G1", "P1"]);
        design.tiers[1].indices = vec![0.0, 24.0];
        design.tiers[1].detached = vec![24.0];
        let chips = selected_tier_chips(&design, 1);
        assert_eq!(chips.len(), 2);
        assert!(!chips[0].detached);
        assert!(chips[1].detached);
    }

    // --- traced_material_for (the missing-name case must
    // refuse, never silently fall through to `resolve_material`'s own
    // materials[0]/Diamond fallback) ---

    #[test]
    fn traced_material_for_names_a_resolvable_built_in_selection() {
        let mut design = EditorState::fresh().design;
        design.material.name = Some("Sapphire".to_string());
        let (name, unresolved) = traced_material_for(&design, &[]);
        assert_eq!(name, "Sapphire");
        assert_eq!(unresolved, None);
    }

    #[test]
    fn traced_material_for_names_a_resolvable_custom_selection() {
        let mut design = EditorState::fresh().design;
        design.material.name = Some("My Garnet".to_string());
        let mut custom = indicatrix::optics::materials::GemMaterial::diamond();
        custom.name = "My Garnet".to_string();
        let (name, unresolved) = traced_material_for(&design, &[custom]);
        assert_eq!(name, "My Garnet");
        assert_eq!(unresolved, None);
    }

    /// The actual bug case this guards against: a design naming a material that
    /// resolves through NEITHER the built-in table NOR the live custom-material
    /// list (a deleted custom material, or a typo'd/hand-edited name) must refuse
    /// -- not report itself resolved and leave `sync_viewport_material_link` to
    /// fall through to `resolve_material`'s silent `materials[0]` (Diamond)
    /// fallback.
    #[test]
    fn traced_material_for_refuses_a_name_no_catalogue_resolves() {
        let mut design = EditorState::fresh().design;
        design.material.name = Some("Deleted Custom Garnet".to_string());
        let (name, unresolved) = traced_material_for(&design, &[]);
        assert_eq!(name, "");
        let reason = unresolved.expect("an unresolvable name must refuse, not substitute");
        assert!(reason.contains("Deleted Custom Garnet"));
    }
}