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
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
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
//! Feeding the shared (GPU path-traced + solid-preview) viewport: the paint-first
//! [`refresh_all_now`]/[`refresh_all`] entry points, the trace-staleness marker,
//! and the replan-on-edit request path ([`submit_preview_replan`]/
//! [`submit_preview_replan_for`]/[`push_solved_preview`]) the solid-preview
//! worker thread and its completion callback use.

use super::{
    SolidLastSolved,
    panel::refresh_editor_panel_from_solve,
    panel_stale::push_stale_content,
    state::{
        EditorState, apply_multi_selection, design_to_gpu_planes,
        manufacturability_warnings_tagged, push_multi_selected_count, push_tiers,
        status_text_and_is_problem_from_solved, tier_items_from_solved,
        yield_report_texts_from_solved,
    },
};
use crate::{
    EditorModel, MainWindow, SolidPreviewModel, TiltModel,
    bridge::render_thread::{PlanesOwner, RenderContext},
    gui::{
        editor::auto_solve,
        render::camera_lighting::contained_request_size,
        solid_preview::preview_state::{CameraPose, ReplanRequest, SolidPreviewState},
    },
};
use indicatrix::geometry::meet_solver::SolvedTier;
use indicatrix_cut_core::Design;
use slint::{ComponentHandle, SharedString};
use std::{
    cell::RefCell,
    collections::BTreeSet,
    rc::Rc,
    sync::{Arc, Mutex, PoisonError, atomic::Ordering as AtomicOrdering},
    time::{Duration, Instant},
};

/// The Solid/Diagram viewport's own size in PHYSICAL pixels, for a raster/pick
/// request. `SolidPreviewModel.viewport_width`/`viewport_height` are LOGICAL (DIP)
/// sizes pushed straight from `solid_viewport.slint`'s own `width`/`height`, so a
/// request built from them one-to-one rasterizes at only one device pixel per
/// logical pixel -- soft on any `HiDPI` display. Multiplying by
/// `ui.window().scale_factor()` here is one half of the `HiDPI` fix; the other half
/// is `callbacks::tier_actions`'s hover/click conversion, which MULTIPLIES the
/// incoming (logical) pointer position by the same factor to reach the physical
/// pixel it names in the resulting (now physically sized) pick buffer.
/// `solid_preview::diagram_wiring` does the same for the diagram's pick buffer.
pub(in crate::gui::editor) fn scaled_viewport_size(ui: &MainWindow) -> (u32, u32) {
    let scale = ui.window().scale_factor();
    (
        (ui.global::<SolidPreviewModel>().get_viewport_width() * scale) as u32,
        (ui.global::<SolidPreviewModel>().get_viewport_height() * scale) as u32,
    )
}

/// Writes `design`'s current plane arrangement into the shared (GPU path-traced)
/// viewport and marks it dirty, then re-issues those same planes to the solid
/// preview -- see this group's `mod.rs` doc comment ("Feeding the viewport") for why
/// this is split out from [`super::panel::refresh_editor_panel`].
///
/// `SolidPreviewState::request_redraw`'s `planes` sign convention (`n . x <= m`) is
/// the OPPOSITE of `design_to_gpu_planes`'s `GpuFacetPlane` (`n . x + d = 0`) --
/// `GpuFacetPlane::to_halfspace_f64`'s `m = -d` flip, reapplied here by hand since
/// `active_planes` is already in `GpuFacetPlane` form.
/// Tells the viewport whether the path-traced image still describes the design on
/// the bench.
///
/// Compares the generation stamped on the planes the render thread actually holds
/// against `state`'s live one. Pushed from both the stale path (every ordinary edit,
/// where it becomes true) and the refresh path (a real solve, where the planes have
/// just been re-pushed and it becomes false again), so the marker never outlives the
/// condition it reports.
///
/// `pub(super)`, not private: [`super::panel_stale::push_stale_content`] (a
/// sibling file) shares this exact push.
pub(super) fn push_trace_staleness(
    ui: &MainWindow,
    render_ctx: &Arc<Mutex<RenderContext>>,
    state: &EditorState,
) {
    let generation = state.generation.load(std::sync::atomic::Ordering::Relaxed);
    let stale = {
        let ctx = render_ctx
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        ctx.traced_planes_are_stale(generation)
    };
    ui.global::<crate::ViewportModel>().set_trace_stale(stale);
}

/// `solved` is the SAME solve [`super::panel::refresh_editor_panel`] already
/// computed for this same "Solve" click -- `None` only for a design that does
/// not currently solve at all (a `MissingAnchor`, most commonly), in which case
/// this falls back to the plain, internally-solving forms.
/// Passing it through here avoids a SECOND independent
/// `Design::solve()` on top of `refresh_editor_panel`'s own.
fn refresh_viewport(
    ui: &MainWindow,
    render_ctx: &Arc<Mutex<crate::bridge::render_thread::RenderContext>>,
    preview_state: &Arc<SolidPreviewState>,
    solid_last_solved: &SolidLastSolved,
    state: &EditorState,
    solved: Option<&[SolvedTier]>,
) {
    let mut ctx = render_ctx
        .lock()
        .unwrap_or_else(std::sync::PoisonError::into_inner);
    // The editor always wins a claim, so this cannot fail --
    // but it goes through the same single write path as every other writer so the
    // ownership stamp stays truthful, which is what stops a later catalogue click
    // from silently replacing the design under the cutter's hands.
    let planes_for_claim = solved.map_or_else(
        || design_to_gpu_planes(&state.design),
        |solved| auto_solve::design_to_gpu_planes_from_solved(&state.design, solved),
    );
    ctx.claim_active_planes(
        std::sync::Arc::new(planes_for_claim),
        Some((
            state.design.meta.gear_teeth_abs(),
            state.design.meta.gear_reference_angle as f32,
        )),
        PlanesOwner::Editor {
            generation: state.generation.load(std::sync::atomic::Ordering::Relaxed),
        },
    );
    ctx.dirty = true;
    let design_gear = ctx.design_gear;
    let planes: Vec<(glam::Vec3, f32)> = ctx
        .active_planes
        .iter()
        .map(|p| (glam::Vec3::from(p.normal), -p.d))
        .collect();
    let view_mode = ui.global::<SolidPreviewModel>().get_view_mode() as u8;
    // Letterboxes the request to the traced image's own
    // rectangle in Path-traced/Both, exactly like `camera_lighting::
    // resubmit_at_current_pose` already does for a camera drag/zoom/view-mode
    // switch -- see `contained_request_size`'s own doc comment for why this
    // call site needs the identical treatment.
    let size = contained_request_size(view_mode, scaled_viewport_size(ui), (ctx.width, ctx.height));
    preview_state.request_redraw_with_gear(
        planes,
        CameraPose {
            yaw: ctx.yaw,
            pitch: ctx.pitch,
            distance: ctx.distance,
        },
        size,
        view_mode,
        design_gear,
    );
    drop(ctx);

    // The editor just claimed the shared plane slot for
    // `state.design` -- whatever material name a PREVIOUS occupant (a catalogue
    // preview load, the only writer of this field) left in `cached_curve_material`
    // no longer describes anything, so it must stop being compared against
    // `ctx.material_name` for tilt-dialog staleness. Clearing it here (rather than
    // leaving it to whatever the next catalogue load happens to overwrite it with)
    // means "no cached artefact for this design" is representable instead of an
    // unrelated design's material silently standing in for it.
    ui.global::<TiltModel>()
        .set_cached_curve_material("".into());
    // Geometry just changed under the tilt dialog -- if it's
    // open, its four curves and summary badges are about to describe stale
    // geometry as settled results unless a fresh sweep is requested.
    // `AxesCacheKey` (tilt_profile.rs) already hashes the planes, so this is a
    // no-op resweep whenever nothing about the planes actually moved.
    if ui.global::<TiltModel>().get_dialog_open() {
        ui.global::<TiltModel>().invoke_request_tilt_profile_axes();
    }

    // This is the SAME real design solve (`New`/`Load Selected`/the explicit
    // "Solve" action) `refresh_editor_panel` already computed. Stashing it here,
    // rather than independently re-solving a second time just to populate this
    // cache, is what lets the NEXT small edit's `submit_preview_replan` call use
    // a real `resolve_dirty` subgraph solve rather than falling back to another
    // full solve.
    if let Some(solved) = solved {
        *solid_last_solved
            .lock()
            .unwrap_or_else(PoisonError::into_inner) = Some(solved.to_vec());
    }
    // After the guard above is dropped: the planes the tracer holds were just
    // re-stamped with this generation, so the trace-staleness marker clears here.
    push_trace_staleness(ui, render_ctx, state);
}

/// Submits a replan-on-edit request to the Solid preview's worker thread -- the
/// actual `resolve_dirty`/`Design::solve` call never runs here, on the UI thread.
///
/// `dirty` should name exactly the tier(s) the edit touched when known precisely (a
/// single-tier Save/Remove/detach toggle); leave it empty for an edit that never
/// moves any tier's mast (material/preform/girdle-diameter). Pass
/// `force_full_solve: true` for an edit whose blast radius isn't tracked precisely
/// (Undo/Redo, a gear remap, a symmetry/mirror change): forces a full `solve()`
/// rather than a possibly-wrong subgraph `resolve_dirty` -- always safe, only
/// potentially slower.
///
/// Also stamps the request with `state.generation`'s current value and stashes a
/// matching `Design`/`multi_selected` snapshot via `auto_solve::
/// stash_current_design` -- see [`push_solved_preview`]/
/// `auto_solve::take_matching_design` for the consuming half once the worker's
/// frame lands back in `gui::SlintSolidSink::apply`.
pub(in crate::gui::editor) fn submit_preview_replan(
    ui: &MainWindow,
    render_ctx: &Arc<Mutex<RenderContext>>,
    preview_state: &Arc<SolidPreviewState>,
    solid_last_solved: &SolidLastSolved,
    state: &EditorState,
    dirty: BTreeSet<usize>,
    force_full_solve: bool,
) {
    submit_preview_replan_for(
        ui,
        render_ctx,
        preview_state,
        solid_last_solved,
        ReplanSource {
            design: &state.design,
            generation: state.generation.load(AtomicOrdering::Relaxed),
            multi_selected: &state.multi_selected,
        },
        dirty,
        force_full_solve,
    );
}

/// [`submit_preview_replan`]'s `design`/`generation`/`multi_selected` inputs,
/// bundled (rather than three more parameters on [`submit_preview_replan_for`])
/// purely to keep that function under clippy's argument-count lint -- the same
/// reasoning `callbacks::tier_actions::LoadedDesignOutcome`/`auto_solve::
/// PanelInputs` already use for themselves.
#[derive(Clone, Copy)]
pub(in crate::gui::editor) struct ReplanSource<'a> {
    pub(in crate::gui::editor) design: &'a Design,
    pub(in crate::gui::editor) generation: u64,
    pub(in crate::gui::editor) multi_selected: &'a BTreeSet<usize>,
}

/// [`submit_preview_replan`]'s own body, taking a [`ReplanSource`] SNAPSHOT
/// rather than a live `&EditorState` -- split out so
/// `auto_solve::schedule_idle_replan_if_stale` can resubmit a follow-up replan
/// once the solid-preview worker goes idle after a partial (subgraph) resolve,
/// without needing the live `Rc<RefCell<EditorState>>` that module deliberately
/// never holds (see its own doc comment, "Why a `thread_local!`, not a new
/// `EditorState` field"). [`submit_preview_replan`] itself is the thin,
/// `EditorState`-shaped wrapper every other caller keeps using.
pub(in crate::gui::editor) fn submit_preview_replan_for(
    ui: &MainWindow,
    render_ctx: &Arc<Mutex<RenderContext>>,
    preview_state: &Arc<SolidPreviewState>,
    solid_last_solved: &SolidLastSolved,
    source: ReplanSource<'_>,
    dirty: BTreeSet<usize>,
    force_full_solve: bool,
) {
    let ReplanSource {
        design,
        generation,
        multi_selected,
    } = source;
    let last_solved = if force_full_solve {
        None
    } else {
        solid_last_solved
            .lock()
            .unwrap_or_else(PoisonError::into_inner)
            .clone()
    };
    let (camera, render_size, n_d) = {
        let ctx = render_ctx
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        (
            CameraPose {
                yaw: ctx.yaw,
                pitch: ctx.pitch,
                distance: ctx.distance,
            },
            (ctx.width, ctx.height),
            // `_with` resolves a CUSTOM
            // catalogue material by name too, not only a built-in preset -- the
            // bare accessor silently falls back to 1.5442 (fused quartz) for any
            // design named after a custom material, which the tilt overlay this
            // `n_d` feeds (`tier_items`'s critical-angle margin column) would then
            // measure against the wrong critical angle with nothing on screen
            // saying so.
            design.effective_refractive_index_with(&ctx.custom_materials),
        )
    };
    let selected_tier = usize::try_from(ui.global::<EditorModel>().get_selected_tier_index()).ok();
    let view_mode = ui.global::<SolidPreviewModel>().get_view_mode() as u8;
    // See `refresh_viewport`'s identical call, just above,
    // for why -- this is the post-edit path `contained_request_size`'s own doc
    // comment names as still needing the same treatment.
    let size = contained_request_size(view_mode, scaled_viewport_size(ui), render_size);
    let show_preform = ui.global::<SolidPreviewModel>().get_show_preform_planes();
    let enlarged_panel = ui
        .global::<SolidPreviewModel>()
        .get_diagram_enlarged_panel();
    // ONE `Arc<Design>` snapshot per drained edit-intent frame, shared (via
    // cheap `Arc::clone`, never a second deep clone) between this
    // stash and the replan request below. `auto_solve::take_matching_design` can
    // hand this SAME snapshot back to `editor::apply_matching_preview_frame` once
    // a solid-preview frame lands claiming this exact generation -- see that
    // function's own doc comment for why this cannot instead be read straight off
    // `state` from there (`gui::SlintSolidSink::apply` runs off the UI thread it
    // hops back onto, `Send`-bound, and can never reach this
    // `Rc<RefCell<EditorState>>`).
    //
    // `ReplanRequest::design` is `Arc<Design>` (`solid_preview::
    // preview_state`/`live_update`): `Arc::clone` below shares this exact
    // allocation with `design_snapshot` rather than cloning `design` a second
    // time (`(*design_snapshot).clone()`) to build a plain, non-`Arc` field.
    // Sharing the allocation all the way through `PlanJob`/`PlannedFrame` to the
    // render worker means a burst of coalesced replan requests (an angle-nudge
    // drag, most commonly) clones the design at most once per generation, not
    // once per request.
    let design_snapshot = Arc::new(design.clone());
    auto_solve::stash_current_design(
        generation,
        Arc::clone(&design_snapshot),
        multi_selected.clone(),
    );
    // Read here rather than cached on `SolidPreviewState` alone, so the
    // slider and the redraw can never disagree about how much of the schedule is
    // being shown. `-1` (the default) means the whole design.
    let cutoff = ui.global::<SolidPreviewModel>().get_tier_cutoff();
    preview_state.set_tier_cutoff(usize::try_from(cutoff).ok());
    preview_state.request_replan(ReplanRequest {
        design: Arc::clone(&design_snapshot),
        dirty,
        last_solved,
        camera,
        size,
        selected_tier,
        n_d,
        view_mode,
        generation,
        show_preform,
        enlarged_panel,
    });
}

/// Pushes the tier table's rows, the validation banner, the
/// manufacturability warnings and the yield figures straight from a solid-preview
/// frame's own already-solved `solved` masts, once `auto_solve::
/// take_matching_design` has confirmed the frame's own generation still names the
/// live design -- see that function's own doc comment for the staleness check.
/// `design`/`multi_selected` are the plain clones that same call handed back.
///
/// Deliberately narrower than [`super::panel::refresh_editor_panel`]/
/// [`super::panel_stale::push_stale_content`]: proportions, the cutting
/// schedule, preform/material scratch fields and Deep Solve/Optimize
/// availability are all either solve-independent (already current, pushed
/// synchronously by [`super::panel_stale::refresh_editor_panel_stale`] at edit
/// time) or not pushed by this mechanism -- only the four fields that
/// [`auto_solve::dispatch_background_solve`]'s OWN completion would otherwise
/// have been the sole source of.
///
/// Called from `gui::SlintSolidSink::apply` -- a solid-preview WORKER-thread
/// callback hopped onto the UI thread via `slint::Weak::upgrade_in_event_loop` --
/// through `editor::apply_matching_preview_frame`'s thin forwarding wrapper, the
/// one bridge this group exposes beyond [`super::super::setup_editor_callbacks`]
/// itself (see that module's own doc comment, "Module split").
///
/// `custom_materials`: resolves `design`'s
/// effective refractive index the SAME custom-catalogue-aware way
/// [`super::inspector::refresh_design_settings`]/`auto_solve::panel_inputs`
/// already do -- the bare accessor would silently fall back to 1.5442 for a
/// design named after a custom catalogue material, leaving the tier table's
/// critical-angle margin column disagreeing with the Design Settings panel's
/// own effective-RI readout for exactly that design.
///
/// `custom_sg`: the catalogue's custom-material specific-
/// gravity table, handed to [`yield_report_texts_from_solved`] for the same
/// reason `auto_solve::panel_inputs`/`super::panel::push_yield_and_proportions`
/// do -- see that function's own doc comment.
pub(in crate::gui::editor) fn push_solved_preview(
    ui: &MainWindow,
    design: &Design,
    solved: &[SolvedTier],
    multi_selected: &BTreeSet<usize>,
    custom_materials: &[indicatrix::optics::materials::GemMaterial],
    custom_sg: &[(String, f64)],
) {
    let n_d = design.effective_refractive_index_with(custom_materials);
    let mut tiers = tier_items_from_solved(design, solved, n_d);
    apply_multi_selection(&mut tiers, multi_selected);
    push_tiers(ui, tiers);
    push_multi_selected_count(ui, multi_selected.len());

    let (status_text, is_problem) = status_text_and_is_problem_from_solved(design, solved);
    ui.global::<EditorModel>()
        .set_status_text(status_text.into());
    ui.global::<EditorModel>().set_status_is_problem(is_problem);
    ui.global::<EditorModel>()
        .set_solve_state(if is_problem { "failed" } else { "solved" }.into());
    // A third route to "solved" (after `refresh_all` and the background solve's
    // own completion): let the guide's "Solve and check" step see it, AFTER the
    // verdict above is in place.
    crate::gui::editor::guide::check_design_progress(ui, design);

    // The tagged pairs, not `manufacturability_warning_lines_
    // from_solved`'s flattened text-only list, so the tier index survives to
    // `manufacturability_warning_tiers` -- `editor_tier_table.slint`'s own row
    // markers read this to flag the specific row a warning is about.
    let tagged_warnings = manufacturability_warnings_tagged(design, Some(solved));
    let warning_tiers: Vec<i32> = tagged_warnings
        .iter()
        .map(|(index, _)| i32::try_from(*index).unwrap_or(i32::MAX))
        .collect();
    let warnings: Vec<SharedString> = tagged_warnings
        .into_iter()
        .map(|(_, text)| SharedString::from(text))
        .collect();
    super::state::push_rows(
        &ui.global::<EditorModel>().get_manufacturability_warnings(),
        warnings,
        |model| {
            ui.global::<EditorModel>()
                .set_manufacturability_warnings(model);
        },
    );
    super::state::push_rows(
        &ui.global::<EditorModel>()
            .get_manufacturability_warning_tiers(),
        warning_tiers,
        |model| {
            ui.global::<EditorModel>()
                .set_manufacturability_warning_tiers(model);
        },
    );

    let (vol_yield_text, carat_text, sg_used_text, fit_text) =
        yield_report_texts_from_solved(design, solved, custom_sg);
    ui.global::<EditorModel>()
        .set_volumetric_yield_text(vol_yield_text.into());
    ui.global::<EditorModel>()
        .set_carat_weight_text(carat_text.into());
    ui.global::<EditorModel>()
        .set_specific_gravity_used_text(sg_used_text.into());
    ui.global::<EditorModel>()
        .set_preform_fit_warning(fit_text.into());
}

/// [`super::panel::refresh_editor_panel`] + [`refresh_viewport`] together --
/// pushes a real solve's result into both the panel and the shared viewport.
/// Only called by `New`, `Load Selected`, "Adopt", and the explicit "Solve"
/// action; every other edit callback calls
/// [`super::panel_stale::refresh_editor_panel_stale`] instead and leaves the
/// viewport untouched.
///
/// # Synchronous only for a cheap-enough design
///
/// `Design::solve`'s refinement sweep is cubic in plane count (a real 103-tier/210-
/// plane design: 5.9s -- see `mod.rs`'s "Never block the UI thread with a solve"
/// section), so this only solves inline on the UI thread for a design at or under
/// [`auto_solve::should_solve_synchronously`] -- comfortably fast in practice, and
/// keeps the "New" dialog's promise of an immediately solved, unstale design without
/// a visible "Solving..." flash. A larger design instead gets the SAME stale content
/// [`super::panel_stale::refresh_editor_panel_stale`] pushes after any other edit,
/// plus an immediately (not debounced) dispatched background solve -- see
/// [`auto_solve::dispatch_background_solve`].
///
/// This treats the explicit Solve button/F5 identically to New/Load rather than
/// special-casing it back to always-background regardless of size: a design small
/// enough for this function's synchronous path solves fast enough that forcing it
/// through a worker thread and a `Weak::upgrade_in_event_loop` round trip would only
/// add latency, not remove any UI freeze worth avoiding.
///
/// `wholesale`: `true` only for a caller that just
/// replaced `EditorState` wholesale (New/Load Selected/Open Native) -- see
/// [`auto_solve::reset_for_new_design`]'s own doc comment for why exactly those
/// three (and only those three) need the reset it performs. Every OTHER caller
/// (the explicit "Solve" button, Adopt/Adopt All/Adopt Selected/Pin to
/// Mast/Optimize Apply/Retarget Apply) passes `false`: it is still solving the
/// SAME design the auto-solve budget has already been measuring, so wiping that
/// measurement on every one of those actions would make
/// [`auto_solve::should_solve_synchronously`]'s "prefer a real measurement" rule
/// unreachable on precisely the path (an explicit re-Solve) it exists for --
/// `auto_solve::last_solve()` is read instead, exactly like every other caller of
/// that function already reads a real measurement when one exists.
/// The [`Rc<RefCell<EditorState>>`]-based entry point every OWNED caller
/// (`callbacks::tier_actions`/`callbacks::solve_actions`/`callbacks::
/// retarget_actions`) uses instead of calling [`refresh_all`] directly, so the
/// sync solve branch paints "Solving" before it runs.
///
/// # Why this wraps [`refresh_all`] rather than [`refresh_all`] itself changing
///
/// `native_io::finish_state_replace` also calls
/// [`refresh_all`] directly, passing a plain `&EditorState` obtained from its own
/// `state.borrow()` -- changing [`refresh_all`]'s OWN signature to take
/// `Rc<RefCell<EditorState>>` would break that call site. So [`refresh_all`]
/// keeps its original signature and behaviour completely unchanged (`native_io.rs`
/// is unaffected), and this function is the additional, paint-first entry point
/// every owned call site uses instead.
///
/// # What "paint-first" means here
///
/// [`refresh_all`]'s own sync branch (at or under
/// [`auto_solve::should_solve_synchronously`]) sets `solve_running`/
/// `solve_state` to "solving" and then immediately runs the blocking
/// `Design::solve()` in the same call, with no yield back to the event loop in
/// between -- the toolkit only ever paints the FINAL state once the whole
/// callback returns, so "Solving..." is never actually visible for a fast-sync
/// design (see [`refresh_all`]'s own doc comment). This
/// function peeks the SAME plane-estimate/last-solve decision [`refresh_all`]
/// makes internally; when it would take the sync branch, it sets
/// `solve_running`/`solve_state` HERE, then defers the actual (still fully
/// synchronous) [`refresh_all`] call behind a `Timer::single_shot(Duration::ZERO,
/// ..)` so the event loop gets to paint this frame first. The background branch
/// is unaffected -- it already returns without blocking, so it runs immediately.
pub(in crate::gui::editor) fn refresh_all_now(
    ui: &MainWindow,
    render_ctx: &Arc<Mutex<crate::bridge::render_thread::RenderContext>>,
    preview_state: &Arc<SolidPreviewState>,
    solid_last_solved: &SolidLastSolved,
    state: &Rc<RefCell<EditorState>>,
    wholesale: bool,
) {
    let (plane_estimate, last_solve) = {
        let st = state.borrow();
        let plane_estimate: usize = st.design.tiers.iter().map(|t| t.indices.len()).sum();
        // Mirrors `refresh_all`'s own `wholesale` branch exactly (see that
        // function's doc comment) EXCEPT for the `auto_solve::reset_for_new_design`
        // side effect itself, which must run exactly once -- left for the real
        // `refresh_all` call below to perform, not duplicated here.
        let last_solve = if wholesale {
            None
        } else {
            auto_solve::last_solve()
        };
        (plane_estimate, last_solve)
    };
    if !auto_solve::should_solve_synchronously(plane_estimate, last_solve) {
        let st = state.borrow();
        refresh_all(
            ui,
            render_ctx,
            preview_state,
            solid_last_solved,
            &st,
            wholesale,
        );
        return;
    }
    // Paints "Solving..." now; the real (still synchronous) solve runs once the
    // event loop has had a chance to render this frame -- see this function's own
    // doc comment.
    ui.global::<EditorModel>().set_solve_running(true);
    ui.global::<EditorModel>().set_solve_state("solving".into());
    let ui_weak = ui.as_weak();
    let render_ctx = Arc::clone(render_ctx);
    let preview_state = Arc::clone(preview_state);
    let solid_last_solved = Arc::clone(solid_last_solved);
    let state = Rc::clone(state);
    slint::Timer::single_shot(Duration::ZERO, move || {
        let Some(ui) = ui_weak.upgrade() else {
            return;
        };
        let st = state.borrow();
        refresh_all(
            &ui,
            &render_ctx,
            &preview_state,
            &solid_last_solved,
            &st,
            wholesale,
        );
    });
}

/// Mirrors [`EditorState::has_design`] into `EditorModel.has_design` -- the one
/// setter path for that flag. Called by [`refresh_all`] (so every replacement that
/// refreshes through it stays in sync) and directly by the install paths
/// (`do_new_design_create`, `apply_loaded_design`, `native_io::finish_state_replace`),
/// whose [`refresh_all_now`] may defer the real refresh by one event-loop tick.
pub(in crate::gui::editor) fn push_has_design(ui: &MainWindow, state: &EditorState) {
    ui.global::<EditorModel>().set_has_design(state.has_design);
}

/// Refreshes the panel and the viewport from `state` -- solving synchronously when
/// that is cheap, otherwise pushing stale content and dispatching a background solve
/// -- then lets the worked-example guide check whether its current step's goal was
/// just reached (`guide::check_progress`).
pub(in crate::gui::editor) fn refresh_all(
    ui: &MainWindow,
    render_ctx: &Arc<Mutex<crate::bridge::render_thread::RenderContext>>,
    preview_state: &Arc<SolidPreviewState>,
    solid_last_solved: &SolidLastSolved,
    state: &EditorState,
    wholesale: bool,
) {
    // Plane count, not tier count. Tier count does not drive
    // solve cost -- a wide-orbit tier emits many planes at once, so a small schedule
    // can still be an expensive, UI-blocking solve (the corpus's worst case is 103
    // tiers but 210 planes). The index count per tier is the cheap estimate of that,
    // available without solving.
    let plane_estimate: usize = state.design.tiers.iter().map(|t| t.indices.len()).sum();
    let last_solve = if wholesale {
        // A fresh call replacing `EditorState` wholesale means a real solve is
        // about to happen (either synchronously below, or via the background
        // dispatch) against a DIFFERENT design than whatever `Runtime::last_solve`
        // currently holds -- that previous design's measured solve time has
        // nothing to say about this one's cost, so it is cleared unconditionally.
        // The synchronous branch below overwrites it again immediately with a
        // real measurement; the background branch leaves it `None` until that
        // dispatch completes, which `auto_solve::should_schedule_auto_solve`'s
        // doc comment already treats as "try auto-solve," a reasonable default
        // right after a solve this function itself just triggered. `None` for
        // THIS call's own measurement deliberately: the reset immediately above
        // has just cleared it, and reaching back for the value it cleared would
        // judge a freshly loaded design by the previous one's solve time --
        // exactly the case where the two have nothing to do with each other, and
        // the one where guessing wrong blocks the UI thread.
        auto_solve::reset_for_new_design();
        None
    } else {
        // Same design as before (an explicit Solve/Adopt/Optimize Apply/etc. on
        // whatever is already loaded) -- its own last REAL measurement, if any,
        // is exactly the signal `should_solve_synchronously` wants. Clearing
        // it here unconditionally would make that rule
        // unreachable for a re-Solve on a design just over the plane-count
        // estimate but well under its own measured time.
        auto_solve::last_solve()
    };
    if auto_solve::should_solve_synchronously(plane_estimate, last_solve) {
        // Brackets the synchronous solve with
        // the SAME `solve_running`/`solve_state` signal `dispatch_background_solve`
        // already gives its own (background) solve, so the command bar/status
        // strip never have a code path that solves without SOME visible marker of
        // it -- even though, being fully synchronous, nothing repaints between
        // this `true` and the `false` a few lines down (no yield back to the
        // event loop happens mid-call); the toolkit only ever paints the FINAL
        // state once this function returns. Real, uninterrupted busy feedback for
        // this fast path would need the solve itself deferred behind a zero-delay
        // timer tick so "Solving..." can paint first -- a larger change to
        // `refresh_all`'s currently-synchronous contract that every one of its
        // several call sites would need auditing
        // against, left undone here. What this DOES fix: `solve_state` is
        // "solving" for the whole duration of this call rather than whatever it
        // was left at by the PREVIOUS refresh, so a reentrant read of it (were one
        // ever added) could not mistake this design for already-idle mid-solve.
        ui.global::<EditorModel>().set_solve_running(true);
        ui.global::<EditorModel>().set_solve_state("solving".into());
        // Timed around the real `Design::solve()` call alone,
        // not `refresh_editor_panel`'s UI-model pushes -- inflating the measured
        // duration with that work would make it incomparable with
        // `auto_solve::dispatch_background_solve`'s own timer (the async path's
        // baseline `should_schedule_auto_solve` compares this measurement
        // against), so a design near the budget threshold could flip auto-solve on
        // or off depending only on which path solved it, not on how expensive
        // solving actually is.
        //
        // Timed once here rather than a second,
        // thrown-away `state.design.solve()` purely to measure elapsed time,
        // immediately followed by `refresh_editor_panel` solving the SAME design
        // again internally to build the panel fields -- doing that would cost two
        // real solves for one "Solve" click. `refresh_editor_panel_from_solve`
        // below takes this exact result instead of re-solving, so this is the
        // only solve on this path.
        let start = Instant::now();
        let solved_result = state.design.solve();
        let elapsed = start.elapsed();
        auto_solve::record_solve_duration(elapsed);
        // Same measurement `record_solve_duration` already takes, also shown here
        // -- captured once rather than re-read, so the figure
        // on screen is exactly the one the auto-solve budget decides on.
        ui.global::<EditorModel>()
            .set_last_solve_duration_ms(i32::try_from(elapsed.as_millis()).unwrap_or(i32::MAX));
        // `solved` is this SAME solve's own result, handed to `refresh_viewport`
        // below so it never pays for a second one of its own either.
        let solved = refresh_editor_panel_from_solve(ui, render_ctx, state, solved_result);
        refresh_viewport(
            ui,
            render_ctx,
            preview_state,
            solid_last_solved,
            state,
            solved.as_deref(),
        );
        // `refresh_editor_panel` has already set `solve_state` to "solved"/"failed"
        // above -- only the running flag still needs clearing.
        ui.global::<EditorModel>().set_solve_running(false);
    } else {
        // Too expensive for the synchronous path above -- reached either by a
        // fresh New/Load/Open (`wholesale`, where any cached `solid_last_solved`
        // on hand describes the design that was just replaced, not this one) or
        // by an explicit re-Solve of a design that is itself large/slow-measured
        // (`wholesale: false` does not make this branch assume the cache is a
        // stranger's, but a stale cache is
        // just as possible here: an earlier edit on this same design could have
        // left `solid_last_solved` behind a subgraph resolve that never reached
        // every tier). Either way, treat every tier as dirty rather than risk a
        // coincidental length match showing a stale/unrelated design's masts as
        // this one's own.
        let all_dirty: BTreeSet<usize> = (0..state.design.tiers.len()).collect();
        push_stale_content(ui, render_ctx, state, &all_dirty);
        auto_solve::dispatch_background_solve(
            ui,
            render_ctx,
            state.design.clone(),
            &state.generation,
            state.multi_selected.clone(),
        );
    }
    push_has_design(ui, state);
    crate::gui::editor::guide::check_progress(ui, state);
}