indicatrix-cut 0.7.2

Desktop faceting-design editor: library browsing, spectral 3D rendering, material retargeting, and a solid inspection view.
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
//! The Slice tool: a line dragged across the stone becomes a PROVISIONAL facet, tweaked
//! with the same three handles, then kept (one `Edit::AddTier`) or discarded.
//!
//! # The provisional session
//!
//! [`ProvisionalSlice`] lives in the manipulation session ([`super::SESSION`]), never in
//! `EditorState`: it owns a clone of the committed design with the new tier appended at
//! `len()`, and it is rendered by submitting a replan of that clone under
//! [`PROVISIONAL_GENERATION`]. `gui::solid_sink` recognises the generation and keeps the
//! frame out of `solid_last_solved`, the tier table and the path tracer's planes, so
//! only the pick buffer, the hover/tier tables and the geometry (what is on screen)
//! follow it. The frame's solved masts reach the session through
//! [`note_masts`], because the sink does not cache them.
//!
//! A drag on the provisional facet edits the clone in place ([`apply_step`]) and
//! resubmits ([`resubmit`]): no history entry, no toast. While the session exists the
//! committed design must not change underneath it: [`on_landed`] compares the session's
//! `base_generation` with the editor's on every landed frame ([`expire_if_stale`] does
//! the same before a button).
//!
//! # Keeping the provisional picture on screen
//!
//! Three things could put the committed stone back over the provisional facet, and each
//! is answered here:
//!
//! - a frame of the COMMITTED design landing (the idle replan, a background solve, a
//!   selection replan): [`on_landed`] sees its generation and resubmits the provisional
//!   design ([`landed_action`]); a provisional frame can never cause that in turn;
//! - a redraw that reprojects the committed planes (a camera orbit, a view-mode
//!   switch, a solve landing): [`note_planes`] hands the provisional frame's planes to
//!   `SolidPreviewState::set_planes_override`, so every such redraw draws them;
//! - a replan that rebuilds the render style: the green outline is stored with
//!   `SolidPreviewState::set_outlines` and stamped on every draw.

mod keep;
mod provisional;

use super::{
    CTX, PROVISIONAL_GENERATION, SESSION, Shared, Target,
    drag::{AppliedEdit, GestureInputs, Step},
    frame_updates_mast_cache, handles, set_hint, tier_label,
};
use crate::{
    MainWindow, ManipulateModel, SolidPreviewModel,
    gui::{
        editor::{
            callbacks::resubmit_facet_overlay,
            view::{ReplanSource, submit_preview_replan, submit_preview_replan_chained},
        },
        show_toast,
        solid_preview::{cut_slider::current_steps, preview_state::FrameGeometry},
        tutorial_events::raise,
    },
};
use glam::Vec3;
use indicatrix::geometry::meet_solver::SolvedTier;
use indicatrix_editor::{
    guide::viewing_events as events,
    manipulate::{SliceSide, text},
};
use provisional::{ProvisionalSlice, rebuild_indices, set_angle, set_mast, turn};
use slint::ComponentHandle as _;
use std::{
    collections::BTreeSet,
    sync::{Arc, PoisonError},
};

pub(super) use keep::keep;
pub(super) use provisional::{
    DiscardReason, LandedAction, SliceLine, SliceState, Snapshot, cut_hides_tier, cut_moved,
    expected_plane_count, landed_action, plan_slice, replan_chain, session_outlives,
    surviving_facets, tier_step, with_provisional_tier,
};
#[cfg(test)]
pub(super) use provisional::{keep_allowed, wheel_turn};

/// The hint after a line too short to define a plane.
const TOO_SHORT_HINT: &str =
    "That line is too short to cut a facet. Drag a longer line across the stone.";

/// The hint when there is no picture of the stone yet.
const NEEDS_PICTURE_HINT: &str =
    "Nothing to slice yet: solve the design first so the stone is on screen.";

/// The toast when an edit of the committed design ended the session.
const CHANGED_TOAST: &str = "Slice discarded -- the design changed";

/// The toast when a tier-list selection ended the session.
const SELECTION_TOAST: &str = "Slice discarded -- another tier was selected";

/// What the hint line and the refused Keep say while the provisional tier has no
/// facet on the stone: a fresh slice sits exactly at the tangency mast, so it cuts
/// nothing until its depth handle is dragged inward.
const NO_DEPTH_HINT: &str =
    "Drag the depth handle inward first -- the facet does not touch the stone yet";

/// The hint while the Cut slider hides the provisional tier (the mesh then holds only
/// the first tiers' planes).
const CUT_SLIDER_HINT: &str =
    "The Cut slider is hiding the new facet: move it to the end to see and adjust it.";

/// Runs `f` on the provisional session, if any. `f` must not touch the session itself.
fn with_provisional<R>(f: impl FnOnce(&mut ProvisionalSlice) -> R) -> Option<R> {
    SESSION.with(|cell| cell.borrow_mut().slice.provisional.as_mut().map(f))
}

/// [`with_provisional`] for a read-only look.
fn with_provisional_ref<R>(f: impl FnOnce(&ProvisionalSlice) -> R) -> Option<R> {
    SESSION.with(|cell| cell.borrow().slice.provisional.as_ref().map(f))
}

/// Whether a provisional slice exists.
pub(super) fn is_active() -> bool {
    SESSION.with(|cell| cell.borrow().slice.provisional.is_some())
}

/// The hint for the provisional tier as it stands.
fn provisional_hint(p: &ProvisionalSlice) -> String {
    let Some(tier) = p.tier() else {
        return String::new();
    };
    if p.surviving == Some(0) {
        return format!(
            "New tier {}: {NO_DEPTH_HINT}. Esc discards it.",
            tier_label(tier, p.tier_index)
        );
    }
    text::slice_provisional_hint(
        &tier_label(tier, p.tier_index),
        p.facet_count(),
        tier.angle_deg,
        p.snapped.index,
    )
}

/// The provisional hint, or `None` without a session.
pub(super) fn provisional_hint_text() -> Option<String> {
    with_provisional_ref(provisional_hint)
}

/// Writes the hint that belongs to no handle: the provisional tier's, else Slice mode's,
/// else nothing.
pub(super) fn resting_hint(ui: &MainWindow) {
    let model = ui.global::<ManipulateModel>();
    let cutoff = ui.global::<SolidPreviewModel>().get_tier_cutoff();
    // The slider counts cutting steps, which (once concave tiers exist) are not stored
    // tier indices: compare it with the step the provisional tier is cut at.
    let hidden_by_cut = with_provisional_ref(|p| {
        tier_step(&p.design, p.tier_index).is_some_and(|step| cut_hides_tier(cutoff, step))
    });
    let hint = hidden_by_cut
        .filter(|&hidden| hidden)
        .map(|_| CUT_SLIDER_HINT.to_string())
        .or_else(provisional_hint_text)
        .or_else(|| {
            model
                .get_slice_mode()
                .then(|| text::slice_mode_hint(model.get_slice_symmetric()))
        })
        .unwrap_or_default();
    set_hint(ui, &hint);
}

/// The handle target on the provisional tier for the frame `geometry`, or `None`
/// without a session or before a frame's masts describe it.
pub(super) fn place_provisional(geometry: &FrameGeometry) -> Option<Target> {
    with_provisional(|p| {
        let map = p.facet_map()?;
        handles::target_for(geometry, &map, &p.design, p.tier_index, None, true)
    })
    .flatten()
}

/// What a handle press on the provisional tier starts from.
pub(super) fn gesture_inputs() -> Option<GestureInputs> {
    with_provisional_ref(|p| {
        let tier = p.tier()?;
        Some(GestureInputs {
            start_angle_deg: tier.angle_deg,
            masts: p.masts.clone(),
            restore: Some(Snapshot {
                tier: tier.clone(),
                snapped: p.snapped,
            }),
        })
    })
    .flatten()
}

/// The solved masts of a provisional-generation frame (see `gui::solid_sink`). Kept only
/// when they describe the session's design one to one.
pub(super) fn note_masts(masts: Vec<SolvedTier>) {
    with_provisional(|p| {
        if masts.len() == p.design.tiers.len() {
            p.masts = Some(masts);
            p.facet_map = None;
            p.masts_new = true;
        }
    });
}

/// Turns a landed provisional frame's masts into the green outline (and refreshes the
/// hint's facet count). Cheap when nothing is new.
pub(super) fn sync_outline(ui: &MainWindow, ctx: &Shared) {
    let geometry = ctx
        .geometry
        .lock()
        .unwrap_or_else(PoisonError::into_inner)
        .clone();
    let outcome = with_provisional(|p| {
        if !std::mem::take(&mut p.masts_new) {
            return None;
        }
        let touched_before = provisional::keep_allowed(p.surviving);
        let map = p.facet_map()?;
        // The map numbers the finished provisional stone. A frame cut back by the Cut
        // slider numbers fewer facets (and hides this tier, the last one), so its ids
        // are not the map's: outline nothing rather than the wrong facets.
        let aligned = geometry
            .as_ref()
            .is_none_or(|g| handles::ids_aligned(map.facet_count(), g.facet_centroids.len()));
        let ids = if aligned {
            map.facets_of_tier(p.tier_index).to_vec()
        } else {
            Vec::new()
        };
        // Which of the tier's planes touch the stone in the frame on screen.
        if let Some(geometry) = &geometry {
            p.surviving = surviving_facets(&ids, &geometry.facet_centroids, map.facet_count());
        }
        let changed = ids != p.outline;
        if changed {
            p.outline.clone_from(&ids);
        }
        // Keep is allowed once a facet of the new tier touches the stone.
        let starts_cutting = !touched_before && provisional::keep_allowed(p.surviving);
        Some((changed, ids, starts_cutting))
    })
    .flatten();
    let Some((changed, ids, starts_cutting)) = outcome else {
        return;
    };
    if changed {
        resubmit_facet_overlay(&ctx.preview_state, move |overlay| overlay.provisional = ids);
    }
    if starts_cutting {
        // A tutorial step may wait for the new facet to reach the stone.
        raise(ui, events::SLICE_CUTS_STONE);
    }
    let hover_active = SESSION.with(|cell| cell.borrow().hover_hint_active);
    if !ui.global::<ManipulateModel>().get_dragging() && !hover_active {
        resting_hint(ui);
    }
}

/// Applies one throttled handle step to the provisional tier in place (no history, no
/// panel refresh). `None` when nothing changed.
pub(super) fn apply_step(step: Step) -> Option<AppliedEdit> {
    with_provisional(|p| {
        let changed = match step {
            Step::Angle(deg) => set_angle(p, deg),
            Step::Mast(mast) => set_mast(p, mast),
            Step::Teeth(more) => turn(p, more),
        };
        if changed {
            p.facet_map = None;
        }
        changed.then_some(AppliedEdit {
            generation: 0,
            replaced_meet: None,
        })
    })
    .flatten()
}

/// Puts the provisional tier back the way a handle press found it (Escape mid-drag) and
/// re-renders.
pub(super) fn restore_tier(ui: &MainWindow, ctx: &Shared, snapshot: Snapshot) {
    let restored = with_provisional(|p| {
        let index = p.tier_index;
        let Some(slot) = Arc::make_mut(&mut p.design).tiers.get_mut(index) else {
            return false;
        };
        *slot = snapshot.tier;
        p.snapped = snapshot.snapped;
        p.facet_map = None;
        true
    })
    .unwrap_or(false);
    if restored {
        resubmit(ui, ctx);
    }
}

/// Renders the provisional design: a replan stamped [`PROVISIONAL_GENERATION`], so the
/// sink and `submit_preview_replan_chained` keep it out of the committed caches. Once
/// the session holds the masts of a landed provisional frame the replan chains from
/// them with `dirty = {provisional tier}` (a subgraph re-solve that fits the preview
/// budget on a large design); the first replan after a session begins (or a Flip) is a
/// full solve -- see [`replan_chain`].
///
/// The replan draws the stone at the Cut slider's current cut. When the slider has moved
/// since the last provisional replan, the planes the preview still holds for the slice are
/// the old cut's: they are dropped here, so a redraw before the new frame lands shows the
/// committed stone at the new cut instead of a stale one ([`note_planes`] then takes the
/// new frame's planes).
pub(super) fn resubmit(ui: &MainWindow, ctx: &Shared) {
    let Some((design, last_solved, dirty)) = with_provisional(|p| {
        p.awaiting = true;
        let (last_solved, dirty) =
            replan_chain(p.masts.as_deref(), p.design.tiers.len(), p.tier_index);
        (Arc::clone(&p.design), last_solved, dirty)
    }) else {
        return;
    };
    let cut_steps = current_steps(ui, &design);
    let moved =
        with_provisional(|p| cut_moved(std::mem::replace(&mut p.cut_steps, cut_steps), cut_steps));
    if moved == Some(true) {
        ctx.preview_state.set_planes_override(None);
    }
    submit_preview_replan_chained(
        ui,
        &ctx.render_ctx,
        &ctx.preview_state,
        ReplanSource {
            design: &design,
            generation: PROVISIONAL_GENERATION,
            multi_selected: &BTreeSet::new(),
        },
        dirty,
        last_solved,
    );
}

/// Re-renders the committed design (the provisional outline goes away).
fn replan_committed(ui: &MainWindow, ctx: &Shared) {
    if let Ok(st) = ctx.state.try_borrow() {
        submit_preview_replan(
            ui,
            &ctx.render_ctx,
            &ctx.preview_state,
            &ctx.solid_last_solved,
            &st,
            BTreeSet::new(),
            false,
        );
    }
}

/// Removes the session and everything drawn for it (outline, a drag on it, the buttons).
fn tear_down(ui: &MainWindow, ctx: &Shared) -> Option<ProvisionalSlice> {
    let (session, dragging_it) = SESSION.with(|cell| {
        let mut session = cell.borrow_mut();
        let taken = session.slice.provisional.take();
        let dragging_it = taken.is_some() && session.drag.as_ref().is_some_and(|d| d.provisional);
        if dragging_it {
            session.drag = None;
        }
        (taken, dragging_it)
    });
    let session = session?;
    let model = ui.global::<ManipulateModel>();
    model.set_provisional_active(false);
    model.set_provisional_label("".into());
    if dragging_it {
        model.set_dragging(false);
        model.set_hovered_handle(-1);
    }
    resubmit_facet_overlay(&ctx.preview_state, |overlay| overlay.provisional.clear());
    // Redraws reproject the committed planes again.
    ctx.preview_state.set_planes_override(None);
    Some(session)
}

/// Drops the provisional slice (Discard, Escape, leaving Slice mode, an edit underneath
/// it). A no-op without a session.
pub(super) fn discard(ui: &MainWindow, ctx: &Shared, reason: DiscardReason) {
    let Some(session) = tear_down(ui, ctx) else {
        return;
    };
    let label = session
        .tier()
        .map_or_else(String::new, |tier| tier_label(tier, session.tier_index));
    if reason.replans() {
        replan_committed(ui, ctx);
    }
    handles::refresh_handles(ui, ctx);
    resting_hint(ui);
    let toast = match reason {
        DiscardReason::User => text::slice_discarded_toast(&label),
        DiscardReason::DesignChanged => CHANGED_TOAST.to_string(),
        DiscardReason::SelectionChanged => SELECTION_TOAST.to_string(),
    };
    show_toast(ui, &toast, "info");
}

/// A tier-list selection change is about to replan the committed design: end the
/// session first (its replan is the caller's).
pub(super) fn drop_for_selection(ui: &MainWindow) {
    let Some(ctx) = CTX.with(|cell| cell.borrow().clone()) else {
        return;
    };
    discard(ui, &ctx, DiscardReason::SelectionChanged);
}

/// Discards the session when the committed design has moved on since it began. Returns
/// whether it did.
pub(super) fn expire_if_stale(ui: &MainWindow, ctx: &Shared) -> bool {
    let Some(base) = with_provisional_ref(|p| p.base_generation) else {
        return false;
    };
    let stale = ctx
        .state
        .try_borrow()
        .is_ok_and(|st| !session_outlives(base, st.current_generation()));
    if stale {
        discard(ui, ctx, DiscardReason::DesignChanged);
    }
    stale
}

/// A solid-preview frame stamped `frame_generation` has landed: keeps a live provisional
/// session on screen. A frame of the COMMITTED design (an idle replan, a background
/// solve, a tier selection's replan) has replaced the provisional picture, so the
/// provisional design is rendered again (cheap: the plan chains from the session's own
/// masts); a session whose committed design moved on is discarded. Returns whether it
/// was discarded.
pub(super) fn on_landed(ui: &MainWindow, ctx: &Shared, frame_generation: u64) -> bool {
    let Some((base, awaiting)) = with_provisional_ref(|p| (p.base_generation, p.awaiting)) else {
        return false;
    };
    // A provisional-generation frame is the answer to the last provisional replan.
    if !frame_updates_mast_cache(frame_generation) {
        with_provisional(|p| p.awaiting = false);
    }
    // A design that is being edited right now cannot have moved on: assume it has not.
    let current = ctx
        .state
        .try_borrow()
        .map_or(base, |st| st.current_generation());
    match landed_action(Some(base), awaiting, current, frame_generation) {
        LandedAction::Ignore => false,
        LandedAction::Resubmit => {
            resubmit(ui, ctx);
            false
        }
        LandedAction::Discard => {
            discard(ui, ctx, DiscardReason::DesignChanged);
            true
        }
    }
}

/// Stops waiting for a provisional frame: a committed replan took the plan gate's slot.
pub(super) fn clear_awaiting() {
    with_provisional(|p| p.awaiting = false);
}

/// The planes a provisional-generation frame was drawn from (see
/// `gui::solid_sink`): while the session exists, every reprojecting redraw (a camera
/// orbit, a view-mode switch, a background solve) draws THESE instead of the committed
/// `RenderContext::active_planes`, so the provisional facet stays on screen. Only planes
/// that match the provisional design one to one are taken -- the rare provisional-
/// generation frame that carries committed planes (a redraw racing the first
/// provisional replan) is ignored.
///
/// "Match" means the plane count of the provisional design at the Cut slider's cut of the
/// latest provisional replan ([`expected_plane_count`]): a cut-back stone has fewer planes
/// than the finished one, and comparing with the finished count rejected every frame drawn
/// while the slider was cut back, which left the planes of the previous cut on screen.
pub(super) fn note_planes(planes: &[(Vec3, f32)]) {
    let accepted = with_provisional(|p| {
        let masts = p
            .masts
            .as_deref()
            .filter(|masts| masts.len() == p.design.tiers.len())?;
        (expected_plane_count(&p.design, masts, p.cut_steps) == planes.len())
            .then(|| planes.to_vec())
    })
    .flatten();
    let Some(planes) = accepted else {
        return;
    };
    if let Some(ctx) = CTX.with(|cell| cell.borrow().clone()) {
        ctx.preview_state.set_planes_override(Some(planes));
    }
}

/// `ManipulateModel.slice_toggled`: `slice_mode` already holds its new value.
pub(super) fn toggled(ui: &MainWindow, ctx: &Shared) {
    if ui.global::<ManipulateModel>().get_slice_mode() {
        SESSION.with(|cell| cell.borrow_mut().hover_hint_active = false);
        // A tutorial step may wait for Slice mode to be switched on.
        raise(ui, events::SLICE_STARTED);
    } else {
        cancel_gesture(ui);
        discard(ui, ctx, DiscardReason::User);
    }
    // Slice mode shows only the provisional tier's handles; leaving it brings the
    // selected facet's own back.
    handles::refresh_handles(ui, ctx);
    resting_hint(ui);
}

/// Shows the rubber band from `start` to `end` (logical coordinates).
fn set_line(ui: &MainWindow, start: (f32, f32), end: (f32, f32)) {
    let model = ui.global::<ManipulateModel>();
    model.set_line_x0(start.0);
    model.set_line_y0(start.1);
    model.set_line_x1(end.0);
    model.set_line_y1(end.1);
    model.set_slice_line_visible(true);
}

/// `ManipulateModel.slice_begin`: the press that starts a line.
pub(super) fn begin(ui: &MainWindow, x: f32, y: f32) {
    if !ui.global::<ManipulateModel>().get_slice_mode() {
        return;
    }
    SESSION.with(|cell| cell.borrow_mut().slice.gesture = Some((x, y)));
    set_line(ui, (x, y), (x, y));
}

/// `ManipulateModel.slice_move`: the pointer moved while drawing the line.
pub(super) fn move_to(ui: &MainWindow, x: f32, y: f32) {
    if let Some(start) = SESSION.with(|cell| cell.borrow().slice.gesture) {
        set_line(ui, start, (x, y));
    }
}

/// `ManipulateModel.slice_cancel`: Escape (or a cancelled pointer) mid-line.
pub(super) fn cancel_gesture(ui: &MainWindow) {
    SESSION.with(|cell| cell.borrow_mut().slice.gesture = None);
    ui.global::<ManipulateModel>().set_slice_line_visible(false);
}

/// `ManipulateModel.slice_end`: the release that finishes the line -- the cut plane is
/// snapped and the provisional tier appears.
pub(super) fn end(ui: &MainWindow, ctx: &Shared, x: f32, y: f32) {
    let Some(start) = SESSION.with(|cell| cell.borrow_mut().slice.gesture.take()) else {
        return;
    };
    ui.global::<ManipulateModel>().set_slice_line_visible(false);
    let geometry = ctx
        .geometry
        .lock()
        .unwrap_or_else(PoisonError::into_inner)
        .clone();
    let Some(geometry) = geometry else {
        set_hint(ui, NEEDS_PICTURE_HINT);
        return;
    };
    let line = SliceLine::new(
        handles::pointer_to_pick(ui, start.0, start.1),
        handles::pointer_to_pick(ui, x, y),
        &geometry,
    );
    // A second line replaces the first, against the same committed stone.
    let corners = with_provisional_ref(|p| Arc::clone(&p.base_corners))
        .unwrap_or_else(|| Arc::clone(&geometry.corner_points));
    if install(ui, ctx, line, SliceSide::Right, corners) {
        // A tutorial step may wait for a line to become a provisional tier.
        raise(ui, events::SLICE_DRAWN);
    }
}

/// Builds the session for `line` cutting away `side`, renders it and words the hint.
/// A degenerate line leaves everything as it was and says why. Returns whether the session
/// was built.
fn install(
    ui: &MainWindow,
    ctx: &Shared,
    line: SliceLine,
    side: SliceSide,
    corners: Arc<Vec<Vec3>>,
) -> bool {
    let model = ui.global::<ManipulateModel>();
    let symmetric = model.get_slice_symmetric();
    let built = ctx.state.try_borrow().ok().and_then(|st| {
        let plan = plan_slice(&line, side, &corners, &st.design, symmetric)?;
        let (design, tier_index) = with_provisional_tier(&st.design, plan.tier)?;
        let label = tier_label(design.tiers.get(tier_index)?, tier_index);
        Some((
            design,
            tier_index,
            plan.snapped,
            st.current_generation(),
            label,
        ))
    });
    let Some((design, tier_index, snapped, base_generation, label)) = built else {
        set_hint(ui, TOO_SHORT_HINT);
        return false;
    };
    SESSION.with(|cell| {
        let mut session = cell.borrow_mut();
        session.hover_hint_active = false;
        session.slice.provisional = Some(ProvisionalSlice::new(
            base_generation,
            design,
            tier_index,
            side,
            snapped,
            line,
            corners,
        ));
    });
    // A replaced session (a second line, Flip) must not keep drawing the old planes.
    ctx.preview_state.set_planes_override(None);
    model.set_provisional_label(label.into());
    model.set_provisional_active(true);
    resubmit(ui, ctx);
    resting_hint(ui);
    true
}

/// `ManipulateModel.slice_flip`: the same line, the other side cut away.
pub(super) fn flip(ui: &MainWindow, ctx: &Shared) {
    if expire_if_stale(ui, ctx) {
        return;
    }
    let Some((line, side, corners)) =
        with_provisional_ref(|p| (p.line, p.side.flipped(), Arc::clone(&p.base_corners)))
    else {
        return;
    };
    if install(ui, ctx, line, side, corners) {
        // A tutorial step may wait for the cut to be flipped to the other side.
        raise(ui, events::SLICE_FLIPPED);
    }
}

/// `ManipulateModel.slice_symmetric_toggled`: `slice_symmetric` holds its new value.
pub(super) fn symmetric_toggled(ui: &MainWindow, ctx: &Shared) {
    if expire_if_stale(ui, ctx) {
        return;
    }
    let symmetric = ui.global::<ManipulateModel>().get_slice_symmetric();
    if with_provisional(|p| rebuild_indices(p, symmetric)).unwrap_or(false) {
        resubmit(ui, ctx);
    }
    resting_hint(ui);
}