Skip to main content

polytope_playground/
console.rs

1use anyhow::{anyhow, bail, Result};
2use loam::app::environment::Environment;
3use loam::app::session::SessionApp;
4use loam::app::CursorPolicy;
5use loam::runtime::Dispatch;
6
7use crate::camera::CameraMode;
8use crate::color::ColorMode;
9use crate::display::{Surface, MAX_WIREFRAME_WIDTH_PX};
10use crate::mode::Mode;
11use crate::projection::Family;
12use crate::{Action, Playground};
13
14const MAX_SEEK_SECONDS: f32 = 1.0e6;
15
16fn number(token: &str, name: &str) -> Result<f32> {
17    token
18        .parse()
19        .map_err(|error| anyhow!("{name}: invalid `{token}`: {error}"))
20}
21
22fn toggle(args: &[&str], usage: &str) -> Result<Option<bool>> {
23    match args {
24        [] => Ok(None),
25        ["on"] => Ok(Some(true)),
26        ["off"] => Ok(Some(false)),
27        _ => bail!("{usage}"),
28    }
29}
30
31fn color(token: &str) -> Option<ColorMode> {
32    match token {
33        "vertex-gradient" => Some(ColorMode::VertexGradient),
34        "unique-edge" => Some(ColorMode::UniqueEdge),
35        _ => ColorMode::from_token(token),
36    }
37}
38
39fn projection(token: &str) -> Option<Family> {
40    match token {
41        "w-pinhole" => Some(Family::Perspective),
42        _ => Family::from_token(token),
43    }
44}
45
46fn set_camera_mode(dispatch: &mut Dispatch<'_, Playground>, mode: CameraMode) {
47    let previous = dispatch.app.camera.get().mode;
48    if mode == CameraMode::Freecam && previous != CameraMode::Freecam {
49        let eye = dispatch.views.root_mut().eye;
50        dispatch.app.camera.get_mut().free.eye = eye;
51    }
52    dispatch.app.camera.get_mut().mode = mode;
53}
54
55pub(crate) fn install(mut app: SessionApp<Playground>) -> SessionApp<Playground> {
56    app = app.command(
57        "demo",
58        "show or select the active demo: rotate | toybox",
59        move |args, submit, _out| match args {
60            [] => {
61                submit.inspect("demo", |dispatch, out| {
62                    out.line(format!("demo: {}", dispatch.app.mode.get().name()));
63                });
64                Ok(())
65            }
66            [token] => {
67                let mode = Mode::from_token(token)
68                    .ok_or_else(|| anyhow!("unknown demo `{token}` (try rotate|toybox)"))?;
69                submit.app(Action::Mode(mode));
70                Ok(())
71            }
72            _ => bail!("usage: demo [rotate|toybox]"),
73        },
74    );
75
76    app = app.command(
77        "reset",
78        "reset poses, rotation, and slice; shapes, view, and camera stay",
79        move |args, submit, _out| {
80            if !args.is_empty() {
81                bail!("usage: reset");
82            }
83            submit.app(Action::Reset);
84            Ok(())
85        },
86    );
87
88    app = app.command(
89        "spin",
90        "play or pause rotation; optional on | off",
91        |args, submit, _out| {
92            submit.app(Action::Running(toggle(args, "usage: spin [on|off]")?));
93            Ok(())
94        },
95    );
96
97    app = app.command(
98        "hud",
99        "toggle the state readout; optional on | off",
100        |args, submit, _out| {
101            submit.app(Action::Hud(toggle(args, "usage: hud [on|off]")?));
102            Ok(())
103        },
104    );
105
106    app = app.command(
107        "seek",
108        "set rotation time in seconds",
109        |args, submit, _out| {
110            let [token] = args else {
111                bail!("usage: seek <seconds>");
112            };
113            let time = number(token, "seek")?;
114            if !time.is_finite() || !(0.0..=MAX_SEEK_SECONDS).contains(&time) {
115                bail!("time must be between 0 and 1000000 seconds");
116            }
117            submit.app(Action::Time(time));
118            Ok(())
119        },
120    );
121
122    app = app.command(
123        "rate",
124        "set rotation speed from 0.25 to 4",
125        |args, submit, _out| {
126            let [token] = args else {
127                bail!("usage: rate <multiplier>");
128            };
129            let rate = number(token, "rate")?;
130            if !rate.is_finite() || !(0.25..=4.0).contains(&rate) {
131                bail!("rate must be between 0.25 and 4");
132            }
133            submit.app(Action::Rate(rate));
134            Ok(())
135        },
136    );
137
138    app = app.command("slice", "set the w cross-section", |args, submit, _out| {
139        let [token] = args else {
140            bail!("usage: slice <w>");
141        };
142        let w = number(token, "slice")?;
143        if !w.is_finite() {
144            bail!("slice must be finite");
145        }
146        submit.app(Action::ExactSlice(w));
147        Ok(())
148    });
149
150    app = app.command(
151        "controls",
152        "toggle the controls panel; optional on | off",
153        |args, submit, _out| {
154            submit.app(Action::Controls(toggle(args, "usage: controls [on|off]")?));
155            Ok(())
156        },
157    );
158
159    app = app.command(
160        "formula",
161        "toggle the rotation formula; optional on | off",
162        |args, submit, _out| {
163            submit.app(Action::Formula(toggle(args, "usage: formula [on|off]")?));
164            Ok(())
165        },
166    );
167
168    app = app.command(
169        "labels",
170        "toggle the shape labels; optional on | off",
171        |args, submit, _out| {
172            submit.app(Action::Labels(toggle(args, "usage: labels [on|off]")?));
173            Ok(())
174        },
175    );
176
177    app = app.command(
178        "guides",
179        "toggle the toybox floor guides; optional on | off",
180        |args, submit, _out| {
181            submit.app(Action::Guides(toggle(args, "usage: guides [on|off]")?));
182            Ok(())
183        },
184    );
185
186    app = app.command(
187        "toybox",
188        "toybox debug drawing: debug [on|off]",
189        |args, submit, _out| match args {
190            ["debug", rest @ ..] => {
191                submit.app(Action::ToyboxDebug(toggle(
192                    rest,
193                    "usage: toybox debug [on|off]",
194                )?));
195                Ok(())
196            }
197            _ => bail!("usage: toybox debug [on|off]"),
198        },
199    );
200
201    app = app.command(
202        "handles",
203        "toggle the 4D rotation handles; optional on | off",
204        |args, submit, _out| {
205            submit.app(Action::Gimbal(toggle(args, "usage: handles [on|off]")?));
206            Ok(())
207        },
208    );
209
210    app = app.command(
211        "floor",
212        "toggle the ground plane; optional on | off",
213        |args, submit, _out| {
214            let setting = toggle(args, "usage: floor [on|off]")?;
215            submit.app_fn("floor", move |dispatch: &mut Dispatch<'_, Playground>| {
216                let shown = &mut dispatch.app.environment.get_mut().floor_visible;
217                *shown = setting.unwrap_or(!*shown);
218            });
219            Ok(())
220        },
221    );
222
223    app = app.command(
224        "ground",
225        "read or set checker colors and fog: dark | light | fog | reset",
226        |args, submit, _out| {
227            let field = match args {
228                [] => Some(None),
229                ["dark"] => Some(Some("dark")),
230                ["light"] => Some(Some("light")),
231                ["fog"] => Some(Some("fog")),
232                _ => None,
233            };
234            if let Some(field) = field {
235                submit.inspect("ground", move |dispatch, out| {
236                    out.line(dispatch.app.environment.get().report(field));
237                });
238                return Ok(());
239            }
240            Environment::default().apply(args)?;
241            let owned = args
242                .iter()
243                .map(|token| (*token).to_string())
244                .collect::<Vec<_>>();
245            submit.app_fn("ground", move |dispatch: &mut Dispatch<'_, Playground>| {
246                let args = owned.iter().map(String::as_str).collect::<Vec<_>>();
247                let mut environment = *dispatch.app.environment.get();
248                if environment.apply(&args).is_ok() {
249                    dispatch.app.environment.set(environment);
250                }
251            });
252            Ok(())
253        },
254    );
255
256    app = app.command(
257        "surface",
258        "set the surface mode: raster | sdf | off; bare selects off",
259        |args, submit, _out| {
260            let surface = match args {
261                [] | ["off"] => Surface::Off,
262                ["raster"] => Surface::Raster,
263                ["sdf"] => Surface::Sdf,
264                _ => bail!("usage: surface [raster|sdf|off]"),
265            };
266            submit.app_fn("surface", move |dispatch: &mut Dispatch<'_, Playground>| {
267                dispatch.app.display.get_mut().surface = surface;
268            });
269            Ok(())
270        },
271    );
272
273    app = app.command(
274        "wireframe",
275        "wireframe [on|off|width [px]|alpha [value]|color [mode]|perspective [mode]]",
276        |args, submit, _out| {
277            match args {
278                [] => submit.app_fn(
279                    "wireframe",
280                    |dispatch: &mut Dispatch<'_, Playground>| {
281                        let shown = &mut dispatch.app.display.get_mut().wireframe;
282                        *shown = !*shown;
283                    },
284                ),
285                ["on"] | ["off"] => {
286                    let shown = args[0] == "on";
287                    submit.app_fn(
288                        "wireframe",
289                        move |dispatch: &mut Dispatch<'_, Playground>| {
290                            dispatch.app.display.get_mut().wireframe = shown;
291                        },
292                    );
293                }
294                ["width"] => submit.inspect("wireframe width", |dispatch, out| {
295                    out.line(format!(
296                        "wireframe width: {:.2} px",
297                        dispatch.app.display.get().wireframe_width_px
298                    ));
299                }),
300                ["width", token] => {
301                    let width_px = number(token, "wireframe width")?;
302                    if !width_px.is_finite()
303                        || width_px <= 0.0
304                        || width_px > MAX_WIREFRAME_WIDTH_PX
305                    {
306                        bail!(
307                            "wireframe width {width_px} out of range; expected (0, {MAX_WIREFRAME_WIDTH_PX}]"
308                        );
309                    }
310                    submit.app_fn(
311                        "wireframe width",
312                        move |dispatch: &mut Dispatch<'_, Playground>| {
313                            dispatch.app.display.get_mut().wireframe_width_px = width_px;
314                        },
315                    );
316                }
317                ["alpha"] => submit.inspect("wireframe alpha", |dispatch, out| {
318                    out.line(format!(
319                        "wireframe alpha: {:.3}",
320                        dispatch.app.display.get().wireframe_opacity
321                    ));
322                }),
323                ["alpha", token] => {
324                    let opacity = number(token, "wireframe alpha")?;
325                    if !opacity.is_finite() || opacity <= 0.0 || opacity > 1.0 {
326                        bail!("wireframe alpha {opacity} out of range; expected (0, 1]");
327                    }
328                    submit.app_fn(
329                        "wireframe alpha",
330                        move |dispatch: &mut Dispatch<'_, Playground>| {
331                            dispatch.app.display.get_mut().wireframe_opacity = opacity;
332                        },
333                    );
334                }
335                ["color"] => submit.app(Action::NextColor),
336                ["color", token] => {
337                    let mode = color(token).ok_or_else(|| {
338                        anyhow!("unknown color mode `{token}` (try vertex-gradient|unique-edge|w-depth)")
339                    })?;
340                    submit.app(Action::Color(mode));
341                }
342                ["perspective"] => submit.app(Action::NextProjection),
343                ["perspective", token] => {
344                    let family = projection(token).ok_or_else(|| {
345                        anyhow!("unknown projection `{token}` (try shadow|perspective|stereographic)")
346                    })?;
347                    submit.app(Action::Projection(family));
348                }
349                _ => bail!(
350                    "usage: wireframe [on|off|width [px]|alpha [value]|color [mode]|perspective [mode]]"
351                ),
352            }
353            Ok(())
354        },
355    );
356
357    app = app.command(
358        "section",
359        "section perimeter [on|off]",
360        |args, submit, _out| {
361            match args {
362                [] => submit.inspect("section", |dispatch, out| {
363                    out.line(format!(
364                        "section perimeter: {}",
365                        if dispatch.app.display.get().section_perimeter {
366                            "on"
367                        } else {
368                            "off"
369                        }
370                    ));
371                }),
372                ["perimeter"] => submit.app_fn(
373                    "section perimeter",
374                    |dispatch: &mut Dispatch<'_, Playground>| {
375                        let shown = &mut dispatch.app.display.get_mut().section_perimeter;
376                        *shown = !*shown;
377                    },
378                ),
379                ["perimeter", "on"] | ["perimeter", "off"] => {
380                    let shown = args[1] == "on";
381                    submit.app_fn(
382                        "section perimeter",
383                        move |dispatch: &mut Dispatch<'_, Playground>| {
384                            dispatch.app.display.get_mut().section_perimeter = shown;
385                        },
386                    );
387                }
388                _ => bail!("usage: section perimeter [on|off]"),
389            }
390            Ok(())
391        },
392    );
393
394    app = app.command(
395        "camera",
396        "camera [orbit|freecam|speed <N>|cursor <hold|toggle>]; bare cycles",
397        |args, submit, _out| {
398            match args {
399                [] => submit.app_fn("camera", |dispatch: &mut Dispatch<'_, Playground>| {
400                    let mode = match dispatch.app.camera.get().mode {
401                        CameraMode::Orbit => CameraMode::Freecam,
402                        CameraMode::Freecam => CameraMode::Orbit,
403                    };
404                    set_camera_mode(dispatch, mode);
405                }),
406                ["orbit"] | ["freecam"] => {
407                    let mode = if args[0] == "orbit" {
408                        CameraMode::Orbit
409                    } else {
410                        CameraMode::Freecam
411                    };
412                    submit.app_fn("camera", move |dispatch: &mut Dispatch<'_, Playground>| {
413                        set_camera_mode(dispatch, mode);
414                    });
415                }
416                ["speed"] => submit.inspect("camera speed", |dispatch, out| {
417                    out.line(format!(
418                        "camera speed: {:.2} u/sec",
419                        dispatch.app.camera.get().speed
420                    ));
421                }),
422                ["speed", token] => {
423                    let speed = number(token, "camera speed")?;
424                    if !speed.is_finite() || speed <= 0.0 {
425                        bail!("camera speed must be finite and positive");
426                    }
427                    submit.app_fn(
428                        "camera speed",
429                        move |dispatch: &mut Dispatch<'_, Playground>| {
430                            dispatch.app.camera.get_mut().speed = speed;
431                        },
432                    );
433                }
434                ["cursor"] => submit.inspect("camera cursor", |dispatch, out| {
435                    let policy = match dispatch.app.camera.get().cursor_policy {
436                        CursorPolicy::Hold => "hold",
437                        CursorPolicy::Toggle => "toggle",
438                    };
439                    out.line(format!("camera cursor: {policy}"));
440                }),
441                ["cursor", "hold"] | ["cursor", "toggle"] => {
442                    let policy = if args[1] == "hold" {
443                        CursorPolicy::Hold
444                    } else {
445                        CursorPolicy::Toggle
446                    };
447                    submit.app_fn(
448                        "camera cursor",
449                        move |dispatch: &mut Dispatch<'_, Playground>| {
450                            dispatch.app.camera.get_mut().cursor_policy = policy;
451                        },
452                    );
453                }
454                _ => bail!("camera: unknown argument (try orbit|freecam|speed|cursor)"),
455            }
456            Ok(())
457        },
458    );
459
460    app
461}
462
463#[cfg(test)]
464mod tests {
465    use std::sync::atomic::{AtomicBool, Ordering};
466    use std::sync::Arc;
467
468    use loam::app::args::Args;
469    use loam::runtime::host::HostConfig;
470    use loam::runtime::{Ctx, DomainError, Eye, Input, Phase, Publication};
471
472    use super::*;
473    use crate::catalog::DEFAULT_ROW;
474    use crate::{bindings, boot};
475
476    #[test]
477    fn console_commands_keep_source_order_and_rejected_styles_preserve_state() {
478        let mut booted = boot(&DEFAULT_ROW[..1]).expect("the session boots");
479        let eye = Eye::looking_at([2.0, 3.0, 7.0], [0.5, 0.25, -1.0], [0.0, 1.0, 0.0]);
480        booted.session.views_mut().root_mut().eye = eye;
481        let mut app = install(SessionApp::with_args(
482            HostConfig::new("console test", bindings()),
483            Args::default(),
484        ));
485        for line in [
486            "demo toybox",
487            "reset",
488            "demo",
489            "slice 2",
490            "wireframe width 3",
491            "wireframe width",
492            "wireframe width NaN",
493            "wireframe width 0",
494            "wireframe width 17",
495            "wireframe alpha 0",
496            "wireframe alpha NaN",
497            "camera freecam",
498        ] {
499            app.console_mut().execute(line);
500        }
501        app.console_mut().dispatch_pending();
502        app.boundary(&mut booted.session, Input::default())
503            .expect("the console commands apply");
504
505        assert_eq!(*booted.session.app.mode.get(), Mode::Toybox);
506        assert_eq!(*booted.session.app.slice.get(), 2.0);
507        assert_eq!(booted.session.app.display.get().wireframe_width_px, 3.0);
508        assert_eq!(booted.session.app.display.get().wireframe_opacity, 1.0);
509        assert_eq!(booted.session.app.camera.get().mode, CameraMode::Freecam);
510        assert_eq!(booted.session.app.camera.get().free.eye, eye);
511        let history = app
512            .console_mut()
513            .ui()
514            .history()
515            .iter()
516            .map(|line| line.text.clone())
517            .collect::<Vec<_>>();
518        assert!(history.iter().any(|line| line == "demo: toybox"));
519        assert!(history
520            .iter()
521            .any(|line| line == "wireframe width: 3.00 px"));
522    }
523    #[test]
524    fn installed_playground_console_recovers_a_fault_at_ordinary_capacity() {
525        const ORDINARY_CAPACITY: usize = 1023;
526
527        let mut booted = boot(&DEFAULT_ROW[..1]).expect("the session boots");
528        let armed = Arc::new(AtomicBool::new(true));
529        let trip = armed.clone();
530        booted.session.system(
531            Phase::Publication,
532            "trip",
533            move |_ctx: Ctx<'_, Playground>| {
534                if trip.swap(false, Ordering::Relaxed) {
535                    return Err(DomainError::ChartBoundary);
536                }
537                Ok(())
538            },
539        );
540        let epoch = booted.session.scene().epoch();
541        let mut app = install(SessionApp::with_args(
542            HostConfig::new("console recovery test", bindings()),
543            Args::default(),
544        ));
545
546        let mut publication = Publication::default();
547        booted
548            .session
549            .publish(&mut publication)
550            .expect_err("the tripped system faults publication");
551
552        for _ in 0..ORDINARY_CAPACITY {
553            app.console_mut().execute("reset");
554        }
555        app.console_mut().execute("recover");
556        app.console_mut().dispatch_pending();
557        app.boundary(&mut booted.session, Input::default())
558            .expect("the host recovers");
559
560        assert_eq!(booted.session.faulted_phase(), None);
561        assert_eq!(booted.session.scene().epoch(), epoch.advance());
562    }
563}