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
//! Material/RI and gear-tooth view-model helpers: the built-in and design material
//! combo lists, the RI-source explanation text, and the gear preset/remap-preview
//! helpers the design settings panel's gear control uses.

use super::core::{EditorState, GEAR_PRESETS};
use crate::GearRemapRow;
use indicatrix::optics::materials::GemMaterial;
use indicatrix_cut_core::{Design, Edit, MaterialSelection, RemapRounding};

impl EditorState {
    /// [`design_material_options`], rebuilt only when `custom`'s own name list has
    /// changed since the last call -- see [`MaterialComboCache`]'s own doc comment.
    /// `view::refresh_design_settings` calls this every refresh; only a save/delete/
    /// rename in the material editor dialog (which changes `custom`'s names) actually
    /// pays for a rebuild.
    pub(in crate::gui::editor) fn material_combo_options(
        &self,
        custom: &[GemMaterial],
    ) -> Vec<String> {
        let signature: Vec<String> = custom.iter().map(|m| m.name.clone()).collect();
        let mut cache = self.material_combo_cache.borrow_mut();
        if cache.signature.as_ref() != Some(&signature) {
            cache.options = design_material_options(custom);
            cache.signature = Some(signature);
        }
        cache.options.clone()
    }
}

/// [`EditorState::material_combo_cache`]'s contents: [`design_material_options`]'s
/// last result, plus the custom-material name list (in `custom_materials` order) it
/// was built from.
///
/// `view::refresh_design_settings` only calls
/// `design_material_options(&ctx.custom_materials)` again when the combo's actual
/// contents change -- a custom material saved, deleted, or renamed in the
/// material editor dialog -- rather than on every editor refresh: every other
/// refresh (an angle nudge, a solve, a tier edit) would otherwise rebuild the same
/// now-33-plus-customs-entry `Vec<String>` for nothing. `signature` is only the
/// NAME list, not the full `GemMaterial`: `design_material_options` lists names
/// only (an RI/absorption edit to an existing custom material changes no name in
/// the list, so it cannot change this combo's contents either).
///
/// `signature` is `None` until the first build, not a plain `Vec` default: with no
/// custom materials in the vault the incoming name list is empty, which would
/// equal an empty default signature, so the cache would never rebuild and the
/// combo would receive an EMPTY model -- Slint's `ComboBox` then clears
/// `current-value` to `""`, which shows as a blank Material box for every loaded
/// design.
#[derive(Default)]
pub(in crate::gui::editor) struct MaterialComboCache {
    signature: Option<Vec<String>>,
    options: Vec<String>,
}

/// The material `ComboBox`'s preset names, in EXACT index order -- index 0 is
/// `"(none)"`; every following index is a [`indicatrix_cut_core::MaterialCatalogue`]
/// built-in name, in that catalogue's own order (which is
/// `GemMaterial::all_materials`'s order, unchanged by this function).
///
/// This lists every built-in species the renderer supports, not just a
/// hand-picked THIRTEEN-name subset (`"Diamond"`..`"Cubic Zirconia"`) of the
/// renderer's THIRTY-TWO built-ins -- every garnet, Aquamarine, Morganite,
/// Chrysoberyl (Yellow), Amethyst, Citrine, Peridot, YAG, GGG, Benitoite,
/// Andalusite, Opal, both glasses and Rutile can already be traced in Live
/// Render (`gui::startup_settings::built_in_material_option_names` already reads
/// `GemMaterial::all_materials()` directly), and can also be NAMED on a design
/// or offered by the New Design dialog. `MaterialCatalogue::build(&[])` -- no
/// custom materials, since this is the CAD side's built-in-only picker -- is
/// the one place that decides "every built-in species exists", so this list
/// cannot drift from the renderer's own.
///
/// `new_design_dialog.slint`'s New Design material `ComboBox`
/// binds its `model` to `EditorModel.new_material_options`,
/// pushed from this same function every refresh (`view.rs`) -- see that property's
/// own doc comment (`ui/models/editor.slint`).
pub(in crate::gui::editor) fn builtin_preset_names() -> Vec<String> {
    std::iter::once("(none)".to_string())
        .chain(indicatrix_cut_core::MaterialCatalogue::build(&[]).names())
        .collect()
}

/// [`builtin_preset_names`]'s index for `name` (`None` -> `0`, an unrecognized name
/// -> `0` as a safe fallback rather than an out-of-range `ComboBox` index).
pub(in crate::gui::editor) fn material_index_from_name(name: Option<&str>) -> i32 {
    let names = builtin_preset_names();
    name.and_then(|n| names.iter().position(|p| p == n))
        .map_or(0, |i| i as i32)
}

/// The inverse of [`material_index_from_name`]: the name at `index` in
/// [`builtin_preset_names`], or `None` for index `0` ("(none)") or an out-of-range
/// index (defensive only -- `EditorView`'s `ComboBox` can never produce one).
pub(in crate::gui::editor) fn material_name_from_index(index: i32) -> Option<String> {
    let names = builtin_preset_names();
    usize::try_from(index)
        .ok()
        .and_then(|i| names.get(i).cloned())
        .filter(|name| name != "(none)")
}

/// Parses the Yield form's three fields into
/// [`indicatrix_cut_core::Edit::SetGirdleDiameterMm`]/[`indicatrix_cut_core::Edit::SetMaterial`]'s
/// payloads. Pure and unit tested directly. An empty `girdle_diameter_mm`/
/// `specific_gravity_override` field parses to `None` (the "unset, use the preset's
/// own figure" state), never an error: blank is a valid, deliberate choice here,
/// unlike the preform's three fields, which always name a real dimension.
///
/// The returned [`MaterialSelection`] is `current.with_specific_gravity_override(..)`
/// -- `name`/`refractive_index_override` always carry through from `current`
/// unchanged. Deriving `name` from `material_index` against
/// [`builtin_preset_names`] (built-ins only) instead would silently rename a
/// custom catalogue material to "(none)" (since `material_index_from_name` has
/// no custom entry to map it to) and would drop the RI override on every
/// "Apply Yield Inputs" click, even one that only touched the girdle diameter --
/// the Yield tab's own Material combo can only ever name a built-in preset or
/// "(none)", so it has no way to name a custom material correctly either.
/// `_material_index` is consequently unread: it stays a
/// parameter (prefixed `_`) only so this signature keeps matching the
/// existing `on_apply_yield_inputs` call site; the combo itself is
/// inert for naming purposes here (changing it and clicking Apply does not rename the
/// design's material) -- redesigning or removing that
/// control is a separate UI decision.
pub(in crate::gui::editor) fn parse_yield_form(
    girdle_diameter_mm: &str,
    _material_index: i32,
    specific_gravity_override: &str,
    current: &MaterialSelection,
) -> Result<(Option<f64>, MaterialSelection), String> {
    let girdle_diameter_mm = if girdle_diameter_mm.trim().is_empty() {
        None
    } else {
        let value: f64 = girdle_diameter_mm.trim().parse().map_err(|_| {
            format!(
                "Girdle diameter '{}' is not a number.",
                girdle_diameter_mm.trim()
            )
        })?;
        if !value.is_finite() || value <= 0.0 {
            return Err("Girdle diameter must be a positive, finite number.".to_string());
        }
        Some(value)
    };

    let specific_gravity_override = if specific_gravity_override.trim().is_empty() {
        None
    } else {
        let value: f64 = specific_gravity_override.trim().parse().map_err(|_| {
            format!(
                "Specific gravity '{}' is not a number.",
                specific_gravity_override.trim()
            )
        })?;
        if !value.is_finite() || value <= 0.0 {
            return Err("Specific gravity override must be a positive, finite number.".to_string());
        }
        Some(value)
    };

    Ok((
        girdle_diameter_mm,
        current.with_specific_gravity_override(specific_gravity_override),
    ))
}

/// Explains what `Design::effective_refractive_index_with`'s current result (shown
/// as "Eff. RI" in the design settings panel) actually IS and where it came from,
/// so a cutter picking a custom catalogue material
/// can tell whether the critical angle/MARGIN/render/export are using
/// that material's real index or a stale fallback, because nothing on screen named
/// the source. Mirrors `Design::effective_refractive_index_with`'s own precedence
/// exactly (typed override, then a custom catalogue material by name, then a
/// built-in preset, then the design's legacy imported `I` line) so this text can
/// never disagree with the number it explains.
#[must_use]
pub(in crate::gui::editor) fn ri_source_text(
    material: &MaterialSelection,
    custom: &[GemMaterial],
) -> String {
    if let Some(value) = material.refractive_index_override {
        return format!("typed override ({value:.4})");
    }
    if let Some(name) = material.name.as_deref() {
        if custom.iter().any(|gem| gem.name.eq_ignore_ascii_case(name)) {
            return format!("custom catalogue material '{name}'");
        }
        if indicatrix_cut_core::built_in_refractive_index(name).is_some() {
            return format!("built-in material '{name}'");
        }
        return format!(
            "'{name}' is not a recognized material -- falling back to this design's legacy imported value"
        );
    }
    "no material selected -- using this design's legacy imported value".to_string()
}

/// Appended to a custom material's own name when it collides
/// case-insensitively with a built-in already in the combo (`design_material_options`'s
/// list order guarantees the built-in's own plain entry always comes first, from
/// [`builtin_preset_names`]), so the combo shows a second, clearly-labeled entry
/// instead of silently dropping the custom material from the list entirely -- a
/// cutter selecting "Diamond" can tell that a custom "Diamond"
/// exists too, and that `EditorMaterialLookup`'s custom-over-built-in
/// precedence (`material_lookup.rs`) means it is the one actually rendered.
/// [`design_material_name_from_index`] strips this suffix back off before it ever
/// becomes a real [`MaterialSelection::name`] -- see that function's own doc comment.
const CUSTOM_BUILTIN_COLLISION_SUFFIX: &str = " (custom)";

/// The design settings panel's material combo, in the EXACT order it must list:
/// [`builtin_preset_names`] verbatim, then `custom`'s own names -- labeled with
/// [`CUSTOM_BUILTIN_COLLISION_SUFFIX`] for any that collides
/// case-insensitively with a built-in already listed, rather than skipped outright
/// -- then a final `"Custom RI…"` sentinel -- see [`design_material_name_from_index`]
/// for what selecting it means. Pure and cheap enough to call directly for a
/// one-off need; `view::refresh_design_settings`'s own per-refresh call instead goes
/// through [`EditorState::material_combo_options`], which caches this result and
/// only re-derives it when `custom`'s own name list has actually changed.
pub(in crate::gui::editor) fn design_material_options(custom: &[GemMaterial]) -> Vec<String> {
    let mut options: Vec<String> = builtin_preset_names();
    for material in custom {
        if options
            .iter()
            .any(|name| name.eq_ignore_ascii_case(&material.name))
        {
            options.push(format!(
                "{}{CUSTOM_BUILTIN_COLLISION_SUFFIX}",
                material.name
            ));
        } else {
            options.push(material.name.clone());
        }
    }
    options.push("Custom RI\u{2026}".to_string());
    options
}

/// The inverse of [`design_material_name_from_index`]: `options`'s index for `name`
/// (case-insensitive, matched against the REAL name -- a labeled entry's own suffix is
/// stripped before comparing, so a stored `MaterialSelection::name` of "Diamond" still
/// finds a match even if the only remaining occurrence in `options` were the labeled
/// one), or `0` ("(none)") when `name` is absent or not found.
pub(in crate::gui::editor) fn design_material_index_from_name(
    name: Option<&str>,
    options: &[String],
) -> i32 {
    name.and_then(|n| {
        options.iter().position(|o| {
            o.strip_suffix(CUSTOM_BUILTIN_COLLISION_SUFFIX)
                .unwrap_or(o)
                .eq_ignore_ascii_case(n)
        })
    })
    .map_or(0, |i| i as i32)
}

/// The name at `index` in `options`, or `None` for index `0` ("(none)"), the trailing
/// `"Custom RI…"` sentinel, or an out-of-range index. Selecting `"Custom RI…"`
/// clears `MaterialSelection::name` the same way "(none)" does -- it exists so a
/// species with no built-in or catalogue preset (e.g. garnet) can still be given a
/// real, typed refractive index without pretending to pick a preset that isn't used.
///
/// A [`CUSTOM_BUILTIN_COLLISION_SUFFIX`]-labeled entry
/// (`"Diamond (custom)"`) is stripped back to the real material name (`"Diamond"`)
/// here -- the label is a DISPLAY convenience only; the stored
/// [`MaterialSelection::name`] must stay the real name so it keeps resolving through
/// `super::material_lookup::EditorMaterialLookup`'s custom-over-built-in precedence
/// exactly like the plain built-in entry does (both name the same real material,
/// since the label exists only to show the collision, not to distinguish two
/// different selections).
pub(super) fn design_material_name_from_index(index: i32, options: &[String]) -> Option<String> {
    usize::try_from(index)
        .ok()
        .and_then(|i| options.get(i))
        .filter(|&name| name != "(none)" && name != "Custom RI\u{2026}")
        .map(|name| {
            name.strip_suffix(CUSTOM_BUILTIN_COLLISION_SUFFIX)
                .map_or_else(|| name.clone(), str::to_string)
        })
}

/// Parses the design settings panel's material combo index plus its RI override text
/// field into a new [`MaterialSelection`] -- reads `current` first and carries its
/// `specific_gravity_override` through unchanged (that stays the Yield panel's own
/// field), matching [`Edit::SetMaterial`]'s "wholesale, not per-field" contract. An
/// empty override field means `None` (use the resolved material's own `n_D`); a
/// non-blank field must parse as a finite refractive index strictly greater than 1.0
/// (no real gem material has RI <= 1.0).
pub(in crate::gui::editor) fn parse_design_material_form(
    combo_index: i32,
    ri_override_text: &str,
    options: &[String],
    current: &MaterialSelection,
) -> Result<MaterialSelection, String> {
    let refractive_index_override = if ri_override_text.trim().is_empty() {
        None
    } else {
        let value: f64 = ri_override_text.trim().parse().map_err(|_| {
            format!(
                "Refractive index '{}' is not a number.",
                ri_override_text.trim()
            )
        })?;
        if !value.is_finite() || value <= 1.0 {
            return Err(
                "Refractive index override must be a finite number greater than 1.0.".to_string(),
            );
        }
        Some(value)
    };
    Ok(MaterialSelection {
        name: design_material_name_from_index(combo_index, options),
        specific_gravity_override: current.specific_gravity_override,
        refractive_index_override,
    })
}

/// The gear combo's index for `gear_teeth` -- a position in [`GEAR_PRESETS`] when it
/// matches exactly, else `GEAR_PRESETS.len()` (the trailing "Custom" entry), matching
/// [`gear_choice_to_teeth`]'s inverse mapping.
pub(in crate::gui::editor) fn gear_index_from_teeth(gear_teeth: i32) -> i32 {
    GEAR_PRESETS
        .iter()
        .position(|&g| g == gear_teeth)
        .map_or(GEAR_PRESETS.len() as i32, |i| i as i32)
}

/// The inverse of [`gear_index_from_teeth`]: resolves the gear combo's selected index
/// (plus, only for the trailing "Custom" entry, the paired text field) to a real gear
/// tooth count. `preset_index` outside `0..=GEAR_PRESETS.len()` is defensive-only and
/// falls back to reading `custom_text`, same as an explicit "Custom" pick.
pub(in crate::gui::editor) fn gear_choice_to_teeth(
    preset_index: i32,
    custom_text: &str,
) -> Result<i32, String> {
    if let Ok(i) = usize::try_from(preset_index)
        && let Some(&teeth) = GEAR_PRESETS.get(i)
    {
        return Ok(teeth);
    }
    let teeth: i32 = custom_text.trim().parse().map_err(|_| {
        format!(
            "Gear tooth count '{}' is not a whole number.",
            custom_text.trim()
        )
    })?;
    if teeth <= 0 {
        return Err("Gear tooth count must be a positive whole number.".to_string());
    }
    Ok(teeth)
}

/// A real dry run of [`Edit::RemapIndices`] against a scratch clone of `design`
/// (never `design` itself), turned into the [`GearRemapRow`]s the gear-remap
/// confirmation panel shows. `new_indices` comes from a real `apply_edit` call
/// (staying byte-identical to what confirming would actually write), while
/// `non_integral` is computed from the UNROUNDED ratio so it flags a tier whose ideal
/// new position isn't a whole tooth regardless of which `rounding` this preview uses.
pub(in crate::gui::editor) fn gear_remap_preview(
    design: &Design,
    from_gear: i32,
    to_gear: i32,
    rounding: RemapRounding,
) -> Vec<GearRemapRow> {
    let format_indices = |v: &[f64]| v.iter().map(f64::to_string).collect::<Vec<_>>().join(", ");
    let original: Vec<String> = design
        .tiers
        .iter()
        .map(|t| format_indices(&t.indices))
        .collect();
    // Same magnitude-only ratio `Edit::RemapIndices` itself applies -- computing it
    // here from the signed tooth counts is what made this preview disagree with the
    // edit for a design whose `.asc` header carries a negative `g`.
    let ratio = indicatrix_cut_core::remap_ratio(from_gear, to_gear);
    let non_integral: Vec<bool> = design
        .tiers
        .iter()
        .map(|t| t.indices.iter().any(|&i| (i * ratio).fract() != 0.0))
        .collect();

    let mut remapped = design.clone();
    if remapped
        .apply_edit(Edit::RemapIndices {
            from_gear,
            to_gear,
            rounding,
        })
        .is_err()
    {
        // `RemapIndices` names no tier index, so `apply_edit` cannot fail for it.
        // Defensive only: an empty preview reads as "nothing to remap" rather than
        // panicking on a future change to that contract.
        return Vec::new();
    }

    design
        .tiers
        .iter()
        .zip(&remapped.tiers)
        .enumerate()
        .map(|(i, (before, after))| GearRemapRow {
            name: before.name.clone().into(),
            old_indices: original[i].clone().into(),
            new_indices: format_indices(&after.indices).into(),
            non_integral: non_integral[i],
        })
        .collect()
}