Skip to main content

polytope_playground/
ui.rs

1use loam::app::egui;
2use loam::app::session::CommandSender;
3use loam::math::{Rotor, Rotor4};
4use loam::runtime::{Entity, Session};
5use loam_egui::{
6    dnd::{drag_source_collapsing, drop_target_idx, force_opaque_active, make_room_gap, pickup_t},
7    media::{add_button, chevron_button, play_pause_button, rate_toggle, refresh_button},
8    slider_with_edit,
9};
10
11use crate::color::ColorMode;
12use crate::consts::{BASE_ROTATION_RATE, W_RANGE};
13use crate::display::Surface;
14use crate::mode::Mode;
15use crate::projection::Family;
16use crate::strip::{Cell, Strip, MAX_CELLS, MAX_T_EXTENT, MIN_CELLS, MIN_T_EXTENT};
17use crate::toy::{DepthBand, ARENA_HALF};
18use crate::{catalog, Action, Playground};
19
20const PLANE_LABELS: [&str; 6] = ["xy", "xz", "xw", "yz", "yw", "zw"];
21const OVERLAY_PAD: f32 = 16.0;
22const CONTROL_H: f32 = 28.0;
23const CONTROL_W: f32 = 28.0;
24const PLAY_W: f32 = 36.0;
25const CARD_WIDTH: f32 = 74.0;
26const CARD_HEIGHT: f32 = CONTROL_H + 2.0;
27
28#[derive(Default)]
29pub(crate) struct Panel {
30    expanded: bool,
31    show_render: bool,
32    show_about: bool,
33}
34
35pub(crate) fn draw(
36    context: &egui::Context,
37    session: &Session<Playground>,
38    panel: &mut Panel,
39    sender: &CommandSender<Playground>,
40    rotor: Rotor4,
41    depths: &[DepthBand],
42) {
43    let app = &session.app;
44    menu_bar(context, app, panel, sender);
45    rotor_formula(context, app, rotor);
46    render_settings(context, app, panel, sender);
47    about(context, panel);
48    if *app.controls.get() {
49        match *app.mode.get() {
50            Mode::Rotate => overlay(context, app, panel, sender),
51            Mode::Toybox => w_line(context, app, sender, depths),
52        }
53    }
54}
55
56fn menu_bar(
57    context: &egui::Context,
58    app: &Playground,
59    panel: &mut Panel,
60    sender: &CommandSender<Playground>,
61) {
62    egui::TopBottomPanel::top("polytope-playground-menu").show(context, |ui| {
63        egui::MenuBar::new().ui(ui, |ui| {
64            ui.menu_button("Demo", |ui| {
65                for (mode, label) in [(Mode::Rotate, "Rotate"), (Mode::Toybox, "Toybox")] {
66                    if ui
67                        .selectable_label(*app.mode.get() == mode, label)
68                        .clicked()
69                    {
70                        sender.app(Action::Mode(mode));
71                        ui.close_kind(egui::UiKind::Menu);
72                    }
73                }
74            });
75            ui.menu_button("Edit", |ui| {
76                if ui.button("Reset poses, rotation, and slice").clicked() {
77                    sender.app(Action::Reset);
78                    ui.close_kind(egui::UiKind::Menu);
79                }
80            });
81            loam_egui::sticky_menu(ui, "View", |ui| {
82                let mut controls = *app.controls.get();
83                match *app.mode.get() {
84                    Mode::Rotate => {
85                        if ui.checkbox(&mut controls, "Controls").changed() {
86                            sender.app(Action::Controls(None));
87                        }
88                        let mut formula = *app.formula.get();
89                        if ui.checkbox(&mut formula, "Rotation formula").changed() {
90                            sender.app(Action::Formula(None));
91                        }
92                    }
93                    Mode::Toybox => {
94                        if ui.checkbox(&mut controls, "w line").changed() {
95                            sender.app(Action::Controls(None));
96                        }
97                        let mut guides = *app.guides.get();
98                        if ui.checkbox(&mut guides, "Floor guides").changed() {
99                            sender.app(Action::Guides(None));
100                        }
101                        let mut rope = *app.rope.get();
102                        if ui
103                            .checkbox(&mut rope, "Rope carry")
104                            .on_hover_text("Held shapes swing from the point you grabbed.")
105                            .changed()
106                        {
107                            sender.app(Action::Rope(None));
108                        }
109                    }
110                }
111                ui.checkbox(&mut panel.show_render, "Render settings");
112                ui.separator();
113                if ui.button("About this program").clicked() {
114                    panel.show_about = true;
115                    egui::Popup::close_all(ui.ctx());
116                }
117            });
118        });
119    });
120}
121
122fn overlay(
123    context: &egui::Context,
124    app: &Playground,
125    panel: &mut Panel,
126    sender: &CommandSender<Playground>,
127) {
128    let screen = context.content_rect();
129    let width = (screen.width() - 2.0 * (OVERLAY_PAD + 10.0)).clamp(280.0, 760.0);
130    let window = bottom_panel(context, "polytope-playground-controls", width, 10.0);
131    window.show(context, |ui| {
132        ui.set_width(width);
133        if panel.expanded {
134            egui::ScrollArea::vertical()
135                .max_height((screen.height() - 190.0).max(80.0))
136                .show(ui, |ui| expanded(ui, app, sender));
137            ui.separator();
138        }
139        sliders(ui, app, sender);
140        transport(ui, app, panel, sender);
141    });
142}
143
144fn expanded(ui: &mut egui::Ui, app: &Playground, sender: &CommandSender<Playground>) {
145    view_tabs(ui, app, sender);
146    if app.strip.get().on {
147        filmstrip(ui, *app.strip.get(), sender);
148    } else {
149        shapes(ui, app, sender);
150    }
151    ui.separator();
152    active_set(ui, app, sender);
153}
154
155fn view_tabs(ui: &mut egui::Ui, app: &Playground, sender: &CommandSender<Playground>) {
156    let display = *app.display.get();
157    let strip = *app.strip.get();
158    ui.horizontal_wrapped(|ui| {
159        if ui
160            .selectable_label(!display.single && !strip.on, "Shapes")
161            .clicked()
162        {
163            let mut next = display;
164            next.single = false;
165            sender.app(Action::Display(next));
166            if strip.on {
167                sender.app(Action::Strip(Strip { on: false, ..strip }));
168            }
169        }
170        if ui
171            .selectable_label(display.single && !strip.on, "Single")
172            .clicked()
173        {
174            let mut next = display;
175            next.single = true;
176            sender.app(Action::Display(next));
177            if strip.on {
178                sender.app(Action::Strip(Strip { on: false, ..strip }));
179            }
180        }
181        if ui.selectable_label(strip.on, "Filmstrip").clicked() {
182            sender.app(Action::Strip(Strip { on: true, ..strip }));
183        }
184        let mut formula = *app.formula.get();
185        if ui.checkbox(&mut formula, "Formula").changed() {
186            sender.app(Action::Formula(None));
187        }
188    });
189}
190
191fn active_set(ui: &mut egui::Ui, app: &Playground, sender: &CommandSender<Playground>) {
192    const PLANES: [usize; 6] = [0, 1, 3, 2, 4, 5];
193    const CELL_SPACING: f32 = 4.0;
194    const CHECKBOX_W: f32 = 18.0;
195    const LABEL_W: f32 = 22.0;
196    const VALUE_W: f32 = 56.0;
197    const ROW_GAP: f32 = 6.0;
198
199    let time = *app.time.get();
200    let spin = *app.spin.get();
201    let total = ui.available_width();
202    let cell_width = ((total - 2.0 * ROW_GAP) / 3.0).floor();
203    let slider_width = (cell_width - CHECKBOX_W - LABEL_W - VALUE_W - 3.0 * CELL_SPACING).max(40.0);
204    for row in PLANES.chunks(3) {
205        ui.horizontal(|ui| {
206            ui.spacing_mut().item_spacing.x = ROW_GAP;
207            for &index in row {
208                ui.allocate_ui_with_layout(
209                    egui::vec2(cell_width, CONTROL_H),
210                    egui::Layout::left_to_right(egui::Align::Center),
211                    |ui| {
212                        ui.spacing_mut().item_spacing.x = CELL_SPACING;
213                        ui.spacing_mut().slider_width = slider_width;
214                        let mut active = spin.planes[index];
215                        if ui
216                            .add_sized([CHECKBOX_W, 18.0], egui::Checkbox::new(&mut active, ""))
217                            .changed()
218                        {
219                            sender.app(Action::Plane(index));
220                        }
221                        ui.add_sized(
222                            [LABEL_W, 18.0],
223                            egui::Label::new(egui::RichText::new(PLANE_LABELS[index]).monospace()),
224                        );
225                        let angle = app.angles.get()[index]
226                            + if spin.planes[index] {
227                                time * BASE_ROTATION_RATE
228                            } else {
229                                0.0
230                            };
231                        let mut degrees = wrap_degrees(angle.to_degrees());
232                        let slider = egui::Slider::new(&mut degrees, -720.0..=720.0)
233                            .show_value(false)
234                            .smart_aim(false)
235                            .clamping(egui::SliderClamping::Always);
236                        if ui.add_sized([slider_width, 18.0], slider).changed() {
237                            sender.app(Action::PlaneAngle(index, degrees.to_radians()));
238                        }
239                        ui.add_sized(
240                            [VALUE_W, 18.0],
241                            egui::Label::new(
242                                egui::RichText::new(format!("{degrees:>+6.1}°")).monospace(),
243                            ),
244                        );
245                    },
246                );
247            }
248        });
249    }
250}
251
252fn wrap_degrees(degrees: f32) -> f32 {
253    let wrapped = degrees.rem_euclid(1440.0);
254    if wrapped > 720.0 {
255        wrapped - 1440.0
256    } else {
257        wrapped
258    }
259}
260
261fn shapes(ui: &mut egui::Ui, app: &Playground, sender: &CommandSender<Playground>) {
262    let active = *app.active.get();
263    let row_len = app.slots.len();
264    let row_rect_id = ui.make_persistent_id("shape-row-rect");
265    let last_row_rect: Option<egui::Rect> =
266        ui.ctx().memory(|memory| memory.data.get_temp(row_rect_id));
267    let dragging = egui::DragAndDrop::payload::<usize>(ui.ctx()).is_some();
268    let drop_index =
269        last_row_rect.and_then(|rect| drop_target_idx(ui.ctx(), dragging, rect, row_len));
270    let released = ui.ctx().input(|input| input.pointer.any_released());
271    let drop_id = ui.make_persistent_id("shape-row-drop");
272    // Keep the preview until the reorder command commits.
273    let mut dropped = ui.ctx().data_mut(|data| {
274        let (entity, from, destination) = data.get_temp::<(Entity, usize, usize)>(drop_id)?;
275        if app.slots.get(entity).is_some_and(|slot| slot.index == from) {
276            Some((from, destination))
277        } else {
278            data.remove::<(Entity, usize, usize)>(drop_id);
279            None
280        }
281    });
282    if released {
283        if let (Some(to), Some(from)) = (
284            drop_index,
285            egui::DragAndDrop::payload::<usize>(ui.ctx()).map(|payload| *payload),
286        ) {
287            let _ = egui::DragAndDrop::take_payload::<usize>(ui.ctx());
288            let destination = if to > from { to - 1 } else { to };
289            if let Some((entity, _)) = app
290                .slots
291                .iter()
292                .find(|(_, slot)| slot.index == from && to <= row_len && destination != from)
293            {
294                sender.app(Action::ReorderShape { from, to });
295                dropped = Some((from, destination));
296                ui.ctx()
297                    .data_mut(|data| data.insert_temp(drop_id, (entity, from, destination)));
298            }
299        }
300    }
301    ui.separator();
302    let row_rect = egui::ScrollArea::horizontal()
303        .auto_shrink([false, true])
304        .id_salt("polytope-playground-shapes")
305        .show(ui, |ui| {
306            ui.with_layout(egui::Layout::left_to_right(egui::Align::Min), |ui| {
307                ui.spacing_mut().item_spacing.x = 4.0;
308                ui.set_min_height(CARD_HEIGHT);
309                if released {
310                    for index in 0..=row_len {
311                        let gap = ui.make_persistent_id(("shape-gap", index));
312                        let card = ui.make_persistent_id(("shape-card", index));
313                        let _ = ui.ctx().animate_value_with_time(gap, 0.0, 0.0);
314                        let _ = ui
315                            .ctx()
316                            .animate_value_with_time(card.with("pickup"), 0.0, 0.0);
317                    }
318                }
319                let still_dragging = egui::DragAndDrop::payload::<usize>(ui.ctx()).is_some();
320                let render_drop_index = if still_dragging { drop_index } else { None };
321                for index in 0..row_len {
322                    let gap_id = ui.make_persistent_id(("shape-gap", index));
323                    let _ = make_room_gap(
324                        ui,
325                        render_drop_index == Some(index),
326                        gap_id,
327                        CARD_HEIGHT,
328                        CARD_WIDTH,
329                    );
330                    let source = dropped.map_or(index, |(from, destination)| {
331                        crate::row::reordered_index(index, destination, from)
332                    });
333                    let Some(slot) = app
334                        .slots
335                        .iter()
336                        .find(|(_, slot)| slot.index == source)
337                        .map(|(_, slot)| *slot)
338                    else {
339                        continue;
340                    };
341                    let card_id = ui.make_persistent_id(("shape-card", index));
342                    let raised = pickup_t(ui.ctx(), card_id);
343                    let selected = slot.index == active;
344                    let visuals = ui.visuals();
345                    let fill = if selected {
346                        visuals.selection.bg_fill
347                    } else {
348                        visuals.widgets.noninteractive.bg_fill
349                    };
350                    let stroke = if raised > 0.0 {
351                        egui::Stroke::new(1.0, egui::Color32::from_rgb(255, 200, 60))
352                    } else if selected {
353                        visuals.selection.stroke
354                    } else {
355                        visuals.widgets.noninteractive.bg_stroke
356                    };
357                    let response = drag_source_collapsing(ui, card_id, slot.index, |ui| {
358                        if ui.ctx().is_being_dragged(card_id) {
359                            force_opaque_active(ui);
360                        }
361                        egui::Frame::default()
362                            .fill(fill)
363                            .stroke(stroke)
364                            .inner_margin(egui::Margin::symmetric(4, 6))
365                            .corner_radius(3)
366                            .show(ui, |ui| {
367                                ui.add_sized(
368                                    [64.0, CONTROL_H - 12.0],
369                                    egui::Label::new(
370                                        egui::RichText::new(slot.entry.label).strong(),
371                                    )
372                                    .selectable(false),
373                                );
374                            });
375                    })
376                    .on_hover_cursor(egui::CursorIcon::Grab)
377                    .on_hover_ui(|ui| {
378                        ui.label(slot.entry.long_name);
379                        ui.label("Drag to reorder. Right-click to replace or remove.");
380                    })
381                    .interact(egui::Sense::click());
382                    if response.clicked() {
383                        sender.app(Action::Active(slot.index));
384                    }
385                    response.context_menu(|ui| {
386                        ui.set_min_width(160.0);
387                        ui.label("Replace with");
388                        catalog::shape_catalog_menu(ui, |card| {
389                            sender.app(Action::Shape(slot.index, card));
390                        });
391                        ui.separator();
392                        if ui.button("Remove from row").clicked() {
393                            sender.app(Action::RemoveShape(slot.index));
394                            ui.close_kind(egui::UiKind::Menu);
395                        }
396                    });
397                }
398                let trailing_id = ui.make_persistent_id(("shape-gap", row_len));
399                let _ = make_room_gap(
400                    ui,
401                    render_drop_index == Some(row_len),
402                    trailing_id,
403                    CARD_HEIGHT,
404                    CARD_WIDTH,
405                );
406                let add = add_button(ui, egui::vec2(CONTROL_W, CONTROL_H))
407                    .on_hover_text("Add a shape to the row.");
408                egui::Popup::menu(&add).show(|ui| {
409                    ui.set_min_width(160.0);
410                    catalog::shape_catalog_menu(ui, |card| {
411                        sender.app(Action::AddShape(card));
412                    });
413                });
414            })
415            .response
416            .rect
417        })
418        .inner;
419    if egui::DragAndDrop::payload::<usize>(ui.ctx()).is_none() {
420        ui.ctx()
421            .memory_mut(|memory| memory.data.insert_temp(row_rect_id, row_rect));
422    }
423}
424
425fn bottom_panel(
426    context: &egui::Context,
427    id: &'static str,
428    width: f32,
429    margin: f32,
430) -> egui::Window<'static> {
431    let screen = context.content_rect();
432    let visuals = &context.style().visuals;
433    let frame = egui::Frame::default()
434        .fill(visuals.window_fill)
435        .stroke(visuals.window_stroke)
436        .corner_radius(visuals.window_corner_radius)
437        .inner_margin(margin);
438    egui::Window::new(id)
439        .id(egui::Id::new(id))
440        .title_bar(false)
441        .resizable(false)
442        .collapsible(false)
443        .movable(true)
444        .auto_sized()
445        .pivot(egui::Align2::CENTER_BOTTOM)
446        .default_pos(egui::pos2(screen.center().x, screen.bottom() - OVERLAY_PAD))
447        .default_width(width)
448        .frame(frame)
449}
450
451fn w_line(
452    context: &egui::Context,
453    app: &Playground,
454    sender: &CommandSender<Playground>,
455    depths: &[DepthBand],
456) {
457    const WIDTH: f32 = 360.0;
458    const MARGIN: f32 = 8.0;
459    const VALUE_W: f32 = 64.0;
460    const LANE: f32 = 2.0;
461    const LANE_GAP: f32 = 2.0;
462    const RAIL_GAP: f32 = 4.0;
463    const HANDLE: f32 = 5.0;
464
465    let slice = *app.slice.get();
466    let window = bottom_panel(context, "polytope-playground-w-line", WIDTH, MARGIN);
467    window.show(context, |ui| {
468        ui.set_width(WIDTH);
469        ui.horizontal(|ui| {
470            let lanes = depths.len().max(1) as f32 * (LANE + LANE_GAP) - LANE_GAP;
471            let (rect, response) = ui.allocate_exact_size(
472                egui::vec2(
473                    ui.available_width() - VALUE_W,
474                    lanes + RAIL_GAP + HANDLE + 1.0,
475                ),
476                egui::Sense::click_and_drag(),
477            );
478            let left = rect.left() + HANDLE;
479            let right = rect.right() - HANDLE;
480            let x_for =
481                |w: f32| left + ((w / ARENA_HALF).clamp(-1.0, 1.0) * 0.5 + 0.5) * (right - left);
482            if response.clicked() || response.dragged() {
483                if let Some(at) = response.interact_pointer_pos() {
484                    let position =
485                        ((at.x - left) / (right - left).max(f32::MIN_POSITIVE)).clamp(0.0, 1.0);
486                    sender.app(Action::Slice((position * 2.0 - 1.0) * ARENA_HALF));
487                }
488            }
489
490            let handle = ui.style().interact(&response).fg_stroke.color;
491            let weak = ui.visuals().weak_text_color();
492            let painter = ui.painter();
493            let axis = rect.top() + lanes + RAIL_GAP;
494            painter.line_segment(
495                [egui::pos2(left, axis), egui::pos2(right, axis)],
496                egui::Stroke::new(1.0, weak),
497            );
498            for x in [left, right] {
499                painter.line_segment(
500                    [egui::pos2(x, axis - 4.0), egui::pos2(x, axis + 4.0)],
501                    egui::Stroke::new(1.0, weak),
502                );
503            }
504
505            let mut hovered = None;
506            for (index, band) in depths.iter().enumerate() {
507                let top = rect.top() + index as f32 * (LANE + LANE_GAP);
508                let dim = if band.asleep { 0.5 } else { 1.0 };
509                let channel = |value: f32| (value * dim * 255.0).clamp(0.0, 255.0) as u8;
510                let color = egui::Color32::from_rgb(
511                    channel(band.color[0]),
512                    channel(band.color[1]),
513                    channel(band.color[2]),
514                );
515                let start = x_for(band.min);
516                let bar = egui::Rect::from_min_max(
517                    egui::pos2(start, top),
518                    egui::pos2(x_for(band.max).max(start + 2.0), top + LANE),
519                );
520                let in_slice = (band.min..=band.max).contains(&slice);
521                painter.rect_filled(
522                    bar,
523                    1.0,
524                    color.gamma_multiply(if in_slice { 1.0 } else { 0.3 }),
525                );
526                if response
527                    .hover_pos()
528                    .is_some_and(|at| bar.expand2(egui::vec2(3.0, LANE_GAP)).contains(at))
529                {
530                    hovered = Some(index);
531                }
532            }
533
534            let x = x_for(slice);
535            painter.line_segment(
536                [egui::pos2(x, rect.top() - 1.0), egui::pos2(x, axis)],
537                egui::Stroke::new(1.0, handle),
538            );
539            painter.add(egui::Shape::convex_polygon(
540                vec![
541                    egui::pos2(x, axis),
542                    egui::pos2(x + HANDLE, axis + HANDLE + 1.0),
543                    egui::pos2(x - HANDLE, axis + HANDLE + 1.0),
544                ],
545                handle,
546                egui::Stroke::NONE,
547            ));
548
549            if response.hovered() {
550                ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
551            }
552            if let Some(index) = hovered {
553                response.on_hover_text_at_pointer(band_name(depths, index));
554            }
555            ui.add_sized(
556                egui::vec2(VALUE_W, rect.height()),
557                egui::Label::new(egui::RichText::new(format!("w {slice:>+.2}")).monospace())
558                    .selectable(false),
559            );
560        });
561    });
562}
563
564fn band_name(depths: &[DepthBand], index: usize) -> String {
565    let label = depths[index].label;
566    let same = |band: &&DepthBand| band.label == label;
567    if depths.iter().filter(same).count() > 1 {
568        format!(
569            "{label} {}",
570            depths[..index].iter().filter(same).count() + 1
571        )
572    } else {
573        label.to_string()
574    }
575}
576
577fn sliders(ui: &mut egui::Ui, app: &Playground, sender: &CommandSender<Playground>) {
578    const VALUE_WIDTH: f32 = 72.0;
579    let width = ui.available_width();
580    let spacing = ui.spacing().item_spacing.x;
581    ui.spacing_mut().slider_width = (width - VALUE_WIDTH - spacing).max(140.0);
582    let row = egui::vec2(width, CONTROL_H);
583    let layout = egui::Layout::left_to_right(egui::Align::Center);
584    let mut slice = *app.slice.get();
585    ui.allocate_ui_with_layout(row, layout, |ui| {
586        let label = format!("w {:>+.3}", slice);
587        let change = slider_with_edit(
588            ui,
589            &mut slice,
590            -W_RANGE..=W_RANGE,
591            &label,
592            "",
593            3,
594            VALUE_WIDTH,
595        );
596        if change.changed {
597            sender.app(Action::Slice(slice));
598        }
599    });
600    if *app.mode.get() == Mode::Toybox {
601        return;
602    }
603    let mut time = *app.time.get();
604    let limit = MAX_T_EXTENT.max(time);
605    ui.allocate_ui_with_layout(row, layout, |ui| {
606        let label = format!("t {:>5.2}s", time);
607        let change = slider_with_edit(ui, &mut time, 0.0..=limit, &label, "s", 2, VALUE_WIDTH);
608        if change.changed {
609            sender.app(Action::Time(time));
610        }
611    });
612}
613
614fn transport(
615    ui: &mut egui::Ui,
616    app: &Playground,
617    panel: &mut Panel,
618    sender: &CommandSender<Playground>,
619) {
620    let mut rate = app.spin.get().rate;
621    ui.horizontal(|ui| {
622        let leading = ((ui.available_width() - 215.0) / 2.0).max(8.0);
623        ui.add_space(leading);
624        let control = egui::vec2(CONTROL_W, CONTROL_H);
625        rate_toggle(ui, control, &mut rate, 0.25, true, false);
626        rate_toggle(ui, control, &mut rate, 0.5, false, false);
627        if play_pause_button(ui, egui::vec2(PLAY_W, CONTROL_H), app.spin.get().running)
628            .on_hover_text("Play or pause rotation.")
629            .clicked()
630        {
631            sender.app(Action::Running(Some(!app.spin.get().running)));
632        }
633        rate_toggle(ui, control, &mut rate, 2.0, false, true);
634        rate_toggle(ui, control, &mut rate, 4.0, true, true);
635        if refresh_button(ui, control)
636            .on_hover_text("Reset poses, rotation, and slice.")
637            .clicked()
638        {
639            sender.app(Action::Reset);
640        }
641        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
642            if chevron_button(
643                ui,
644                control,
645                !panel.expanded,
646                if panel.expanded {
647                    "Collapse controls."
648                } else {
649                    "Expand controls."
650                },
651            )
652            .clicked()
653            {
654                panel.expanded = !panel.expanded;
655            }
656            let utility = egui::vec2(CONTROL_W, CONTROL_H);
657            if ui
658                .add(egui::Button::new(egui::RichText::new("⚙").strong()).min_size(utility))
659                .on_hover_text("Render settings.")
660                .clicked()
661            {
662                panel.show_render = !panel.show_render;
663            }
664            if ui
665                .add(egui::Button::new(egui::RichText::new("?").strong()).min_size(utility))
666                .on_hover_text("Help.")
667                .clicked()
668            {
669                panel.show_about = true;
670            }
671        });
672    });
673    if rate != app.spin.get().rate {
674        sender.app(Action::Rate(rate));
675    }
676}
677
678fn render_settings(
679    context: &egui::Context,
680    app: &Playground,
681    panel: &mut Panel,
682    sender: &CommandSender<Playground>,
683) {
684    let before = *app.display.get();
685    let mut display = before;
686    loam_egui::floating_panel(
687        context,
688        "polytope-playground-render",
689        "Render",
690        &mut panel.show_render,
691        |ui| {
692            ui.label(egui::RichText::new("Surface").strong());
693            ui.radio_value(&mut display.surface, Surface::Raster, "Raster");
694            ui.radio_value(&mut display.surface, Surface::Sdf, "SDF");
695            ui.radio_value(&mut display.surface, Surface::Off, "Off");
696            ui.separator();
697            ui.checkbox(&mut display.wireframe, "Wireframe");
698            ui.checkbox(&mut display.section_perimeter, "Section perimeter");
699            ui.add_enabled_ui(display.wireframe, |ui| {
700                ui.add(
701                    egui::Slider::new(&mut display.wireframe_width_px, 0.5..=6.0)
702                        .clamping(egui::SliderClamping::Edits)
703                        .text("Line width")
704                        .suffix(" px"),
705                );
706                ui.add(
707                    egui::Slider::new(&mut display.wireframe_opacity, 0.05..=1.0)
708                        .clamping(egui::SliderClamping::Edits)
709                        .text("Line opacity")
710                        .fixed_decimals(2),
711                );
712            });
713            ui.separator();
714            ui.label(egui::RichText::new("Color").strong());
715            for mode in ColorMode::ALL {
716                if ui.radio(*app.color.get() == mode, mode.name()).clicked() {
717                    sender.app(Action::Color(mode));
718                }
719            }
720            ui.separator();
721            ui.label(egui::RichText::new("Projection").strong());
722            for family in Family::ALL {
723                if ui
724                    .radio(*app.projection.get() == family, family.name())
725                    .clicked()
726                {
727                    sender.app(Action::Projection(family));
728                }
729            }
730        },
731    );
732    if display != before {
733        sender.app(Action::Display(display));
734    }
735}
736
737fn about(context: &egui::Context, panel: &mut Panel) {
738    egui::Window::new("About Polytope Playground")
739        .id(egui::Id::new("polytope-playground-about"))
740        .open(&mut panel.show_about)
741        .resizable(true)
742        .collapsible(false)
743        .default_width(420.0)
744        .show(context, |ui| {
745            ui.heading("Polytope Playground");
746            ui.label("The w control moves a 3D slice through each 4D shape.");
747            ui.label("Rotation in xy, xz, or yz turns the shape within visible space. Rotation in xw, yw, or zw carries it through the slice.");
748            ui.label("Shadow projection drops w. Perspective uses a 4D focal point. Stereographic projection maps directions on S³ into 3D.");
749            ui.separator();
750            ui.label("In Toybox, primary-drag a shape to carry it, then release it to throw. The line at the bottom is the w axis between the arena walls, with one bar for each shape's extent in w. Click or drag it, or hold Q or E, to move the slice. View > Rope carry makes held shapes swing from the point you grabbed.");
751            ui.label("Right-drag to orbit the camera. Scroll to zoom.");
752            ui.label("In Rotate, Space plays or pauses rotation. Q and E move the w slice in orbit mode.");
753        });
754}
755
756fn rotor_formula(context: &egui::Context, app: &Playground, rotor: Rotor4) {
757    if !*app.formula.get() || *app.mode.get() == Mode::Toybox {
758        return;
759    }
760    let available = context.available_rect();
761    egui::Window::new("Rotation formula")
762        .id(egui::Id::new("polytope-playground-formula"))
763        .title_bar(false)
764        .resizable(false)
765        .collapsible(false)
766        .default_pos(egui::pos2(
767            available.right() - 280.0,
768            available.top() + 16.0,
769        ))
770        .default_width(320.0)
771        .show(context, |ui| {
772            ui.monospace("R = exp(B)");
773            ui.label(egui::RichText::new("B = log(R), in degrees").small().weak());
774            loam_egui::bivector_matrix(ui, &rotor.log());
775        });
776}
777
778const CALLOUT_INSET_PT: f32 = 10.0;
779
780pub(crate) fn callouts(
781    context: &egui::Context,
782    session: &Session<Playground>,
783    anchors: &[(usize, &'static str, glam::Vec3)],
784) {
785    let screen = context.viewport_rect();
786    let half = screen.size() * 0.5;
787    for (index, label, point) in anchors {
788        let Some([x, y]) = session.views().ndc(point.to_array()) else {
789            continue;
790        };
791        let at = egui::pos2(
792            screen.center().x + x * half.x,
793            screen.center().y - y * half.y - CALLOUT_INSET_PT,
794        );
795        if !screen.contains(at) {
796            continue;
797        }
798        egui::Area::new(egui::Id::new(("callout", index)))
799            .fixed_pos(at)
800            .pivot(egui::Align2::CENTER_BOTTOM)
801            .order(egui::Order::Foreground)
802            .show(context, |ui| {
803                egui::Frame::default()
804                    .fill(egui::Color32::from_black_alpha(160))
805                    .inner_margin(egui::Margin::symmetric(6, 2))
806                    .corner_radius(3)
807                    .show(ui, |ui| {
808                        ui.monospace(*label);
809                    });
810            });
811    }
812}
813
814fn filmstrip(ui: &mut egui::Ui, strip: Strip, sender: &CommandSender<Playground>) {
815    let mut held = strip;
816    ui.horizontal_wrapped(|ui| {
817        ui.label("filmstrip");
818        if ui.selectable_label(strip.w, "w cells").clicked() {
819            held.w = !held.w;
820        }
821        if ui.selectable_label(strip.t, "t cells").clicked() {
822            held.t = !held.t;
823        }
824        if strip.w && strip.t && ui.selectable_label(strip.swap_axes, "swap").clicked() {
825            held.swap_axes = !held.swap_axes;
826        }
827    });
828    ui.horizontal_wrapped(|ui| {
829        if strip.w {
830            ui.add(
831                egui::DragValue::new(&mut held.count_w)
832                    .range(MIN_CELLS..=MAX_CELLS)
833                    .speed(0.2)
834                    .prefix("w: "),
835            );
836        }
837        if strip.t {
838            ui.add(
839                egui::DragValue::new(&mut held.count_t)
840                    .range(MIN_CELLS..=MAX_CELLS)
841                    .speed(0.2)
842                    .prefix("t: "),
843            );
844            ui.add(
845                egui::DragValue::new(&mut held.t_extent)
846                    .range(MIN_T_EXTENT..=MAX_T_EXTENT)
847                    .speed(0.02)
848                    .fixed_decimals(2)
849                    .suffix("s")
850                    .prefix("dt: "),
851            );
852        }
853        let subject = ui.button(format!(
854            "subject: {}",
855            catalog::SHAPE_CATALOG[strip.subject()].label
856        ));
857        egui::Popup::menu(&subject).show(|ui| {
858            ui.set_min_width(140.0);
859            for (card, entry) in catalog::SHAPE_CATALOG.iter().enumerate() {
860                if ui.button(entry.label).clicked() {
861                    held.subject = card;
862                    ui.close();
863                }
864            }
865        });
866    });
867    if held != strip {
868        sender.app(Action::Strip(held));
869    }
870}
871
872pub(crate) fn strip_labels(context: &egui::Context, session: &Session<Playground>, cells: &[Cell]) {
873    let strip = *session.app.strip.get();
874    if !strip.on {
875        return;
876    }
877    let per_point = context.pixels_per_point().max(1e-3);
878    let label_top = context.available_rect().top() + 8.0;
879    for (index, cell) in cells.iter().enumerate() {
880        let at = egui::pos2(
881            (cell.viewport.x as f32 + cell.viewport.width as f32 * 0.5) / per_point,
882            ((cell.viewport.y as f32 + 8.0) / per_point).max(label_top),
883        );
884        let text = if strip.t {
885            format!("w={:+.3}  t={:.2}s", cell.w, cell.t)
886        } else {
887            format!("w={:+.3}", cell.w)
888        };
889        egui::Area::new(egui::Id::new(("strip-cell", index)))
890            .fixed_pos(at)
891            .pivot(egui::Align2::CENTER_TOP)
892            .order(egui::Order::Foreground)
893            .show(context, |ui| {
894                egui::Frame::default()
895                    .fill(egui::Color32::from_black_alpha(160))
896                    .inner_margin(egui::Margin::symmetric(6, 2))
897                    .corner_radius(3)
898                    .show(ui, |ui| {
899                        ui.monospace(text);
900                    });
901            });
902    }
903}
904
905#[cfg(test)]
906mod tests {
907    use super::*;
908
909    fn text_position(output: &egui::FullOutput, label: &str) -> egui::Pos2 {
910        output
911            .shapes
912            .iter()
913            .find_map(|shape| match &shape.shape {
914                egui::Shape::Text(text) if text.galley.text() == label => Some(text.pos),
915                _ => None,
916            })
917            .expect("visible label")
918    }
919
920    #[test]
921    fn dropping_a_shape_keeps_neighbor_positions_and_row_height() {
922        for multipass in [false, true] {
923            let context = egui::Context::default();
924            let mut app = loam::app::session::SessionApp::<Playground>::new(
925                loam::runtime::HostConfig::new("test", crate::bindings()),
926            );
927            let sender = app.sender();
928            let mut booted = crate::boot(catalog::DEFAULT_ROW).expect("playground");
929            let render = |app: &mut loam::app::session::SessionApp<Playground>,
930                          booted: &mut crate::Boot,
931                          time,
932                          events| {
933                app.boundary(&mut booted.session, loam::runtime::Input::default())
934                    .expect("boundary");
935                let input = egui::RawInput {
936                    screen_rect: Some(egui::Rect::from_min_size(
937                        egui::Pos2::ZERO,
938                        egui::vec2(800.0, 600.0),
939                    )),
940                    time: Some(time),
941                    events,
942                    ..Default::default()
943                };
944                let draw = |context: &egui::Context| {
945                    egui::CentralPanel::default().show(context, |ui| {
946                        shapes(ui, &booted.session.app, &sender);
947                        ui.label("after row");
948                    });
949                    if multipass && context.input(|input| input.pointer.any_released()) {
950                        context.request_discard("reordered row layout");
951                    }
952                };
953                if multipass {
954                    context.run(input, draw)
955                } else {
956                    context.begin_pass(input);
957                    draw(&context);
958                    context.end_pass()
959                }
960            };
961            let initial = render(&mut app, &mut booted, 0.0, Vec::new());
962            let start = text_position(&initial, "24-cell") + egui::vec2(16.0, 6.0);
963            let target = text_position(&initial, "8-cell") + egui::vec2(60.0, 6.0);
964            let button = |pos, pressed| egui::Event::PointerButton {
965                pos,
966                pressed,
967                button: egui::PointerButton::Primary,
968                modifiers: Default::default(),
969            };
970            render(
971                &mut app,
972                &mut booted,
973                0.01,
974                vec![egui::Event::PointerMoved(start), button(start, true)],
975            );
976            render(
977                &mut app,
978                &mut booted,
979                0.03,
980                vec![egui::Event::PointerMoved(target)],
981            );
982            for frame in 2..20 {
983                render(
984                    &mut app,
985                    &mut booted,
986                    frame as f64 * 0.02,
987                    vec![egui::Event::PointerMoved(target)],
988                );
989            }
990            let dragging = render(
991                &mut app,
992                &mut booted,
993                0.42,
994                vec![egui::Event::PointerMoved(target)],
995            );
996            let released = render(&mut app, &mut booted, 0.44, vec![button(target, false)]);
997            let committed = render(&mut app, &mut booted, 0.46, Vec::new());
998            for label in ["5-cell", "after row"] {
999                let before = text_position(&dragging, label);
1000                assert!(
1001                    before.distance(text_position(&released, label)) < 0.5,
1002                    "{label} shifted on release: {:?} to {:?}",
1003                    before,
1004                    text_position(&released, label)
1005                );
1006                assert!(
1007                    before.distance(text_position(&committed, label)) < 0.5,
1008                    "{label} shifted on commit: {:?} to {:?}",
1009                    before,
1010                    text_position(&committed, label)
1011                );
1012            }
1013            assert!(booted
1014                .session
1015                .app
1016                .slots
1017                .iter()
1018                .any(|(_, slot)| slot.entry.label == "24-cell" && slot.index == 3));
1019        }
1020    }
1021}