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 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}