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}