1use std::sync::Arc;
2
3use glam::Vec4;
4use loam::app::args::Args;
5use loam::app::session::{launch_or_headless, look, FrameHook, Orbit, SessionApp};
6use loam::math::{Bivector, Bivector4, EuclideanR4};
7use loam::render::pass::FramePass;
8use loam::render::raymarch::BodyUniform;
9use loam::render::{HyperslicePass, LinePass, PointPass, SkyGroundPass};
10use loam::runtime::host::run_headless;
11use loam::runtime::host::{HostConfig, HostError};
12#[cfg(test)]
13use loam::runtime::Input;
14use loam::runtime::{
15 ActionId, AppCommand, Bindings, Ctx, Dispatch, DomainBuilder, DomainError, DomainHandle,
16 Domains, Entity, Eye, GrabHold, Instance, Key, LogCapacity, Material, MaterialId, Outcome,
17 Phase, Pointer, PointerButton, PointerPhase, Pose, PreparedGeometry, PreparedId, Rejection,
18 Section4, Session, SimConfig, SpawnBundle, ViewId, ViewSpec,
19};
20
21#[cfg(test)]
22#[global_allocator]
23static COUNTING_ALLOCATOR: loam_time::alloc::CountingAllocator<std::alloc::System> =
24 loam_time::alloc::CountingAllocator::new(std::alloc::System);
25
26mod camera;
27mod catalog;
28mod color;
29mod console;
30mod consts;
31mod display;
32mod gimbal;
33mod guides;
34mod hud;
35mod mode;
36mod projection;
37mod row;
38mod scene;
39mod strip;
40mod toy;
41mod ui;
42
43use catalog::ShapeEntry;
44use color::{ColorMode, Shades};
45use consts::{BODY_SIZE, BODY_X_SPACING, BODY_Y, W_SCRUB_RATE};
46use display::{Display, Surface};
47use gimbal::Gimbal;
48use mode::{Mode, Spin};
49use projection::Family;
50use strip::{Cell, Strip};
51
52const SPIN: ActionId = ActionId(0);
53const SLICE_UP: ActionId = ActionId(1);
54const SLICE_DOWN: ActionId = ActionId(2);
55const NEXT_MODE: ActionId = ActionId(3);
56const RESET: ActionId = ActionId(4);
57const GIMBAL: ActionId = ActionId(5);
58const STRIP: ActionId = ActionId(6);
59const CONTROLS: ActionId = ActionId(7);
60const PLANE: [ActionId; 6] = [
61 ActionId(10),
62 ActionId(11),
63 ActionId(12),
64 ActionId(13),
65 ActionId(14),
66 ActionId(15),
67];
68
69const SECTION_COLOR: [f32; 4] = [1.0, 0.85, 0.35, 1.0];
70const SECTION_WIDTH_PX: f32 = 2.0;
71const EDGE_WIDTH_PX: f32 = display::DEFAULT_WIREFRAME_WIDTH_PX;
72const HEADLESS_STEPS: u32 = 8;
73const HEADLESS_FRAME: (u32, u32) = (1280, 720);
74const CAMERA_DISTANCE: f32 = 8.0;
75const DOMAIN_NAME: &str = "r4";
76
77#[derive(Clone, Copy)]
78pub(crate) struct Slot {
79 pub(crate) index: usize,
80 pub(crate) entry: ShapeEntry,
81 pub(crate) rest: Vec4,
82}
83
84#[derive(Clone, Copy)]
85pub(crate) struct Wall;
86
87#[derive(Clone, Copy)]
88pub(crate) struct Toy {
89 pub(crate) rest_anchor: Vec4,
90 pub(crate) rest_time: f32,
91}
92
93#[derive(Clone, Copy)]
94pub(crate) struct HiddenSlot {
95 pub(crate) slot: Slot,
96 pub(crate) instance: Option<Instance>,
97}
98
99#[derive(Clone, Copy)]
100pub(crate) struct Control {
101 orbit: Orbit,
102 gimbal: Gimbal,
103 latest: Option<Pointer>,
104 center: glam::Vec3,
105 camera_focus: Option<(bool, usize, Mode)>,
106 filmstrip_return: Option<Orbit>,
107}
108
109impl Default for Control {
110 fn default() -> Self {
111 let mut orbit = Orbit::around([0.0; 3], CAMERA_DISTANCE);
112 orbit.pitch = -0.25;
113 Self {
114 orbit,
115 gimbal: Gimbal::default(),
116 latest: None,
117 center: glam::Vec3::ZERO,
118 camera_focus: None,
119 filmstrip_return: None,
120 }
121 }
122}
123
124#[derive(Clone, Copy)]
125pub(crate) struct Card {
126 geometry: Option<PreparedId>,
127 toy_geometry: Option<PreparedId>,
128 material: MaterialId,
129 cut: MaterialId,
130 shades: Option<Shades>,
131}
132
133impl Card {
134 fn dressed(&self, geometry: PreparedId) -> Instance {
135 Instance::new(geometry, self.material).sectioned(self.cut)
136 }
137
138 pub(crate) fn body(&self) -> Option<Instance> {
139 self.geometry.map(|geometry| self.dressed(geometry))
140 }
141
142 pub(crate) fn toy(&self) -> Option<Instance> {
143 self.toy_geometry.map(|geometry| self.dressed(geometry))
144 }
145}
146
147#[derive(Clone, Default)]
148pub(crate) struct Catalog {
149 cards: Arc<[Card]>,
150}
151
152loam::runtime::stores! {
153 #[derive(Default)]
154 pub struct Playground {
155 slots: Store<Slot>,
156 walls: Store<Wall>,
157 toys: Store<Toy>,
158 hidden: Store<HiddenSlot>,
159 mode: Value<Mode>,
160 spin: Value<Spin>,
161 active: Value<usize>,
162 slice: Value<f32>,
163 projection: Value<Family>,
164 gimbal: Value<bool>,
165 hud: Value<bool>,
166 color: Value<ColorMode>,
167 strip: Value<Strip>,
168 environment: Value<loam::app::environment::Environment>,
169 display: Value<Display>,
170 time: Value<f32>,
171 angles: Value<[f32; 6]>,
172 controls: Value<bool>,
173 formula: Value<bool>,
174 labels: Value<bool>,
175 guides: Value<bool>,
176 toybox_debug: Value<bool>,
177 rope: Value<bool>,
178 camera: Value<camera::Camera>,
179 control: Value<Control>,
180 catalog: Value<Catalog>,
181 }
182}
183
184#[derive(Clone, Copy)]
185pub(crate) enum Action {
186 Mode(Mode),
187 Active(usize),
188 Slice(f32),
189 ExactSlice(f32),
190 Plane(usize),
191 Gimbal(Option<bool>),
192 Running(Option<bool>),
193 Projection(Family),
194 NextProjection,
195 Color(ColorMode),
196 NextColor,
197 Hud(Option<bool>),
198 Shape(usize, usize),
199 AddShape(usize),
200 RemoveShape(usize),
201 ReorderShape { from: usize, to: usize },
202 Controls(Option<bool>),
203 Formula(Option<bool>),
204 Labels(Option<bool>),
205 Guides(Option<bool>),
206 ToyboxDebug(Option<bool>),
207 Rope(Option<bool>),
208 Strip(Strip),
209 Rate(f32),
210 Display(Display),
211 Reset,
212 Time(f32),
213 PlaneAngle(usize, f32),
214 Throw(Entity, [f32; 3]),
215}
216
217pub(crate) struct Boot {
218 pub(crate) session: Session<Playground>,
219 pub(crate) domain: DomainHandle<EuclideanR4>,
220}
221
222fn rest_of(index: usize, len: usize) -> Vec4 {
223 let center = (len.max(1) - 1) as f32 * 0.5;
224 Vec4::new((index as f32 - center) * BODY_X_SPACING, BODY_Y, 0.0, 0.0)
225}
226
227pub(crate) fn boot(row: &[ShapeEntry]) -> Result<Boot, HostError> {
228 let mut session = Session::new(
229 Playground {
230 slots: loam::runtime::Store::tracked(LogCapacity::default()),
231 ..Playground::default()
232 },
233 SimConfig::default(),
234 );
235 let domain = session.register_domain(
236 DomainBuilder::new(DOMAIN_NAME, EuclideanR4)
237 .tracked(LogCapacity::default())
238 .physics(toy::physics_config())
239 .map_err(|error| HostError::Setup(Rejection::Edit(error)))?,
240 );
241 let root = session.views().root();
242
243 let layers = session.dispatch(|d| -> Result<Layers, Rejection> {
244 let cut = d.add_material(Material::lines(SECTION_COLOR, SECTION_WIDTH_PX));
245 let cards: Arc<[Card]> = prepare_catalog(d, cut).into();
246 for (index, entry) in row.iter().enumerate() {
247 let rest = rest_of(index, row.len());
248 let mut bundle = SpawnBundle::new().at(domain, Pose::at(rest)).row(Slot {
249 index,
250 entry: *entry,
251 rest,
252 });
253 if let Some(instance) = card_of(entry).and_then(|card| cards[card].body()) {
254 bundle = bundle.instance(instance);
255 }
256 d.spawn(bundle)?;
257 }
258 let eye = d.spawn(SpawnBundle::new().at(domain, Pose::at(Vec4::ZERO)))?;
259 let r4 = d.domains.typed(domain)?;
260 let section = r4.add_view(ViewSpec::new(root, eye, Section4 { w: 0.0 }))?;
261 let projection = r4.add_view(ViewSpec::new(root, eye, Family::default().mapping(0.0)))?;
262 d.app.catalog.set(Catalog { cards });
263 Ok(Layers {
264 section,
265 projection,
266 })
267 })?;
268 session.views_mut().root_mut().eye =
269 Eye::looking_at([0.0, 3.0, 9.0], [0.0, BODY_Y, 0.0], [0.0, 1.0, 0.0]);
270 session.app.controls.set(true);
271 session.app.guides.set(true);
272
273 let cards = session.app.catalog.get().cards.clone();
274 install_systems(&mut session, domain, layers, cards);
275 session.set_initial()?;
276 Ok(Boot { session, domain })
277}
278
279#[derive(Clone, Copy)]
280struct Layers {
281 section: ViewId,
282 projection: ViewId,
283}
284
285fn card_of(entry: &ShapeEntry) -> Option<usize> {
286 catalog::SHAPE_CATALOG.iter().position(|held| held == entry)
287}
288
289fn prepare_catalog(dispatch: &mut Dispatch<'_, Playground>, cut: MaterialId) -> Vec<Card> {
290 catalog::SHAPE_CATALOG
291 .iter()
292 .map(|entry| {
293 let [r, g, b] = entry.body_color;
294 let material = dispatch.add_material(Material::lines([r, g, b, 1.0], EDGE_WIDTH_PX));
295 let polytope = entry.shape.polytope4();
296 Card {
297 geometry: polytope.map(|polytope| {
298 dispatch.prepare(PreparedGeometry::Polytope4 {
299 polytope,
300 scale: BODY_SIZE,
301 })
302 }),
303 toy_geometry: polytope.map(|polytope| {
304 dispatch.prepare(PreparedGeometry::Polytope4 {
305 polytope,
306 scale: toy::BODY_SIZE,
307 })
308 }),
309 material,
310 cut,
311 shades: polytope.map(|polytope| {
312 let topology = polytope.topology();
313 Shades {
314 gradient: dispatch.add_palette(color::vertex_gradient_colors(topology)),
315 unique: dispatch.add_palette(color::unique_edge_colors(topology.edges)),
316 extent: color::w_extent(topology, BODY_SIZE),
317 }
318 }),
319 }
320 })
321 .collect()
322}
323
324fn install_systems(
325 session: &mut Session<Playground>,
326 domain: DomainHandle<EuclideanR4>,
327 layers: Layers,
328 cards: Arc<[Card]>,
329) {
330 session.system(
331 Phase::Dispatch,
332 "controls",
333 move |mut ctx: Ctx<'_, Playground>| {
334 control_camera(&mut ctx, domain);
335 control_primary(&mut ctx, domain);
336 if ctx.input.pressed(SPIN) && *ctx.app.mode.get() == Mode::Rotate {
337 let running = ctx.app.spin.get().running;
338 ctx.commands.app(Action::Running(Some(!running)));
339 }
340 if ctx.input.pressed(NEXT_MODE) {
341 let next = match *ctx.app.mode.get() {
342 Mode::Rotate => Mode::Toybox,
343 Mode::Toybox => Mode::Rotate,
344 };
345 ctx.commands.app(Action::Mode(next));
346 }
347 if ctx.input.pressed(RESET) {
348 ctx.commands.app(Action::Reset);
349 }
350 if ctx.input.pressed(GIMBAL) {
351 ctx.commands.app(Action::Gimbal(None));
352 }
353 if ctx.input.pressed(CONTROLS) {
354 ctx.commands.app(Action::Controls(None));
355 }
356 if ctx.input.pressed(STRIP) {
357 let mut strip = *ctx.app.strip.get();
358 strip.on = !strip.on;
359 ctx.commands.app(Action::Strip(strip));
360 }
361 for (index, action) in PLANE.into_iter().enumerate() {
362 if ctx.input.pressed(action) {
363 ctx.commands.app(Action::Plane(index));
364 }
365 }
366 Ok(())
367 },
368 );
369
370 session.system(
371 Phase::Dispatch,
372 "grab hold",
373 move |ctx: Ctx<'_, Playground>| -> Result<(), DomainError> {
374 let hold = if *ctx.app.rope.get() {
375 GrabHold::Anchor
376 } else {
377 GrabHold::Center
378 };
379 if let Some(physics) = ctx.domains.typed(domain)?.physics_mut() {
380 physics.set_grab_hold(hold);
381 }
382 Ok(())
383 },
384 );
385
386 let mut applied = None;
387 session.system(
388 Phase::Publication,
389 "slice view",
390 move |ctx: Ctx<'_, Playground>| -> Result<(), DomainError> {
391 let w = *ctx.app.slice.get();
392 let revision = ctx.app.slice.version();
393 if Some(revision) == applied {
394 return Ok(());
395 }
396 let r4 = ctx.domains.typed(domain)?;
397 let spec = r4
398 .view_mut(layers.section)
399 .ok_or(DomainError::Unsupported("section view"))?;
400 spec.set_mapping(Section4 { w });
401 applied = Some(revision);
402 Ok(())
403 },
404 );
405
406 let mut styled = None;
407 let mut styled_slots = loam::runtime::Cursor::default();
408 session.system(
409 Phase::Publication,
410 "shading",
411 move |ctx: Ctx<'_, Playground>| -> Result<(), DomainError> {
412 let app = &mut *ctx.app;
413 let revision = (
414 app.color.version(),
415 app.strip.version(),
416 app.display.version(),
417 app.active.version(),
418 );
419 let slots_changed = app.slots.changed_since(&mut styled_slots);
420 if !slots_changed && styled == Some(revision) {
421 return Ok(());
422 }
423 let mode = *app.color.get();
424 let strip = app.strip.get().on;
425 let display = *app.display.get();
426 let active = *app.active.get();
427 let subject = if display.single {
428 app.slots
429 .iter()
430 .find(|(_, slot)| slot.index == active)
431 .map(|(entity, _)| entity)
432 } else {
433 None
434 };
435 let r4 = ctx.domains.typed(domain)?;
436 r4.set_view_subject(layers.section, subject)?;
437 r4.set_view_subject(layers.projection, subject)?;
438 let spec = r4
439 .view_mut(layers.section)
440 .ok_or(DomainError::Unsupported("section view"))?;
441 spec.enabled = !strip;
442 spec.edges = false;
443 spec.section_edges = display.wireframe && display.section_perimeter;
444 spec.section_faces = display.surface == Surface::Raster;
445 let spec = r4
446 .view_mut(layers.projection)
447 .ok_or(DomainError::Unsupported("projection view"))?;
448 spec.enabled = display.wireframe && !strip;
449 spec.section_edges = false;
450 spec.section_faces = false;
451 for (entity, slot) in app.slots.iter() {
452 let Some(card) = card_of(&slot.entry).map(|index| cards[index]) else {
453 continue;
454 };
455 let Some(shades) = card.shades else {
456 continue;
457 };
458 if let Some(instance) = r4.instance_mut(entity) {
459 instance.shading = shades.of(mode);
460 instance.section = (!strip).then_some(card.cut);
461 instance.line_width_px = Some(display.wireframe_width_px);
462 instance.line_opacity = Some(display.wireframe_opacity);
463 }
464 }
465 styled = Some(revision);
466 app.slots.catch_up(&mut styled_slots);
467 Ok(())
468 },
469 );
470
471 let mut shown = None;
472 session.system(
473 Phase::Publication,
474 "projection view",
475 move |ctx: Ctx<'_, Playground>| -> Result<(), DomainError> {
476 let family = *ctx.app.projection.get();
477 let slice = *ctx.app.slice.get();
478 let revision = (ctx.app.projection.version(), ctx.app.slice.version());
479 if shown == Some(revision) {
480 return Ok(());
481 }
482 let r4 = ctx.domains.typed(domain)?;
483 let spec = r4
484 .view_mut(layers.projection)
485 .ok_or(DomainError::Unsupported("projection view"))?;
486 spec.set_mapping(family.mapping(slice));
487 shown = Some(revision);
488 Ok(())
489 },
490 );
491
492 session.system(
493 Phase::Simulation,
494 "slice scrub",
495 |ctx: Ctx<'_, Playground>| {
496 if ctx.app.camera.get().mode != camera::CameraMode::Orbit {
497 return Ok(());
498 }
499 let scrub =
500 f32::from(ctx.input.is_held(SLICE_UP)) - f32::from(ctx.input.is_held(SLICE_DOWN));
501 if scrub == 0.0 {
502 return Ok(());
503 }
504 let next = *ctx.app.slice.get() + scrub * W_SCRUB_RATE * ctx.step.dt;
505 let reach = slice_reach(*ctx.app.mode.get());
506 ctx.app.slice.set(next.clamp(-reach, reach));
507 Ok(())
508 },
509 );
510 session.system(
511 Phase::Simulation,
512 "spin",
513 move |ctx: Ctx<'_, Playground>| -> Result<(), DomainError> {
514 if !ctx.app.spin.get().running || *ctx.app.mode.get() == Mode::Toybox {
515 return Ok(());
516 }
517 let time = *ctx.app.time.get() + ctx.step.dt * ctx.app.spin.get().rate;
518 display::seek(ctx.app, ctx.domains, domain, time)?;
519 Ok(())
520 },
521 );
522 session.system(
523 Phase::Simulation,
524 "free camera",
525 |ctx: Ctx<'_, Playground>| {
526 if ctx.app.camera.get().mode == camera::CameraMode::Freecam && ctx.input.cursor_locked {
527 let axes = [
528 f32::from(ctx.input.is_held(camera::RIGHT))
529 - f32::from(ctx.input.is_held(camera::LEFT)),
530 f32::from(ctx.input.is_held(SLICE_UP))
531 - f32::from(ctx.input.is_held(SLICE_DOWN)),
532 f32::from(ctx.input.is_held(camera::FORWARD))
533 - f32::from(ctx.input.is_held(camera::BACKWARD)),
534 ];
535 let camera = ctx.app.camera.get_mut();
536 camera.free.travel(axes, camera.speed * ctx.step.dt);
537 look(ctx.views, camera.free.eye);
538 }
539 Ok(())
540 },
541 );
542 session.system(
543 Phase::Simulation,
544 "toy settle",
545 move |ctx: Ctx<'_, Playground>| -> Result<(), DomainError> {
546 toy::settle(ctx.app, ctx.domains, domain, ctx.step)
547 },
548 );
549}
550
551impl AppCommand<Playground> for Action {
552 fn name(&self) -> &'static str {
553 match self {
554 Self::Mode(mode) => mode.name(),
555 Self::Active(_) => "active",
556 Self::Slice(_) | Self::ExactSlice(_) => "slice",
557 Self::Plane(_) => "plane",
558 Self::Gimbal(None) => "gimbal",
559 Self::Gimbal(Some(_)) => "handles",
560 Self::Running(_) => "spin",
561 Self::Projection(family) => family.name(),
562 Self::NextProjection => "wireframe perspective",
563 Self::Color(mode) => mode.name(),
564 Self::NextColor => "wireframe color",
565 Self::Hud(_) => "hud",
566 Self::Shape(_, card) => catalog::SHAPE_CATALOG
567 .get(*card)
568 .map_or("shape", |entry| entry.label),
569 Self::AddShape(_) => "add shape",
570 Self::RemoveShape(_) => "remove shape",
571 Self::ReorderShape { .. } => "reorder shape",
572 Self::Controls(_) => "controls",
573 Self::Formula(_) => "formula",
574 Self::Labels(_) => "labels",
575 Self::Guides(_) => "guides",
576 Self::ToyboxDebug(_) => "toybox debug",
577 Self::Rope(_) => "rope carry",
578 Self::Strip(_) => "strip",
579 Self::Rate(_) => "rate",
580 Self::Display(_) => "display",
581 Self::Reset => "reset",
582 Self::Time(_) => "time",
583 Self::PlaneAngle(_, _) => "plane angle",
584 Self::Throw(_, _) => "throw toy",
585 }
586 }
587
588 fn apply(&mut self, dispatch: &mut Dispatch<'_, Playground>) -> Result<Outcome, Rejection> {
589 let domain = dispatch.domains.named::<EuclideanR4>(DOMAIN_NAME)?;
590 let Catalog { cards } = dispatch.app.catalog.get().clone();
591 match *self {
592 Action::Mode(mode) => mode::set_mode(dispatch, domain, &cards, mode)?,
593 Action::Active(slot) => {
594 if slot >= dispatch.app.slots.len() {
595 return Err(Rejection::Unsupported("no such slot"));
596 }
597 dispatch.app.active.set(slot);
598 }
599 Action::ReorderShape { from, to } => {
600 row::reorder_shape(dispatch, domain, from, to)?;
601 }
602 Action::Slice(w) => {
603 if !w.is_finite() {
604 return Err(Rejection::Unsupported("slice is not finite"));
605 }
606 let reach = slice_reach(*dispatch.app.mode.get());
607 dispatch.app.slice.set(w.clamp(-reach, reach));
608 }
609 Action::ExactSlice(w) => {
610 if !w.is_finite() {
611 return Err(Rejection::Unsupported("slice is not finite"));
612 }
613 let reach = slice_reach(*dispatch.app.mode.get());
614 if !(-reach..=reach).contains(&w) {
615 return Err(Rejection::Unsupported(
616 "slice is outside the mode's w range",
617 ));
618 }
619 dispatch.app.slice.set(w);
620 }
621 Action::Plane(plane) => mode::toggle_plane(dispatch, plane)?,
622 Action::Gimbal(setting) => {
623 let shown = *dispatch.app.gimbal.get();
624 dispatch.app.gimbal.set(setting.unwrap_or(!shown));
625 }
626 Action::Running(setting) => {
627 if *dispatch.app.mode.get() == Mode::Toybox {
628 return Err(Rejection::Unsupported("rotation belongs to Rotate"));
629 }
630 let running = dispatch.app.spin.get_mut();
631 running.running = setting.unwrap_or(!running.running);
632 }
633 Action::Projection(family) => {
634 dispatch.app.projection.set(family);
635 if family == Family::Stereographic {
636 dispatch.app.display.get_mut().wireframe = true;
637 }
638 }
639 Action::NextProjection => {
640 let held = *dispatch.app.projection.get();
641 let at = Family::ALL
642 .iter()
643 .position(|family| *family == held)
644 .unwrap_or(0);
645 let family = Family::ALL[(at + 1) % Family::ALL.len()];
646 dispatch.app.projection.set(family);
647 if family == Family::Stereographic {
648 dispatch.app.display.get_mut().wireframe = true;
649 }
650 }
651 Action::Color(mode) => dispatch.app.color.set(mode),
652 Action::NextColor => {
653 let held = *dispatch.app.color.get();
654 let at = ColorMode::ALL
655 .iter()
656 .position(|mode| *mode == held)
657 .unwrap_or(0);
658 dispatch
659 .app
660 .color
661 .set(ColorMode::ALL[(at + 1) % ColorMode::ALL.len()]);
662 }
663 Action::Hud(setting) => {
664 let shown = *dispatch.app.hud.get();
665 dispatch.app.hud.set(setting.unwrap_or(!shown));
666 }
667 Action::Strip(strip) => mode::set_strip(dispatch.app, strip)?,
668 Action::Rate(rate) => {
669 if !rate.is_finite() {
670 return Err(Rejection::Unsupported("the rate is not finite"));
671 }
672 dispatch.app.spin.get_mut().rate = rate.clamp(0.0, consts::MAX_RATE);
673 }
674 Action::Display(display) => display::set(dispatch.app, display)?,
675 Action::Reset => mode::reset(dispatch, domain, &cards)?,
676 Action::Controls(setting) => {
677 let visible = *dispatch.app.controls.get();
678 dispatch.app.controls.set(setting.unwrap_or(!visible));
679 }
680 Action::Formula(setting) => {
681 let visible = *dispatch.app.formula.get();
682 dispatch.app.formula.set(setting.unwrap_or(!visible));
683 }
684 Action::Labels(setting) => {
685 let visible = *dispatch.app.labels.get();
686 dispatch.app.labels.set(setting.unwrap_or(!visible));
687 }
688 Action::Guides(setting) => {
689 let visible = *dispatch.app.guides.get();
690 dispatch.app.guides.set(setting.unwrap_or(!visible));
691 }
692 Action::Rope(setting) => {
693 let rope = *dispatch.app.rope.get();
694 dispatch.app.rope.set(setting.unwrap_or(!rope));
695 }
696 Action::ToyboxDebug(setting) => {
697 let visible = *dispatch.app.toybox_debug.get();
698 dispatch.app.toybox_debug.set(setting.unwrap_or(!visible));
699 }
700 Action::Time(time) => {
701 display::seek(dispatch.app, dispatch.domains, domain, time)
702 .map_err(Rejection::Domain)?;
703 }
704 Action::PlaneAngle(plane, angle) => {
705 display::set_plane_angle(dispatch, domain, plane, angle)?;
706 }
707 Action::Shape(slot, card) => {
708 let Some(entry) = catalog::SHAPE_CATALOG.get(card) else {
709 return Ok(Outcome::Done);
710 };
711 mode::set_shape(dispatch, domain, slot, *entry, cards[card])?;
712 }
713 Action::AddShape(card) => {
714 let Some(entry) = catalog::SHAPE_CATALOG.get(card) else {
715 return Ok(Outcome::Done);
716 };
717 row::add_shape(dispatch, domain, *entry, cards[card])?;
718 }
719 Action::RemoveShape(slot) => row::remove_shape(dispatch, domain, slot)?,
720 Action::Throw(entity, pointer) => {
721 mode::throw_toy(dispatch, domain, entity, pointer)?;
722 }
723 }
724 Ok(Outcome::Done)
725 }
726}
727
728pub(crate) fn bindings() -> Bindings {
729 let mut bound = Bindings::new()
730 .key(Key::Space, SPIN)
731 .key(Key::Letter('t'), SPIN)
732 .key(Key::Letter('m'), NEXT_MODE)
733 .key(Key::Letter('r'), RESET)
734 .key(Key::Letter('g'), GIMBAL)
735 .key(Key::Letter('f'), STRIP)
736 .key(Key::Letter('h'), CONTROLS)
737 .key(Key::Letter('d'), camera::RIGHT)
738 .key(Key::Letter('a'), camera::LEFT)
739 .key(Key::Letter('w'), camera::FORWARD)
740 .key(Key::Letter('s'), camera::BACKWARD)
741 .key(Key::Letter('e'), SLICE_UP)
742 .key(Key::Letter('q'), SLICE_DOWN);
743 for (index, action) in PLANE.into_iter().enumerate() {
744 bound = bound.key(Key::Digit(index as u8 + 1), action);
745 }
746 bound
747}
748
749pub(crate) struct Frame {
750 sky: SkyGroundPass,
751 hyperslice: HyperslicePass,
752 rings: LinePass,
753 guides: LinePass,
754 grab_point: PointPass,
755 hud: loam::text::TextPass,
756}
757
758impl Frame {
759 pub(crate) fn new() -> Self {
760 Self {
761 sky: SkyGroundPass::new(scene::ground(true)),
762 hyperslice: HyperslicePass::new(scene::shader_source()),
763 rings: LinePass::new("gimbal"),
764 guides: LinePass::new("toybox-guides"),
765 grab_point: PointPass::new("grab-point"),
766 hud: hud::pass(),
767 }
768 }
769
770 fn passes(&self) -> Vec<Box<dyn FramePass>> {
771 vec![
772 Box::new(self.sky.clone()),
773 Box::new(self.hyperslice.clone()),
774 Box::new(self.rings.clone()),
775 Box::new(self.guides.clone()),
776 Box::new(self.grab_point.clone()),
777 Box::new(self.hud.clone()),
778 ]
779 }
780}
781
782struct Scratch {
783 center: glam::Vec3,
784 slots: Vec<(Entity, ShapeEntry, f32)>,
785 bodies: Vec<BodyUniform>,
786 cells: Vec<Cell>,
787 strip: Vec<(loam::render::Viewport, f32, BodyUniform)>,
788 subject: loam::math::Rotor4,
789 anchors: Vec<(usize, &'static str, glam::Vec3)>,
790 depths: Vec<toy::DepthBand>,
791}
792
793impl Scratch {
794 fn new() -> Self {
795 Self {
796 center: glam::Vec3::ZERO,
797 slots: Vec::new(),
798 bodies: Vec::new(),
799 cells: Vec::new(),
800 strip: Vec::new(),
801 subject: loam::math::Rotor4::IDENTITY,
802 anchors: Vec::new(),
803 depths: Vec::with_capacity(5),
804 }
805 }
806}
807
808fn collect(
809 session: &Session<Playground>,
810 domain: DomainHandle<EuclideanR4>,
811 alpha: f32,
812 scratch: &mut Scratch,
813) {
814 let active = *session.app.active.get();
815 let display = *session.app.display.get();
816 scratch.slots.clear();
817 scratch.slots.extend(
818 session
819 .app
820 .slots
821 .iter()
822 .filter(|(_, slot)| !display.single || slot.index == active)
823 .map(|(entity, slot)| {
824 let size = if session.app.toys.get(entity).is_some() {
825 toy::BODY_SIZE
826 } else {
827 BODY_SIZE
828 };
829 (entity, slot.entry, size)
830 }),
831 );
832 scratch.anchors.clear();
833 scratch.anchors.extend(
834 session
835 .app
836 .slots
837 .iter()
838 .filter(|(_, slot)| !display.single || slot.index == active)
839 .map(|(_, slot)| (slot.index, slot.entry.label, glam::Vec3::ZERO)),
840 );
841 scratch.bodies.clear();
842 scratch.subject = loam::math::Rotor4::IDENTITY;
843 let mut sum = glam::Vec3::ZERO;
844 let Ok(r4) = session.domains().read(domain) else {
845 return;
846 };
847 for (at, (entity, entry, size)) in scratch.slots.iter().enumerate() {
848 let Some(pose) = r4.drawn_pose(*entity, alpha) else {
849 continue;
850 };
851 if scratch.anchors.get(at).is_some_and(|held| held.0 == active) {
852 scratch.subject = pose.frame;
853 }
854 let center = pose.point.truncate();
855 sum += center;
856 if let Some(anchor) = scratch.anchors.get_mut(at) {
857 anchor.2 = center;
858 }
859 if display.surface == Surface::Sdf
860 || (display.surface == Surface::Raster && entry.shape.polytope4().is_none())
861 {
862 scratch.bodies.push(scene::body_of(entry, &pose, *size));
863 }
864 }
865 scratch.center = sum / scratch.slots.len().max(1) as f32;
866}
867
868fn main() -> Result<(), HostError> {
869 launch_or_headless(interactive, headless)
870}
871
872fn interactive(args: Args) -> Result<(Session<Playground>, SessionApp<Playground>), HostError> {
873 let row = catalog::parse_row(&args).map_err(|error| HostError::Host(format!("{error:#}")))?;
874 let booted = boot(&row)?;
875 let frame = Frame::new();
876 let config = HostConfig::new("polytope playground", bindings());
877
878 let sky = frame.sky.clone();
879 let hyperslice = frame.hyperslice.clone();
880 let rings = frame.rings.clone();
881 let guide_pass = frame.guides.clone();
882 let grab_point = frame.grab_point.clone();
883 let mut guides = guides::Guides::default();
884 let readout = frame.hud.clone();
885 let mut lines = String::new();
886 let mut gimbal_renderer = gimbal::GimbalRenderer::default();
887 let domain = booted.domain;
888 let mut scratch = Scratch::new();
889 let mut panel = ui::Panel::default();
890 let mut app = SessionApp::with_args(config, args).recover_on_fault(RESET);
891 for pass in frame.passes() {
892 app = app.pass(pass);
893 }
894 app = console::install(app).on_frame(move |hook: &mut FrameHook<'_, Playground>| {
895 let camera = *hook.session.app.camera.get();
896 hook.capture_cursor(
897 camera.mode == camera::CameraMode::Freecam
898 && !hook
899 .ui
900 .is_some_and(|context| context.wants_keyboard_input()),
901 camera.cursor_policy,
902 );
903 collect(hook.session, domain, hook.alpha, &mut scratch);
904 let eye = hook
905 .session
906 .views()
907 .get(hook.session.views().root())
908 .map_or(Eye::default(), |root| root.eye);
909 let strip = *hook.session.app.strip.get();
910
911 let slice = *hook.session.app.slice.get();
912 let environment = *hook.session.app.environment.get();
913 let floor = environment.floor_visible;
914 sky.publish(
915 &eye,
916 environment.sky,
917 environment.ground(consts::FLOOR_Y, floor),
918 );
919 hyperslice.set_enabled(strip.on || !scratch.bodies.is_empty());
920 hyperslice.publish(
921 scene::uniforms(&eye, environment.sky, slice, floor),
922 &scratch.bodies,
923 );
924 fill_strip(
925 &strip,
926 turn_of(hook.session),
927 hook.size,
928 slice,
929 &mut scratch,
930 );
931 hyperslice.publish_strip(&scratch.strip);
932 rings.publish(
933 &eye,
934 gimbal_renderer.rings(&hook.session.app.control.get().gimbal, scratch.center),
935 );
936 guides.update(hook.session, domain, hook.alpha);
937 guide_pass.publish(&eye, &guides.lines);
938 grab_point.publish(&eye, &guides.points);
939 let shown = *hook.session.app.hud.get();
940 let seat = hook.ui.map_or(hud::Seat::default(), |context| {
941 hud::Seat::in_panel(context.available_rect(), context.pixels_per_point())
942 });
943 hud::publish(
944 &readout,
945 shown.then(|| readout_of(hook.session)).as_ref(),
946 seat,
947 &mut lines,
948 );
949 if let Some(context) = hook.ui {
950 toy::fill_depth_bands(hook.session, domain, hook.alpha, &mut scratch.depths);
951 ui::draw(
952 context,
953 hook.session,
954 &mut panel,
955 hook.sender,
956 scratch.subject,
957 &scratch.depths,
958 );
959 if *hook.session.app.labels.get() && !strip.on {
960 ui::callouts(context, hook.session, &scratch.anchors);
961 }
962 ui::strip_labels(context, hook.session, &scratch.cells);
963 }
964 });
965 Ok((booted.session, app))
966}
967
968fn focus_camera(
969 orbit: &mut Orbit,
970 camera_focus: &mut Option<(bool, usize, Mode)>,
971 filmstrip_return: &mut Option<Orbit>,
972 strip: Strip,
973 focus: (bool, usize, Mode),
974 center: glam::Vec3,
975) {
976 if strip.on {
977 if filmstrip_return.is_none() {
978 let mut centered = Orbit::around([0.0; 3], orbit.distance);
979 centered.yaw = orbit.yaw;
980 centered.pitch = orbit.pitch;
981 *filmstrip_return = Some(std::mem::replace(orbit, centered));
982 *camera_focus = Some(focus);
983 }
984 orbit.target[1] = if strip.w && strip.t { BODY_Y } else { 0.0 };
985 return;
986 }
987 if let Some(returned) = filmstrip_return.take() {
988 *orbit = returned;
989 }
990 if *camera_focus == Some(focus) {
991 return;
992 }
993 let reset =
994 camera_focus.is_some_and(|previous| previous.2 != focus.2 || previous.0 && !focus.0);
995 let target = [center.x, 0.0, center.z];
996 if reset {
997 *orbit = Orbit::around(target, CAMERA_DISTANCE);
998 orbit.pitch = -0.25;
999 } else {
1000 orbit.target = target;
1001 }
1002 *camera_focus = Some(focus);
1003}
1004
1005fn slice_reach(mode: Mode) -> f32 {
1006 match mode {
1007 Mode::Rotate => consts::W_RANGE,
1008 Mode::Toybox => toy::ARENA_HALF,
1009 }
1010}
1011
1012fn readout_of(session: &Session<Playground>) -> hud::Readout {
1013 hud::Readout {
1014 slice: *session.app.slice.get(),
1015 rate: session.app.spin.get().rate,
1016 bodies: session.app.slots.len(),
1017 planes: session.app.spin.get().planes,
1018 toybox: *session.app.mode.get() == Mode::Toybox,
1019 }
1020}
1021
1022fn turn_of(session: &Session<Playground>) -> Bivector4 {
1023 match *session.app.mode.get() {
1024 Mode::Rotate => session.app.spin.get().omega(),
1025 Mode::Toybox => Bivector4::ZERO,
1026 }
1027}
1028
1029fn fill_strip(
1030 strip: &Strip,
1031 omega: Bivector4,
1032 frame: (u32, u32),
1033 slice: f32,
1034 scratch: &mut Scratch,
1035) {
1036 scratch.strip.clear();
1037 if !strip.on {
1038 scratch.cells.clear();
1039 return;
1040 }
1041 strip.cells([frame.0, frame.1], slice, BODY_SIZE, &mut scratch.cells);
1042 let entry = catalog::SHAPE_CATALOG[strip.subject()];
1043 for cell in &scratch.cells {
1044 let rotor = ((omega * cell.t).exp() * scratch.subject).normalize();
1045 scratch.strip.push((
1046 cell.viewport,
1047 cell.w,
1048 BodyUniform::polytope_with_rotor(
1049 [0.0, BODY_Y, 0.0, 0.0],
1050 entry.shape.shape_id(),
1051 BODY_SIZE,
1052 rotor,
1053 entry.body_color,
1054 ),
1055 ));
1056 }
1057}
1058
1059fn scene_center(
1060 app: &Playground,
1061 domains: &Domains,
1062 domain: DomainHandle<EuclideanR4>,
1063) -> glam::Vec3 {
1064 let active = *app.active.get();
1065 let single = app.display.get().single;
1066 let Ok(r4) = domains.read(domain) else {
1067 return glam::Vec3::ZERO;
1068 };
1069 let mut sum = glam::Vec3::ZERO;
1070 let mut count = 0;
1071 for (entity, slot) in app.slots.iter() {
1072 if single && slot.index != active {
1073 continue;
1074 }
1075 let Some(pose) = r4.poses().get(entity) else {
1076 continue;
1077 };
1078 sum += pose.point.truncate();
1079 count += 1;
1080 }
1081 sum / count.max(1) as f32
1082}
1083
1084fn control_camera(ctx: &mut Ctx<'_, Playground>, domain: DomainHandle<EuclideanR4>) {
1085 let center = scene_center(ctx.app, ctx.domains, domain);
1086 let focus = (
1087 ctx.app.display.get().single,
1088 *ctx.app.active.get(),
1089 *ctx.app.mode.get(),
1090 );
1091 let strip = *ctx.app.strip.get();
1092 {
1093 let control = ctx.app.control.get_mut();
1094 for pointer in &ctx.input.pointers {
1095 control.latest = Some(*pointer);
1096 }
1097 control.center = center;
1098 focus_camera(
1099 &mut control.orbit,
1100 &mut control.camera_focus,
1101 &mut control.filmstrip_return,
1102 strip,
1103 focus,
1104 center,
1105 );
1106 }
1107 let mode = ctx.app.camera.get().mode;
1108 let eye = match mode {
1109 camera::CameraMode::Orbit => {
1110 let control = ctx.app.control.get_mut();
1111 control.orbit.apply(ctx.input);
1112 control.orbit.eye()
1113 }
1114 camera::CameraMode::Freecam => {
1115 let camera = ctx.app.camera.get_mut();
1116 if ctx.input.cursor_locked {
1117 camera.free.look(ctx.input.look);
1118 }
1119 camera.free.eye
1120 }
1121 };
1122 look(ctx.views, eye);
1123}
1124
1125fn control_primary(ctx: &mut Ctx<'_, Playground>, domain: DomainHandle<EuclideanR4>) {
1126 if ctx.dragging().is_some_and(|drag| {
1127 *ctx.app.mode.get() != Mode::Toybox || !ctx.app.toys.contains(drag.entity)
1128 }) {
1129 ctx.cancel_drag();
1130 }
1131 let (latest, center, mut gimbal) = {
1132 let control = ctx.app.control.get();
1133 (control.latest, control.center, control.gimbal)
1134 };
1135 gimbal.enabled =
1136 *ctx.app.gimbal.get() && *ctx.app.mode.get() == Mode::Rotate && !ctx.app.strip.get().on;
1137 let root = ctx.views.root();
1138 let ray = latest.and_then(|pointer| ctx.views.ray(root, pointer.ndc));
1139 if gimbal.enabled {
1140 gimbal.aim(ray.as_ref(), center);
1141 } else {
1142 gimbal.release();
1143 }
1144 let mut aimed = false;
1145 for index in 0..ctx.input.pointers.len() {
1146 let pointer = ctx.input.pointers[index];
1147 if pointer.button != Some(PointerButton::Primary) {
1148 continue;
1149 }
1150 match pointer.phase {
1151 PointerPhase::Began => {
1152 let ring = gimbal.enabled
1153 && ctx
1154 .views
1155 .ray(root, pointer.ndc)
1156 .is_some_and(|ray| gimbal.press(&ray, center));
1157 if !ring && *ctx.app.mode.get() == Mode::Toybox && !ctx.app.strip.get().on {
1158 let _ = ctx.grab(pointer.ndc, pointer.time);
1159 aimed = true;
1160 }
1161 }
1162 PointerPhase::Moved if gimbal.held() => {
1163 let turn = ctx
1164 .views
1165 .ray(root, pointer.ndc)
1166 .and_then(|ray| gimbal.turn(&ray));
1167 if let Some(rotor) = turn {
1168 let _ = mode::turn_row(ctx.app, ctx.domains, domain, rotor);
1169 }
1170 }
1171 PointerPhase::Moved if ctx.dragging().is_some() => {
1172 aimed = true;
1173 if ctx.drag(pointer.ndc, pointer.time).is_err() {
1174 ctx.cancel_drag();
1175 }
1176 }
1177 PointerPhase::Ended if gimbal.held() => gimbal.release(),
1178 PointerPhase::Ended if ctx.dragging().is_some() => {
1179 if let Some(release) = ctx.release_at(pointer.time) {
1180 ctx.commands
1181 .app(Action::Throw(release.entity, release.velocity));
1182 }
1183 }
1184 PointerPhase::Cancelled => {
1185 gimbal.release();
1186 ctx.cancel_drag();
1187 }
1188 _ => {}
1189 }
1190 }
1191 if let Some(pointer) = latest.filter(|_| !aimed && ctx.dragging().is_some()) {
1192 let time = ctx.input.time;
1193 if ctx.drag(pointer.ndc, time).is_err() {
1194 ctx.cancel_drag();
1195 }
1196 }
1197 ctx.app.control.get_mut().gimbal = gimbal;
1198}
1199
1200fn report(booted: &mut Boot, config: &HostConfig) -> Result<Vec<String>, HostError> {
1201 let publication = run_headless(&mut booted.session, config, HEADLESS_STEPS, &[])?;
1202 let active = *booted.session.app.active.get();
1203 let entry = booted
1204 .session
1205 .app
1206 .slots
1207 .iter()
1208 .find(|(_, slot)| slot.index == active)
1209 .map(|(_, slot)| slot.entry);
1210 let edges = entry
1211 .and_then(|entry| entry.shape.polytope4())
1212 .map_or(0, |polytope| polytope.edge_count());
1213 Ok(vec![
1214 format!(
1215 "active: {} with {edges} edges",
1216 entry.map_or("none", |entry| entry.label)
1217 ),
1218 format!(
1219 "published segments: {}",
1220 publication
1221 .views
1222 .iter()
1223 .map(|view| view.records.segments().len())
1224 .sum::<usize>()
1225 ),
1226 format!(
1227 "section fills: {} triangles",
1228 publication
1229 .views
1230 .iter()
1231 .map(|view| view.records.triangles().len())
1232 .sum::<usize>()
1233 ),
1234 strip_line(&mut Scratch::new(), *booted.session.app.strip.get()),
1235 ])
1236}
1237
1238fn strip_line(scratch: &mut Scratch, strip: Strip) -> String {
1239 let mut shown = strip;
1240 shown.on = true;
1241 fill_strip(&shown, Bivector4::ZERO, HEADLESS_FRAME, 0.0, scratch);
1242 let (cols, rows, _) = shown.grid();
1243 format!("filmstrip: {} cells, {cols} by {rows}", scratch.strip.len())
1244}
1245
1246fn headless(args: Args) -> Result<(), HostError> {
1247 let row = catalog::parse_row(&args).map_err(|error| HostError::Host(format!("{error:#}")))?;
1248 let mut booted = boot(&row)?;
1249 let config = HostConfig::new("polytope playground", bindings());
1250 for line in report(&mut booted, &config)? {
1251 println!("{line}");
1252 }
1253 Ok(())
1254}
1255
1256#[cfg(test)]
1257mod tests {
1258 use loam::math::Rotor;
1259 use loam::render::raymarch::RaymarchShape;
1260 use loam::runtime::{AppCommand, ChartCommand, ChartPoint, Command, Records};
1261 use loam::shape::polytope::Polytope4;
1262
1263 use super::*;
1264
1265 const CELL24: ShapeEntry = ShapeEntry {
1266 shape: RaymarchShape::Polytope(Polytope4::Cell24),
1267 body_color: [0.95, 0.45, 0.85],
1268 label: "24-cell",
1269 long_name: "icositetrachoron",
1270 category: catalog::Category::RegularPolychoron,
1271 };
1272
1273 const EYE_BACK: f32 = 5.0;
1274
1275 fn one_slot() -> Boot {
1276 let mut booted = boot(&[CELL24]).expect("the session boots");
1277 booted.session.views_mut().root_mut().eye =
1278 Eye::looking_at([0.0, BODY_Y, EYE_BACK], [0.0, BODY_Y, 0.0], [0.0, 1.0, 0.0]);
1279 booted.session.app.spin.get_mut().running = false;
1280 booted
1281 }
1282
1283 pub(crate) fn send(booted: &mut Boot, action: Action) {
1284 booted.session.submit(Command::App(Box::new(action)));
1285 }
1286
1287 fn slot_entity(booted: &Boot) -> Entity {
1288 booted
1289 .session
1290 .app
1291 .slots
1292 .iter()
1293 .map(|(entity, _)| entity)
1294 .next()
1295 .expect("the row has a slot")
1296 }
1297
1298 fn published<R>(
1299 session: &mut Session<Playground>,
1300 records: &mut Records,
1301 read: impl FnOnce(&loam::runtime::Publication) -> R,
1302 ) -> R {
1303 records.publish(session).expect("published");
1304 let publication = records.lend().expect("the buffer is free");
1305 let value = read(&publication);
1306 records.release(publication);
1307 value
1308 }
1309
1310 #[test]
1311 fn a_reset_before_any_action_leaves_every_action_working() {
1312 let mut booted = one_slot();
1313 booted.session.submit(Command::Reset);
1314 booted
1315 .session
1316 .boundary(Input::default())
1317 .expect("the reset applied");
1318 send(&mut booted, Action::Mode(Mode::Toybox));
1319 booted
1320 .session
1321 .boundary(Input::default())
1322 .expect("the boundary ran");
1323
1324 assert_eq!(*booted.session.app.mode.get(), Mode::Toybox);
1325 assert!(
1326 booted.session.results()[0].outcome.is_ok(),
1327 "the first action after a reset was refused: {:?}",
1328 booted.session.results()[0].outcome
1329 );
1330 }
1331
1332 #[test]
1333 fn spin_is_refused_in_toybox_instead_of_reporting_done() {
1334 let mut booted = one_slot();
1335 send(&mut booted, Action::Mode(Mode::Toybox));
1336 booted
1337 .session
1338 .boundary(Input::default())
1339 .expect("the boundary ran");
1340 send(&mut booted, Action::Running(Some(true)));
1341 booted
1342 .session
1343 .boundary(Input::default())
1344 .expect("the boundary ran");
1345 let outcome = &booted.session.results()[0].outcome;
1346 assert!(
1347 matches!(outcome, Err(Rejection::Unsupported(_))),
1348 "spin in Toybox reported {outcome:?}"
1349 );
1350 }
1351
1352 #[test]
1353 fn a_mode_command_lands_in_the_store_at_the_next_boundary() {
1354 let mut booted = one_slot();
1355 send(&mut booted, Action::Mode(Mode::Toybox));
1356 assert_eq!(
1357 *booted.session.app.mode.get(),
1358 Mode::Rotate,
1359 "an intent changed the store before any boundary applied it"
1360 );
1361
1362 booted
1363 .session
1364 .boundary(Input::default())
1365 .expect("the boundary ran");
1366
1367 assert_eq!(*booted.session.app.mode.get(), Mode::Toybox);
1368 assert_eq!(
1369 booted.session.results().len(),
1370 1,
1371 "the boundary applied {} commands, not the one mode change",
1372 booted.session.results().len()
1373 );
1374 assert!(booted.session.results()[0].outcome.is_ok());
1375 }
1376
1377 #[test]
1378 fn wireframe_and_perimeter_toggles_control_separate_edge_layers() {
1379 let mut booted = one_slot();
1380 booted.session.boundary(Input::default()).expect("boundary");
1381 let mut records = Records::default();
1382 for reset in [false, true] {
1383 if reset {
1384 send(&mut booted, Action::Reset);
1385 booted.session.boundary(Input::default()).expect("reset");
1386 }
1387 published(&mut booted.session, &mut records, |publication| {
1388 assert!(publication
1389 .views
1390 .iter()
1391 .all(|view| view.records.segments().is_empty()));
1392 assert!(!publication.views[0].records.triangles().is_empty());
1393 });
1394 }
1395 send(
1396 &mut booted,
1397 Action::Display(Display {
1398 wireframe: true,
1399 ..Display::default()
1400 }),
1401 );
1402 booted
1403 .session
1404 .boundary(Input::default())
1405 .expect("the boundary ran");
1406 let counts = published(&mut booted.session, &mut records, |publication| {
1407 publication
1408 .views
1409 .iter()
1410 .map(|view| view.records.segments().len())
1411 .collect::<Vec<_>>()
1412 });
1413 assert!(counts[0] > 0);
1414 assert_eq!(counts[1], 96);
1415 let mut display = *booted.session.app.display.get();
1416 display.section_perimeter = false;
1417 send(&mut booted, Action::Display(display));
1418 booted
1419 .session
1420 .boundary(Input::default())
1421 .expect("perimeter off");
1422 published(&mut booted.session, &mut records, |publication| {
1423 assert!(publication.views[0].records.segments().is_empty());
1424 assert_eq!(publication.views[1].records.segments().len(), 96);
1425 });
1426 }
1427
1428 #[test]
1429 fn an_unmoved_row_under_an_unchanged_view_is_not_republished() {
1430 let mut booted = one_slot();
1431 let mut records = Records::default();
1432 booted
1433 .session
1434 .boundary(Input::default())
1435 .expect("the boundary ran");
1436 let built = |session: &mut Session<Playground>, records: &mut Records| {
1437 published(session, records, |publication| {
1438 publication.views[0].records.built()
1439 })
1440 };
1441 built(&mut booted.session, &mut records);
1442 let first = built(&mut booted.session, &mut records);
1443 booted
1444 .session
1445 .boundary(Input::default())
1446 .expect("the boundary ran");
1447 let idle = built(&mut booted.session, &mut records);
1448 assert_eq!(first, idle, "publication rebuilt records nothing changed");
1449
1450 send(&mut booted, Action::Slice(0.4));
1451 booted
1452 .session
1453 .boundary(Input::default())
1454 .expect("the boundary ran");
1455 let moved = built(&mut booted.session, &mut records);
1456 assert_ne!(
1457 idle, moved,
1458 "a new slice left the view's records at their old build"
1459 );
1460 }
1461
1462 #[test]
1463 fn toybox_mode_parks_the_rotation_row_and_spawns_five_floor_cleared_toys() {
1464 let mut booted = one_slot();
1465 let original = slot_entity(&booted);
1466 send(&mut booted, Action::Mode(Mode::Toybox));
1467 booted
1468 .session
1469 .boundary(Input::default())
1470 .expect("the boundary ran");
1471 assert!(booted.session.app.hidden.contains(original));
1472 assert!(!booted.session.app.slots.contains(original));
1473 assert_eq!(booted.session.app.slots.len(), 5);
1474 assert_eq!(booted.session.app.toys.len(), 5);
1475 assert_eq!(booted.session.app.walls.len(), 8);
1476 let expected = [
1477 Polytope4::Cell24,
1478 Polytope4::Tesseract,
1479 Polytope4::Pentatope,
1480 Polytope4::Cell16,
1481 Polytope4::Tesseract,
1482 ];
1483 let slots: Vec<(Entity, Slot)> = booted
1484 .session
1485 .app
1486 .slots
1487 .iter()
1488 .map(|(entity, slot)| (entity, *slot))
1489 .collect();
1490 let r4 = booted
1491 .session
1492 .domains()
1493 .read(booted.domain)
1494 .expect("the r4 domain");
1495 for &(entity, slot) in &slots {
1496 assert_eq!(slot.entry.collider_polytope(), Some(expected[slot.index]));
1497 let pose = r4.poses().get(entity).expect("the toy has a pose");
1498 let polytope = expected[slot.index];
1499 let lowest = polytope
1500 .topology()
1501 .vertices
1502 .iter()
1503 .map(|vertex| (pose.point + pose.frame.apply(*vertex * toy::BODY_SIZE)).y)
1504 .fold(f32::INFINITY, f32::min);
1505 assert!((lowest - consts::FLOOR_Y - 0.20).abs() < 1e-5);
1506 assert!(r4
1507 .physics()
1508 .and_then(|physics| physics.body(entity))
1509 .is_some());
1510 }
1511 for _ in 0..600 {
1512 booted.session.tick().expect("the tick ran");
1513 }
1514 let toys: Vec<Entity> = booted
1515 .session
1516 .app
1517 .toys
1518 .iter()
1519 .map(|(entity, _)| entity)
1520 .collect();
1521 let r4 = booted
1522 .session
1523 .domains()
1524 .read(booted.domain)
1525 .expect("the r4 domain");
1526 let physics = r4.physics().expect("the domain has physics");
1527 assert!(toys.iter().all(|entity| physics
1528 .body(*entity)
1529 .and_then(|body| physics.world().body(body))
1530 .is_some_and(|body| body.is_sleeping())));
1531 for (entity, slot) in slots {
1532 let body = physics
1533 .world()
1534 .body(physics.body(entity).expect("body"))
1535 .expect("body row");
1536 let lowest = slot
1537 .entry
1538 .collider_polytope()
1539 .expect("polytope")
1540 .topology()
1541 .vertices
1542 .iter()
1543 .map(|vertex| {
1544 (body.position + body.orientation.rotation.apply(*vertex * toy::BODY_SIZE)).y
1545 })
1546 .fold(f32::INFINITY, f32::min);
1547 assert!(
1548 (consts::FLOOR_Y..=consts::FLOOR_Y + loam::physics::manifold::PENETRATION_SLOP)
1549 .contains(&lowest),
1550 "{} rests at y={lowest}",
1551 slot.entry.label
1552 );
1553 }
1554 }
1555
1556 #[test]
1557 fn row_edits_preserve_rotation_and_reset_keeps_authored_shapes() {
1558 let mut booted = one_slot();
1559 send(&mut booted, Action::PlaneAngle(0, 0.4));
1560 send(&mut booted, Action::AddShape(4));
1561 send(&mut booted, Action::AddShape(6));
1562 send(&mut booted, Action::RemoveShape(0));
1563 booted.session.boundary(Input::default()).expect("edit row");
1564 assert!(booted.session.results().iter().all(|r| r.outcome.is_ok()));
1565 let active = booted
1566 .session
1567 .app
1568 .slots
1569 .iter()
1570 .find(|(_, slot)| slot.index == *booted.session.app.active.get())
1571 .expect("active shape")
1572 .0;
1573 let frame = booted
1574 .session
1575 .domains()
1576 .read(booted.domain)
1577 .expect("domain")
1578 .poses()
1579 .get(active)
1580 .expect("pose")
1581 .frame;
1582 send(&mut booted, Action::ReorderShape { from: 0, to: 2 });
1583 booted.session.boundary(Input::default()).expect("reorder");
1584 assert!(booted.session.results().iter().all(|r| r.outcome.is_ok()));
1585 assert_eq!(booted.session.app.active.get(), &0);
1586 assert_eq!(
1587 booted
1588 .session
1589 .app
1590 .slots
1591 .get(active)
1592 .expect("active shape")
1593 .index,
1594 0
1595 );
1596 let rows: Vec<_> = booted
1597 .session
1598 .app
1599 .slots
1600 .iter()
1601 .map(|(entity, slot)| (entity, *slot))
1602 .collect();
1603 assert_eq!(rows.len(), 2);
1604 let domain = booted
1605 .session
1606 .domains()
1607 .read(booted.domain)
1608 .expect("domain");
1609 for (entity, slot) in &rows {
1610 assert_eq!(slot.rest, rest_of(slot.index, 2));
1611 let pose = domain.poses().get(*entity).expect("pose");
1612 assert_ne!(pose.frame, loam::math::Rotor4::IDENTITY);
1613 assert_eq!(pose.frame, frame);
1614 assert_eq!(pose.point, slot.rest);
1615 assert_eq!(domain.instances().contains(*entity), slot.index == 1);
1616 }
1617 send(&mut booted, Action::Reset);
1618 booted.session.boundary(Input::default()).expect("reset");
1619 assert_eq!(booted.session.app.slots.len(), 2);
1620 let domain = booted
1621 .session
1622 .domains()
1623 .read(booted.domain)
1624 .expect("domain");
1625 for (entity, slot) in rows {
1626 let pose = domain.poses().get(entity).expect("same entity");
1627 assert_eq!(pose.point, slot.rest);
1628 assert_eq!(pose.frame, loam::math::Rotor4::IDENTITY);
1629 }
1630 }
1631
1632 #[test]
1633 fn rotate_pointer_drag_cannot_pick_or_translate_a_shape() {
1634 let mut booted = one_slot();
1635 booted.session.boundary(Input::default()).expect("boundary");
1636 let entity = slot_entity(&booted);
1637 let before = *booted
1638 .session
1639 .domains()
1640 .read(booted.domain)
1641 .expect("domain")
1642 .poses()
1643 .get(entity)
1644 .expect("pose");
1645 for (phase, ndc) in [
1646 (PointerPhase::Began, [0.0, 0.0]),
1647 (PointerPhase::Moved, [0.5, 0.0]),
1648 (PointerPhase::Ended, [0.5, 0.0]),
1649 ] {
1650 let pointer = Pointer {
1651 id: 0,
1652 button: Some(PointerButton::Primary),
1653 ndc,
1654 delta: [0.5, 0.0],
1655 phase,
1656 time: 1.0,
1657 };
1658 booted
1659 .session
1660 .boundary(Input {
1661 pointers: vec![pointer],
1662 ..Input::default()
1663 })
1664 .expect("boundary");
1665 }
1666 assert!(booted.session.dragging().is_none());
1667 let after = booted
1668 .session
1669 .domains()
1670 .read(booted.domain)
1671 .expect("domain")
1672 .poses()
1673 .get(entity)
1674 .expect("pose");
1675 assert_eq!(after.point, before.point);
1676 assert_eq!(after.frame, before.frame);
1677 }
1678
1679 #[test]
1680 fn a_warmed_frame_with_every_overlay_on_asks_the_allocator_for_nothing() {
1681 let mut booted = one_slot();
1682 booted
1683 .session
1684 .dispatch(|dispatch| Action::Gimbal(None).apply(dispatch))
1685 .expect("handles");
1686 booted
1687 .session
1688 .dispatch(|dispatch| Action::Hud(None).apply(dispatch))
1689 .expect("readout");
1690 send(&mut booted, Action::Color(ColorMode::WDepth));
1691 send(
1692 &mut booted,
1693 Action::Strip(Strip {
1694 on: true,
1695 w: true,
1696 t: true,
1697 ..Strip::default()
1698 }),
1699 );
1700 booted
1701 .session
1702 .boundary(Input::default())
1703 .expect("the boundary ran");
1704 assert!(booted.session.results().iter().all(|r| r.outcome.is_ok()));
1705
1706 let mut records = Records::default();
1707 let mut scratch = Scratch::new();
1708 let mut gimbal = Gimbal::default();
1709 gimbal.enabled = true;
1710 let mut gimbal_renderer = gimbal::GimbalRenderer::default();
1711 let mut lines = String::new();
1712 let frame = |booted: &mut Boot,
1713 records: &mut Records,
1714 scratch: &mut Scratch,
1715 gimbal_renderer: &mut gimbal::GimbalRenderer,
1716 lines: &mut String| {
1717 booted
1718 .session
1719 .boundary(Input::default())
1720 .expect("the boundary ran");
1721 booted.session.tick().expect("the tick ran");
1722 records.publish(&mut booted.session).expect("published");
1723 let publication = records.lend().expect("the buffer is free");
1724 let alpha = publication.alpha;
1725 records.release(publication);
1726 collect(&booted.session, booted.domain, alpha, scratch);
1727 fill_strip(
1728 &{ *booted.session.app.strip.get() },
1729 turn_of(&booted.session),
1730 HEADLESS_FRAME,
1731 0.0,
1732 scratch,
1733 );
1734 gimbal_renderer.rings(&gimbal, scratch.center);
1735 hud::write_readout(lines, &readout_of(&booted.session));
1736 };
1737 for _ in 0..16 {
1738 frame(
1739 &mut booted,
1740 &mut records,
1741 &mut scratch,
1742 &mut gimbal_renderer,
1743 &mut lines,
1744 );
1745 }
1746
1747 let bytes = loam_time::alloc::bytes_allocated_by(|| {
1748 for _ in 0..16 {
1749 frame(
1750 &mut booted,
1751 &mut records,
1752 &mut scratch,
1753 &mut gimbal_renderer,
1754 &mut lines,
1755 );
1756 }
1757 })
1758 .expect("the counting allocator is installed");
1759 assert_eq!(
1760 bytes, 0,
1761 "sixteen warmed frames of boundary, tick, publication, collection, strip, gimbal, and readout asked the allocator for {bytes} bytes"
1762 );
1763 }
1764
1765 #[test]
1766 fn reset_restyles_replacement_toys_even_when_the_row_count_does_not_change() {
1767 let mut booted = one_slot();
1768 send(&mut booted, Action::Mode(Mode::Toybox));
1769 send(&mut booted, Action::Color(ColorMode::UniqueEdge));
1770 send(
1771 &mut booted,
1772 Action::Display(Display {
1773 wireframe: true,
1774 wireframe_width_px: 4.0,
1775 wireframe_opacity: 0.2,
1776 ..Display::default()
1777 }),
1778 );
1779 booted
1780 .session
1781 .boundary(Input::default())
1782 .expect("style toys");
1783 let mut records = Records::default();
1784 published(&mut booted.session, &mut records, |_| ());
1785 let instances = |booted: &Boot| {
1786 booted
1787 .session
1788 .domains()
1789 .read(booted.domain)
1790 .expect("domain")
1791 .instances()
1792 .iter()
1793 .map(|(_, instance)| *instance)
1794 .collect::<Vec<_>>()
1795 };
1796 let before = instances(&booted);
1797 assert_eq!(before.len(), 5);
1798 assert!(before
1799 .iter()
1800 .all(|i| i.line_width_px == Some(4.0) && i.line_opacity == Some(0.2)));
1801 send(&mut booted, Action::Reset);
1802 booted
1803 .session
1804 .boundary(Input::default())
1805 .expect("reset toys");
1806 published(&mut booted.session, &mut records, |_| ());
1807 assert_eq!(instances(&booted), before);
1808 }
1809
1810 #[test]
1811 fn a_primary_grab_does_not_block_secondary_orbit_or_wheel_zoom() {
1812 let mut booted = one_slot();
1813 send(&mut booted, Action::Mode(Mode::Toybox));
1814 booted.session.boundary(Input::default()).expect("toybox");
1815 let center = toy::pose_at(Polytope4::Pentatope, 0.0).point.truncate();
1816 booted.session.app.control.get_mut().orbit = Orbit::around(center.to_array(), EYE_BACK);
1817 let mut input = loam::app::session::input::InputMap::default();
1818 input.resize(800, 600, 1.0);
1819 input.button([0.0; 2], PointerButton::Primary, true);
1820 booted.session.boundary(input.take()).expect("grab");
1821 let picked = booted.session.dragging().expect("grab");
1822 booted.session.boundary(Input::default()).expect("hold");
1823
1824 input.button([0.0; 2], PointerButton::Secondary, true);
1825 input.moved([0.05, 0.05]);
1826 input.wheel([0.0, 1.0]);
1827 let movement = input.take();
1828 booted.session.boundary(movement).expect("move");
1829 let orbit = booted.session.app.control.get().orbit;
1830 let root = booted.session.views().root();
1831 let eye = booted.session.views().get(root).expect("root view").eye;
1832 assert!(orbit.yaw < -0.1 && orbit.pitch > 0.08);
1833 assert!(orbit.distance < EYE_BACK);
1834 let held = booted.session.dragging().expect("still grabbed");
1835 assert_eq!(held.entity, picked.entity);
1836 let clip = loam::render::view::root_view_projection(&eye)
1837 * glam::Vec3::from(held.image_point()).extend(1.0);
1838 assert!((clip.x / clip.w - 0.05).abs() < 1e-5);
1839 assert!((clip.y / clip.w - 0.05).abs() < 1e-5);
1840 let physics = booted
1841 .session
1842 .domains()
1843 .read(booted.domain)
1844 .expect("domain");
1845 assert!(physics.physics().expect("physics").is_held(picked.entity));
1846 }
1847
1848 fn flicked(step_ndc: f32) -> (Vec4, Vec4, Vec4) {
1849 use loam::physics::PhysicsSpace;
1850
1851 let mut booted = one_slot();
1852 send(&mut booted, Action::Mode(Mode::Toybox));
1853 booted.session.boundary(Input::default()).expect("toybox");
1854 let entity = booted
1855 .session
1856 .app
1857 .slots
1858 .iter()
1859 .find(|(_, slot)| slot.index == 2)
1860 .map(|(entity, _)| entity)
1861 .expect("middle toy");
1862 let center = booted
1863 .session
1864 .domains()
1865 .read(booted.domain)
1866 .expect("domain")
1867 .poses()
1868 .get(entity)
1869 .expect("pose")
1870 .point
1871 .truncate();
1872 booted.session.app.control.get_mut().orbit = Orbit::around(center.to_array(), EYE_BACK);
1873 booted.session.boundary(Input::default()).expect("aim");
1874 booted.session.grab([0.0; 2], 0.0).expect("grab");
1875 booted.session.boundary(Input::default()).expect("hold");
1876 booted.session.tick().expect("tick");
1877 let mut time = 0.0;
1878 for step in 1..=6 {
1879 time = f64::from(step) / 60.0;
1880 booted
1881 .session
1882 .drag([0.0, step_ndc * step as f32], time)
1883 .expect("drag");
1884 booted.session.boundary(Input::default()).expect("carry");
1885 booted.session.tick().expect("tick");
1886 }
1887 let release = booted.session.release_at(time).expect("held");
1888 send(&mut booted, Action::Throw(release.entity, release.velocity));
1889 booted.session.boundary(Input::default()).expect("throw");
1890 let r4 = booted
1891 .session
1892 .domains()
1893 .read(booted.domain)
1894 .expect("domain");
1895 let physics = r4.physics().expect("physics");
1896 let body = physics
1897 .world()
1898 .body(physics.body(entity).expect("body"))
1899 .expect("row");
1900 let handle = physics.released_anchor(entity).expect("released");
1901 let slip = EuclideanR4.velocity_at_point(body, handle) - body.velocity;
1902 let flick = glam::Vec3::from(release.velocity).extend(0.0);
1903 (body.velocity, slip, flick)
1904 }
1905
1906 #[test]
1907 fn a_rigid_flick_spins_the_toy_the_way_it_moved_the_grab_point() {
1908 let (_, slip, flick) = flicked(0.06);
1909 assert!(
1910 slip.dot(flick.normalize()) > 0.5,
1911 "a flick along {flick} left the grab point turning at {slip}"
1912 );
1913 }
1914
1915 #[test]
1916 fn a_flick_past_the_carry_cap_still_throws_faster() {
1917 let (fast, _, _) = flicked(0.22);
1918 let (faster, _, _) = flicked(0.35);
1919 assert!(
1920 faster.length() > 1.4 * fast.length(),
1921 "a faster flick moved the throw only from {} to {}",
1922 fast.length(),
1923 faster.length()
1924 );
1925 }
1926
1927 #[test]
1928 fn a_carry_by_the_camera_alone_throws_with_its_motion() {
1929 let mut booted = one_slot();
1930 send(&mut booted, Action::Mode(Mode::Toybox));
1931 booted.session.boundary(Input::default()).expect("toybox");
1932 let center = toy::pose_at(Polytope4::Pentatope, 0.0).point.truncate();
1933 booted.session.app.control.get_mut().orbit = Orbit::around(center.to_array(), EYE_BACK);
1934 let pointer = |phase, time| Pointer {
1935 id: 0,
1936 button: Some(PointerButton::Primary),
1937 ndc: [0.0; 2],
1938 delta: [0.0; 2],
1939 phase,
1940 time,
1941 };
1942 booted
1943 .session
1944 .boundary(Input {
1945 pointers: vec![pointer(PointerPhase::Began, 0.0)],
1946 ..Input::default()
1947 })
1948 .expect("grab");
1949 let picked = booted.session.dragging().expect("grab").entity;
1950 for step in 1..=12 {
1951 booted.session.app.control.get_mut().orbit.distance -= 0.1;
1952 booted
1953 .session
1954 .boundary(Input {
1955 time: step as f64 * 0.016,
1956 ..Input::default()
1957 })
1958 .expect("carry");
1959 booted.session.tick().expect("the carry tick ran");
1960 }
1961 booted
1962 .session
1963 .boundary(Input {
1964 pointers: vec![pointer(PointerPhase::Ended, 13.0 * 0.016)],
1965 time: 13.0 * 0.016,
1966 ..Input::default()
1967 })
1968 .expect("release");
1969 let physics = booted
1970 .session
1971 .domains()
1972 .read(booted.domain)
1973 .expect("domain")
1974 .physics()
1975 .expect("physics");
1976 let body = physics.body(picked).expect("body");
1977 let velocity = physics.world().body(body).expect("body row").velocity;
1978 assert!(velocity.z < -1.0, "a camera carry released at {velocity}");
1979 }
1980
1981 #[test]
1982 fn a_toybox_drag_stays_held_between_samples_and_reports_its_guides() {
1983 let mut booted = one_slot();
1984 send(&mut booted, Action::Mode(Mode::Toybox));
1985 booted
1986 .session
1987 .boundary(Input::default())
1988 .expect("the boundary ran");
1989 let entity = booted
1990 .session
1991 .app
1992 .slots
1993 .iter()
1994 .find(|(_, slot)| slot.index == 2)
1995 .map(|(entity, _)| entity)
1996 .expect("the center toy is live");
1997 let center = booted
1998 .session
1999 .domains()
2000 .read(booted.domain)
2001 .expect("the r4 domain")
2002 .poses()
2003 .get(entity)
2004 .expect("the center toy has a pose")
2005 .point
2006 .truncate();
2007 booted.session.views_mut().root_mut().eye = Eye::looking_at(
2008 [center.x, center.y, EYE_BACK],
2009 center.to_array(),
2010 [0.0, 1.0, 0.0],
2011 );
2012 let picked = booted
2013 .session
2014 .grab([0.0, 0.0], 0.0)
2015 .expect("the ray through the slot center picks it");
2016 assert_eq!(picked.entity, entity);
2017 booted.session.boundary(Input::default()).expect("hold");
2018 booted.session.app.toybox_debug.set(true);
2019 let mut guides = guides::Guides::default();
2020 guides.update(&booted.session, booted.domain, 1.0);
2021 assert_eq!(guides.points.len(), 1);
2022 assert_eq!(guides.lines.len(), 8);
2023 let anchor = guides.points[0].position;
2024
2025 let target = booted
2026 .session
2027 .drag([0.0, 0.28], 1.0)
2028 .expect("the drag meets its plane");
2029 booted
2030 .session
2031 .boundary(Input::default())
2032 .expect("the boundary applied the move");
2033 guides.update(&booted.session, booted.domain, 1.0);
2034 assert_eq!(guides.points[0].position, anchor);
2035 let refusal = booted
2036 .session
2037 .dispatch(|dispatch| {
2038 dispatch.apply(Command::Chart(
2039 booted.domain.id(),
2040 ChartCommand::Move {
2041 entity: picked.entity,
2042 point: ChartPoint {
2043 chart: target.chart,
2044 coordinates: [f32::NAN, 0.0, 0.0, 0.0],
2045 },
2046 },
2047 ))
2048 })
2049 .expect_err("a held body accepted a nonfinite target");
2050 assert!(matches!(
2051 refusal,
2052 Rejection::Domain(loam::runtime::DomainError::InvalidCoordinate("x"))
2053 ));
2054 for _ in 0..120 {
2055 booted.session.tick().expect("the tick ran");
2056 }
2057 let r4 = booted
2058 .session
2059 .domains()
2060 .read(booted.domain)
2061 .expect("the r4 domain");
2062 let grip = r4
2063 .physics()
2064 .and_then(|physics| physics.held_anchor(picked.entity))
2065 .expect("the toy is held");
2066 let goal = anchor[1] + target.coordinates[1] - center.y;
2067 assert!(
2068 (grip.y - goal).abs() < 0.1,
2069 "the grab point stopped following at y {} before its goal {goal}",
2070 grip.y
2071 );
2072
2073 let release = booted.session.release_at(1.05).expect("the drag was live");
2074 guides.update(&booted.session, booted.domain, 1.0);
2075 assert!(guides.points.is_empty());
2076 assert_eq!(guides.lines.len(), 4);
2077 send(&mut booted, Action::Throw(release.entity, release.velocity));
2078 booted
2079 .session
2080 .boundary(Input::default())
2081 .expect("the release and throw landed");
2082 for _ in 0..600 {
2083 booted.session.tick().expect("the tick ran");
2084 }
2085 let polytope = booted
2086 .session
2087 .app
2088 .slots
2089 .get(release.entity)
2090 .and_then(|slot| slot.entry.collider_polytope())
2091 .expect("the released toy has a polytope");
2092 let r4 = booted
2093 .session
2094 .domains()
2095 .read(booted.domain)
2096 .expect("the r4 domain");
2097 let physics = r4.physics().expect("the domain has physics");
2098 let body = physics.body(release.entity).expect("the toy has a body");
2099 let body = physics.world().body(body).expect("body row");
2100 let slice_reach = polytope
2101 .topology()
2102 .vertices
2103 .iter()
2104 .map(|vertex| {
2105 (body.orientation.rotation.apply(*vertex * toy::BODY_SIZE))
2106 .w
2107 .abs()
2108 })
2109 .fold(0.0f32, f32::max);
2110 assert!(
2111 body.position.w.abs() <= slice_reach,
2112 "the released toy left its slice reach {slice_reach} at w {} with velocity {}",
2113 body.position.w,
2114 body.velocity.w
2115 );
2116 assert!(body.is_sleeping());
2117 }
2118
2119 #[test]
2120 fn a_shape_card_respawns_the_slot_in_place_with_the_new_polytopes_edges() {
2121 let mut booted = one_slot();
2122 send(
2123 &mut booted,
2124 Action::Display(Display {
2125 wireframe: true,
2126 ..Display::default()
2127 }),
2128 );
2129 send(&mut booted, Action::Slice(consts::W_RANGE));
2130 let rest = booted
2131 .session
2132 .app
2133 .slots
2134 .iter()
2135 .map(|(_, slot)| slot.rest)
2136 .next()
2137 .expect("the row has a slot");
2138
2139 send(&mut booted, Action::Shape(0, 0));
2140 booted
2141 .session
2142 .boundary(Input::default())
2143 .expect("the boundary ran");
2144
2145 let held: Vec<Slot> = booted.session.app.slots.iter().map(|(_, s)| *s).collect();
2146 assert_eq!(held.len(), 1, "the swap left {} slots", held.len());
2147 assert_eq!(held[0].entry.label, "5-cell");
2148 assert_eq!(held[0].index, 0);
2149 assert_eq!(
2150 held[0].rest, rest,
2151 "the replacement moved off the slot's rest"
2152 );
2153
2154 let mut records = Records::default();
2155 let counts = published(&mut booted.session, &mut records, |publication| {
2156 publication
2157 .views
2158 .iter()
2159 .map(|view| view.records.segments().len())
2160 .collect::<Vec<_>>()
2161 });
2162 assert_eq!(
2163 counts,
2164 [0, 10],
2165 "the swapped slot still publishes the old polytope's edges"
2166 );
2167 }
2168
2169 #[test]
2170 fn held_slice_motion_uses_fixed_ticks_while_authored_spin_is_paused() {
2171 const TICKS: usize = 8;
2172
2173 let scrubbed = |boundaries: usize| {
2174 let mut booted = one_slot();
2175 booted.session.app.spin.get_mut().running = false;
2176 for boundary in 0..boundaries {
2177 let held = if boundary == 0 || boundaries > 1 {
2178 vec![SLICE_UP]
2179 } else {
2180 Vec::new()
2181 };
2182 booted
2183 .session
2184 .boundary(Input {
2185 held,
2186 ..Input::default()
2187 })
2188 .expect("the boundary ran");
2189 for _ in 0..TICKS / boundaries {
2190 booted.session.tick().expect("the tick ran");
2191 }
2192 }
2193 *booted.session.app.slice.get()
2194 };
2195
2196 assert!((scrubbed(1) - scrubbed(TICKS)).abs() < 1e-6);
2197 assert!(scrubbed(1) > 0.0);
2198 }
2199
2200 #[test]
2201 fn the_gimbal_rotor_turns_the_row_by_the_angle_its_ring_names() {
2202 use loam::math::{Bivector, Plane4, Rotor};
2203
2204 let mut booted = one_slot();
2205 const ANGLE: f32 = 0.4;
2206 let domain = booted.domain;
2207 booted.session.dispatch(|d| {
2208 mode::turn_row(
2209 d.app,
2210 d.domains,
2211 domain,
2212 (Plane4::Xw.unit_bivector() * ANGLE).exp(),
2213 )
2214 .expect("the turn applies")
2215 });
2216
2217 let entity = slot_entity(&booted);
2218 let r4 = booted
2219 .session
2220 .domains()
2221 .read(booted.domain)
2222 .expect("the r4 domain");
2223 let turned = r4.poses().get(entity).expect("pose").frame.apply(Vec4::X);
2224 let expected = Vec4::new(ANGLE.cos(), 0.0, 0.0, ANGLE.sin());
2225 assert!(
2226 (turned - expected).length() < 1e-5,
2227 "the ring's rotor sent x to {turned} rather than the analytic {expected}"
2228 );
2229 }
2230
2231 #[test]
2232 fn a_press_on_a_ring_turns_the_row_only_while_the_filmstrip_is_off() {
2233 fn press_and_drag(booted: &mut Boot, ndc: [f32; 2]) -> bool {
2234 let mut at = |phase, ndc, time| {
2235 let pointer = Pointer {
2236 id: 0,
2237 button: Some(PointerButton::Primary),
2238 ndc,
2239 delta: [0.0; 2],
2240 phase,
2241 time,
2242 };
2243 booted
2244 .session
2245 .boundary(Input {
2246 pointers: vec![pointer],
2247 ..Input::default()
2248 })
2249 .expect("pointer boundary");
2250 booted.session.app.control.get().gimbal.held()
2251 };
2252 let taken = at(PointerPhase::Began, ndc, 0.0);
2253 at(PointerPhase::Moved, [ndc[0] + 0.2, ndc[1] + 0.2], 1.0);
2254 taken
2255 }
2256
2257 let mut booted = one_slot();
2258 booted
2259 .session
2260 .dispatch(|dispatch| Action::Gimbal(None).apply(dispatch))
2261 .expect("handles");
2262 booted
2263 .session
2264 .boundary(Input::default())
2265 .expect("the boundary ran");
2266 let on_ring = gimbal::widget(glam::Vec3::new(0.0, BODY_Y, 0.0)).rings()[0].point(0.0);
2267 let ndc = booted
2268 .session
2269 .views()
2270 .ndc(on_ring.to_array())
2271 .expect("the ring is in front of the eye");
2272
2273 assert!(
2274 press_and_drag(&mut booted, ndc),
2275 "the press missed the ring, so the strip has nothing to mask"
2276 );
2277 booted
2278 .session
2279 .boundary(Input::default())
2280 .expect("the boundary ran");
2281 let entity = slot_entity(&booted);
2282 let turned = booted
2283 .session
2284 .domains()
2285 .read(booted.domain)
2286 .expect("the r4 domain")
2287 .poses()
2288 .get(entity)
2289 .expect("pose")
2290 .frame;
2291 assert_ne!(
2292 turned,
2293 loam::math::Rotor4::IDENTITY,
2294 "the ring drag never turned the row, so the check below proves nothing"
2295 );
2296
2297 send(
2298 &mut booted,
2299 Action::Strip(Strip {
2300 on: true,
2301 ..Strip::default()
2302 }),
2303 );
2304 booted
2305 .session
2306 .boundary(Input::default())
2307 .expect("the boundary ran");
2308 assert!(
2309 !press_and_drag(&mut booted, ndc),
2310 "the gimbal took a press while the filmstrip covered it"
2311 );
2312 booted
2313 .session
2314 .boundary(Input::default())
2315 .expect("the boundary ran");
2316 let held = booted
2317 .session
2318 .domains()
2319 .read(booted.domain)
2320 .expect("the r4 domain")
2321 .poses()
2322 .get(entity)
2323 .expect("pose")
2324 .frame;
2325 assert_eq!(
2326 held, turned,
2327 "an invisible gimbal turned the row under the filmstrip"
2328 );
2329 }
2330
2331 #[test]
2332 fn a_cancelled_pointer_releases_a_live_grab() {
2333 let mut booted = one_slot();
2334 send(&mut booted, Action::Mode(Mode::Toybox));
2335 booted
2336 .session
2337 .boundary(Input::default())
2338 .expect("the boundary ran");
2339 let entity = booted
2340 .session
2341 .app
2342 .slots
2343 .iter()
2344 .find(|(_, slot)| slot.index == 2)
2345 .map(|(entity, _)| entity)
2346 .expect("middle toy");
2347 let center = booted
2348 .session
2349 .domains()
2350 .read(booted.domain)
2351 .expect("domain")
2352 .poses()
2353 .get(entity)
2354 .expect("pose")
2355 .point
2356 .truncate();
2357 booted.session.views_mut().root_mut().eye = Eye::looking_at(
2358 [center.x, center.y, EYE_BACK],
2359 center.to_array(),
2360 [0.0, 1.0, 0.0],
2361 );
2362 booted
2363 .session
2364 .grab([0.0, 0.0], 0.0)
2365 .expect("the ray through the slot center picks it");
2366 let pointer = Pointer {
2367 id: 0,
2368 button: Some(PointerButton::Primary),
2369 ndc: [0.0, 0.0],
2370 delta: [0.0; 2],
2371 phase: PointerPhase::Cancelled,
2372 time: 1.0,
2373 };
2374 let hover = Pointer {
2375 button: None,
2376 phase: PointerPhase::Moved,
2377 ..pointer
2378 };
2379 let input = Input {
2380 pointers: vec![pointer, hover],
2381 ..Input::default()
2382 };
2383 booted.session.boundary(input).expect("cancel boundary");
2384
2385 assert!(booted.session.dragging().is_none());
2386 assert!(
2387 booted.session.drag([0.5, 0.0], 2.0).is_err(),
2388 "the grab survived the focus change that cancelled the pointer"
2389 );
2390 }
2391
2392 #[test]
2393 fn a_raster_cut_is_duplicated_in_the_projection_layer() {
2394 const TESSERACT: ShapeEntry = ShapeEntry {
2395 shape: RaymarchShape::Polytope(Polytope4::Tesseract),
2396 body_color: [0.30, 0.55, 0.95],
2397 label: "8-cell",
2398 long_name: "tesseract",
2399 category: catalog::Category::RegularPolychoron,
2400 };
2401 let mut booted = boot(&[TESSERACT]).expect("the session boots");
2402 booted.session.boundary(Input::default()).expect("boundary");
2403 let mut records = Records::default();
2404 published(&mut booted.session, &mut records, |publication| {
2405 assert_eq!(publication.views[0].records.triangles().len(), 24);
2406 assert!(publication.views[1].records.triangles().is_empty());
2407 });
2408 }
2409
2410 #[test]
2411 fn filmstrip_recenters_after_single_and_restores_the_single_camera() {
2412 let focus = (true, 0, Mode::Rotate);
2413 let single_target = [-2.4, 0.0, 0.0];
2414 let mut orbit = Orbit::around(single_target, 5.0);
2415 orbit.yaw = 0.3;
2416 orbit.pitch = -0.4;
2417 let mut camera_focus = Some(focus);
2418 let mut filmstrip_return = None;
2419 let strip = Strip {
2420 on: true,
2421 ..Strip::default()
2422 };
2423
2424 focus_camera(
2425 &mut orbit,
2426 &mut camera_focus,
2427 &mut filmstrip_return,
2428 strip,
2429 focus,
2430 glam::Vec3::from(single_target),
2431 );
2432 assert_eq!(orbit.target, [0.0; 3]);
2433 assert_eq!((orbit.yaw, orbit.pitch, orbit.distance), (0.3, -0.4, 5.0));
2434
2435 focus_camera(
2436 &mut orbit,
2437 &mut camera_focus,
2438 &mut filmstrip_return,
2439 Strip { t: true, ..strip },
2440 focus,
2441 glam::Vec3::from(single_target),
2442 );
2443 assert_eq!(orbit.target, [0.0, BODY_Y, 0.0]);
2444
2445 focus_camera(
2446 &mut orbit,
2447 &mut camera_focus,
2448 &mut filmstrip_return,
2449 Strip { on: false, ..strip },
2450 focus,
2451 glam::Vec3::from(single_target),
2452 );
2453 assert_eq!(orbit.target, single_target);
2454 assert_eq!((orbit.yaw, orbit.pitch, orbit.distance), (0.3, -0.4, 5.0));
2455 }
2456
2457 #[test]
2458 fn the_marcher_takes_one_strip_cell_per_grid_rectangle_and_none_once_the_strip_is_off() {
2459 let mut scratch = Scratch::new();
2460 let strip = Strip {
2461 on: true,
2462 w: true,
2463 t: true,
2464 count_w: 5,
2465 count_t: 3,
2466 ..Strip::default()
2467 };
2468 fill_strip(&strip, Bivector4::ZERO, HEADLESS_FRAME, 0.25, &mut scratch);
2469 assert_eq!(scratch.strip.len(), 15, "a 5 by 3 grid is fifteen draws");
2470 let covered: u64 = scratch
2471 .strip
2472 .iter()
2473 .map(|(viewport, _, _)| u64::from(viewport.width) * u64::from(viewport.height))
2474 .sum();
2475 assert_eq!(
2476 covered,
2477 u64::from(HEADLESS_FRAME.0) * u64::from(HEADLESS_FRAME.1),
2478 "the cells the marcher draws leave a gap or overlap"
2479 );
2480 let slices: Vec<f32> = scratch.strip.iter().map(|(_, w, _)| *w).collect();
2481 assert!(
2482 (slices[0] - (0.25 - BODY_SIZE)).abs() < 1e-6
2483 && (slices[14] - (0.25 + BODY_SIZE)).abs() < 1e-6,
2484 "the strip does not span the body around the slider: {slices:?}"
2485 );
2486
2487 fill_strip(
2488 &Strip { on: false, ..strip },
2489 Bivector4::ZERO,
2490 HEADLESS_FRAME,
2491 0.25,
2492 &mut scratch,
2493 );
2494 assert!(
2495 scratch.strip.is_empty(),
2496 "a stale strip kept the filmstrip on screen after it was switched off"
2497 );
2498 }
2499}