1use std::f32::consts::FRAC_PI_2;
2
3use glam::Vec4;
4use loam::app::session::{launch, SessionApp};
5use loam::math::{Bivector, EuclideanR4, Iso4Flat, IsometryGroup, Plane4, Rotor, Rotor4};
6use loam::runtime::host::{HostConfig, HostError};
7use loam::runtime::{
8 ActionId, AppCommand, Bindings, Command, Ctx, Dispatch, DomainBuilder, DomainError,
9 DomainHandle, Eye, Instance, Key, LogCapacity, Material, MaterialId, Outcome, Phase, Pose,
10 PreparedGeometry, Projection4, Rejection, Session, SimConfig, SpawnBundle, TypedDomain, Value,
11 ViewSpec,
12};
13
14const TWIST: ActionId = ActionId(0);
15const SCRAMBLE: ActionId = ActionId(1);
16const UNDO: ActionId = ActionId(2);
17const RESET: ActionId = ActionId(3);
18const TWIST_TICKS: u32 = 20;
19const SCRAMBLE_TWISTS: usize = 12;
20const PIECE_SPACING: f32 = 0.7;
21const STICKER_HALF: f32 = 0.3;
22const FOCAL_DISTANCE: f32 = 4.0;
23const CELL_COLORS: [[f32; 4]; 8] = [
24 [0.9, 0.1, 0.1, 1.0],
25 [1.0, 0.5, 0.1, 1.0],
26 [0.1, 0.7, 0.2, 1.0],
27 [0.2, 0.4, 0.9, 1.0],
28 [0.95, 0.9, 0.2, 1.0],
29 [0.95, 0.95, 0.95, 1.0],
30 [0.6, 0.2, 0.8, 1.0],
31 [0.2, 0.8, 0.8, 1.0],
32];
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35struct Cell {
36 axis: usize,
37 sign: i8,
38}
39
40impl Cell {
41 fn direction(self) -> Vec4 {
42 let mut direction = Vec4::ZERO;
43 direction[self.axis] = f32::from(self.sign);
44 direction
45 }
46
47 fn color(self) -> usize {
48 self.axis * 2 + usize::from(self.sign > 0)
49 }
50
51 fn twist_plane(self) -> Plane4 {
52 Plane4::ALL
53 .into_iter()
54 .find(|plane| !plane_axes(*plane).contains(&self.axis))
55 .unwrap_or(Plane4::Xy)
56 }
57}
58
59fn plane_axes(plane: Plane4) -> [usize; 2] {
60 match plane {
61 Plane4::Xy => [0, 1],
62 Plane4::Xz => [0, 2],
63 Plane4::Xw => [0, 3],
64 Plane4::Yz => [1, 2],
65 Plane4::Yw => [1, 3],
66 Plane4::Zw => [2, 3],
67 }
68}
69
70fn grid_point(grid: [i8; 4]) -> Vec4 {
71 Vec4::new(
72 f32::from(grid[0]),
73 f32::from(grid[1]),
74 f32::from(grid[2]),
75 f32::from(grid[3]),
76 )
77}
78
79#[derive(Clone, Copy)]
80struct Piece {
81 grid: [i8; 4],
82 orientation: Rotor4,
83}
84
85impl Piece {
86 fn iso(&self) -> Iso4Flat {
87 Iso4Flat {
88 rotation: self.orientation,
89 translation: grid_point(self.grid) * PIECE_SPACING,
90 }
91 }
92
93 fn in_cell(&self, cell: Cell) -> bool {
94 self.grid[cell.axis] == cell.sign
95 }
96}
97
98#[derive(Clone, Copy)]
99struct Sticker {
100 cell: Cell,
101 selected: bool,
102}
103
104#[derive(Clone, Copy)]
105struct Slot {
106 local: Cell,
107}
108
109impl Slot {
110 fn iso(self) -> Iso4Flat {
111 let direction = self.local.direction();
112 Iso4Flat {
113 rotation: Rotor4::from_rotation_arc(Vec4::W, direction),
114 translation: direction * STICKER_HALF,
115 }
116 }
117}
118
119#[derive(Clone, Copy)]
120struct Twist {
121 cell: Cell,
122 plane: Plane4,
123 quarter_turns: i8,
124}
125
126impl Twist {
127 fn rotor(&self, fraction: f32) -> Rotor4 {
128 let angle = f32::from(self.quarter_turns) * FRAC_PI_2 * fraction;
129 (self.plane.unit_bivector() * angle).exp().normalize()
130 }
131
132 fn inverse(self) -> Self {
133 Self {
134 quarter_turns: -self.quarter_turns,
135 ..self
136 }
137 }
138}
139
140#[derive(Clone, Copy)]
141struct Turning {
142 twist: Twist,
143 ticks_left: u32,
144}
145
146loam::runtime::stores! {
147 #[derive(Default)]
148 pub struct CubeStores {
149 pieces: Store<Piece>,
150 stickers: Store<Sticker>,
151 slots: Relation<Slot>,
152 history: Value<Vec<Twist>>,
153 turning: Value<Option<Turning>>,
154 rng: Value<u64>,
155 }
156}
157
158#[derive(Clone, Copy)]
159struct Puzzle {
160 domain: DomainHandle<EuclideanR4>,
161 highlight: MaterialId,
162 cell_materials: [MaterialId; 8],
163}
164
165fn place_cell(
166 app: &CubeStores,
167 r4: &mut TypedDomain<EuclideanR4>,
168 rotor: Rotor4,
169 cell: Cell,
170) -> Result<(), DomainError> {
171 for (piece_entity, piece) in app.pieces.iter() {
172 if !piece.in_cell(cell) {
173 continue;
174 }
175 let iso = EuclideanR4.iso_compose(Iso4Flat::from_rotation(rotor), piece.iso());
176 r4.set_pose(piece_entity, Pose::from(iso))?;
177 for id in app.slots.outgoing(piece_entity) {
178 let Some(link) = app.slots.get(id) else {
179 continue;
180 };
181 let pose = Pose::from(EuclideanR4.iso_compose(iso, link.data.iso()));
182 r4.set_pose(link.to(), pose)?;
183 }
184 }
185 Ok(())
186}
187
188fn commit(
189 app: &mut CubeStores,
190 r4: &mut TypedDomain<EuclideanR4>,
191 twist: Twist,
192) -> Result<(), DomainError> {
193 let rotor = twist.rotor(1.0);
194 for (_, piece) in app.pieces.iter_mut() {
195 if !piece.in_cell(twist.cell) {
196 continue;
197 }
198 let turned = rotor.apply(grid_point(piece.grid)).round();
199 piece.grid = [
200 turned.x as i8,
201 turned.y as i8,
202 turned.z as i8,
203 turned.w as i8,
204 ];
205 piece.orientation = (rotor * piece.orientation).normalize();
206 }
207 place_cell(app, r4, Rotor4::IDENTITY, twist.cell)
208}
209
210fn random_twist(rng: &mut u64) -> Twist {
212 *rng = rng
213 .wrapping_mul(6_364_136_223_846_793_005)
214 .wrapping_add(1_442_695_040_888_963_407);
215 let bits = *rng >> 33;
216 let cell = Cell {
217 axis: (bits % 4) as usize,
218 sign: if bits & 4 == 0 { -1 } else { 1 },
219 };
220 Twist {
221 cell,
222 plane: cell.twist_plane(),
223 quarter_turns: if bits & 8 == 0 { 1 } else { -1 },
224 }
225}
226
227struct TwistCommand {
228 twist: Twist,
229}
230
231impl AppCommand<CubeStores> for TwistCommand {
232 fn name(&self) -> &'static str {
233 "twist"
234 }
235
236 fn apply(&mut self, dispatch: &mut Dispatch<'_, CubeStores>) -> Result<Outcome, Rejection> {
237 if dispatch.app.turning.get().is_some() {
238 return Err(Rejection::Unsupported("a twist is in progress"));
239 }
240 dispatch.app.turning.set(Some(Turning {
241 twist: self.twist,
242 ticks_left: TWIST_TICKS,
243 }));
244 dispatch.app.history.get_mut().push(self.twist);
245 Ok(Outcome::Done)
246 }
247}
248
249struct Scramble {
250 puzzle: Puzzle,
251}
252
253impl AppCommand<CubeStores> for Scramble {
254 fn name(&self) -> &'static str {
255 "scramble"
256 }
257
258 fn apply(&mut self, dispatch: &mut Dispatch<'_, CubeStores>) -> Result<Outcome, Rejection> {
259 if dispatch.app.turning.get().is_some() {
260 return Err(Rejection::Unsupported("a twist is in progress"));
261 }
262 let r4 = dispatch.domains.typed(self.puzzle.domain)?;
263 for _ in 0..SCRAMBLE_TWISTS {
264 let twist = random_twist(dispatch.app.rng.get_mut());
265 commit(dispatch.app, r4, twist)?;
266 dispatch.app.history.get_mut().push(twist);
267 }
268 Ok(Outcome::Done)
269 }
270}
271
272struct Undo {
273 puzzle: Puzzle,
274}
275
276impl AppCommand<CubeStores> for Undo {
277 fn name(&self) -> &'static str {
278 "undo"
279 }
280
281 fn apply(&mut self, dispatch: &mut Dispatch<'_, CubeStores>) -> Result<Outcome, Rejection> {
282 if dispatch.app.turning.get().is_some() {
283 return Err(Rejection::Unsupported("a twist is in progress"));
284 }
285 let Some(twist) = dispatch.app.history.get_mut().pop() else {
286 return Ok(Outcome::Done);
287 };
288 let r4 = dispatch.domains.typed(self.puzzle.domain)?;
289 commit(dispatch.app, r4, twist.inverse())?;
290 Ok(Outcome::Done)
291 }
292}
293
294fn sticker_cube() -> PreparedGeometry {
295 let corner = |bits: u32| {
296 let sign = |bit: u32| {
297 if bits & bit == 0 {
298 -STICKER_HALF
299 } else {
300 STICKER_HALF
301 }
302 };
303 [sign(1), sign(2), sign(4), 0.0]
304 };
305 let mut segments = Vec::new();
306 for a in 0..8_u32 {
307 for b in (a + 1)..8 {
308 if (a ^ b).count_ones() == 1 {
309 segments.push([corner(a), corner(b)]);
310 }
311 }
312 }
313 PreparedGeometry::Lines4 { segments }
314}
315
316fn grid_points() -> impl Iterator<Item = [i8; 4]> {
317 (0..81_i32)
318 .map(|index| {
319 [
320 (index % 3 - 1) as i8,
321 (index / 3 % 3 - 1) as i8,
322 (index / 9 % 3 - 1) as i8,
323 (index / 27 - 1) as i8,
324 ]
325 })
326 .filter(|grid| *grid != [0; 4])
327}
328
329fn selected_twist(app: &CubeStores) -> Option<Twist> {
330 let (_, sticker) = app.stickers.iter().find(|(_, sticker)| sticker.selected)?;
331 Some(Twist {
332 cell: sticker.cell,
333 plane: sticker.cell.twist_plane(),
334 quarter_turns: 1,
335 })
336}
337
338fn build(
339 args: loam::app::args::Args,
340) -> Result<(Session<CubeStores>, SessionApp<CubeStores>), HostError> {
341 let stores = CubeStores {
342 rng: Value::new(0),
343 ..CubeStores::default()
344 };
345 let mut session = Session::new(stores, SimConfig::default());
346 let r4 = session
347 .register_domain(DomainBuilder::new("r4", EuclideanR4).tracked(LogCapacity::default()));
348 let sticker_geometry = session.prepare(sticker_cube());
349 let cell_materials = CELL_COLORS.map(|color| session.add_material(Material::flat(color)));
350 let highlight = session.add_material(Material::flat([1.0; 4]));
351 let root = session.views().root();
352 let puzzle = Puzzle {
353 domain: r4,
354 highlight,
355 cell_materials,
356 };
357
358 session.dispatch(|d| -> Result<(), Rejection> {
359 for grid in grid_points() {
360 let piece = Piece {
361 grid,
362 orientation: Rotor4::IDENTITY,
363 };
364 let piece_entity = d.spawn(
365 SpawnBundle::new()
366 .at(r4, Pose::from(piece.iso()))
367 .row(piece),
368 )?;
369 for (axis, &sign) in grid.iter().enumerate() {
370 if sign == 0 {
371 continue;
372 }
373 let cell = Cell { axis, sign };
374 let slot = Slot { local: cell };
375 let sticker = d.spawn(
376 SpawnBundle::new()
377 .at(
378 r4,
379 Pose::from(EuclideanR4.iso_compose(piece.iso(), slot.iso())),
380 )
381 .instance(Instance::new(
382 sticker_geometry,
383 cell_materials[cell.color()],
384 ))
385 .row(Sticker {
386 cell,
387 selected: false,
388 }),
389 )?;
390 d.link(piece_entity, sticker, slot)?;
391 }
392 }
393 let eye = Pose::at(Vec4::W * FOCAL_DISTANCE);
394 let eye = d.spawn(SpawnBundle::new().at(r4, eye))?;
395 let projection = Projection4 {
396 focal: FOCAL_DISTANCE,
397 };
398 d.domains
399 .typed(r4)?
400 .add_view(ViewSpec::new(root, eye, projection))?;
401 Ok(())
402 })?;
403 session.views_mut().root_mut().eye =
404 Eye::looking_at([0.0, 2.0, 6.0], [0.0; 3], [0.0, 1.0, 0.0]);
405
406 session.system(
407 Phase::Dispatch,
408 "select",
409 move |ctx: Ctx<'_, CubeStores>| -> Result<(), DomainError> {
410 let Some(pointer) = ctx.input.began() else {
411 return Ok(());
412 };
413 let Some(pick) = ctx.pick(pointer.ndc) else {
414 return Ok(());
415 };
416 if !ctx.app.stickers.contains(pick.entity) {
417 return Ok(());
418 }
419 let previous = ctx
420 .app
421 .stickers
422 .iter()
423 .find_map(|(entity, sticker)| sticker.selected.then_some(entity));
424 if previous == Some(pick.entity) {
425 return Ok(());
426 };
427 let r4 = ctx.domains.typed(puzzle.domain)?;
428 if let Some(previous) = previous {
429 let sticker = ctx
430 .app
431 .stickers
432 .get_mut(previous)
433 .ok_or(DomainError::Stale(previous))?;
434 let material = puzzle.cell_materials[sticker.cell.color()];
435 sticker.selected = false;
436 let instance = r4
437 .instance_mut(previous)
438 .ok_or(DomainError::Stale(previous))?;
439 instance.material = material;
440 }
441 let sticker = ctx
442 .app
443 .stickers
444 .get_mut(pick.entity)
445 .ok_or(DomainError::Stale(pick.entity))?;
446 sticker.selected = true;
447 let instance = r4
448 .instance_mut(pick.entity)
449 .ok_or(DomainError::Stale(pick.entity))?;
450 instance.material = puzzle.highlight;
451 Ok(())
452 },
453 );
454
455 session.system(
456 Phase::Dispatch,
457 "actions",
458 move |ctx: Ctx<'_, CubeStores>| {
459 if ctx.input.pressed(TWIST) {
460 if let Some(twist) = selected_twist(ctx.app) {
461 ctx.commands.app(TwistCommand { twist });
462 }
463 }
464 if ctx.input.pressed(SCRAMBLE) {
465 ctx.commands.app(Scramble { puzzle });
466 }
467 if ctx.input.pressed(UNDO) {
468 ctx.commands.app(Undo { puzzle });
469 }
470 if ctx.input.pressed(RESET) {
471 ctx.commands.submit(Command::Reset);
472 }
473 Ok(())
474 },
475 );
476
477 session.system(
478 Phase::Simulation,
479 "turn",
480 move |ctx: Ctx<'_, CubeStores>| -> Result<(), DomainError> {
481 let Some(mut turning) = *ctx.app.turning.get() else {
482 return Ok(());
483 };
484 let r4 = ctx.domains.typed(puzzle.domain)?;
485 turning.ticks_left = turning.ticks_left.saturating_sub(1);
486 let fraction = 1.0 - turning.ticks_left as f32 / TWIST_TICKS as f32;
487 place_cell(
488 ctx.app,
489 r4,
490 turning.twist.rotor(fraction),
491 turning.twist.cell,
492 )?;
493 if turning.ticks_left == 0 {
494 commit(ctx.app, r4, turning.twist)?;
495 ctx.app.turning.set(None);
496 } else {
497 ctx.app.turning.set(Some(turning));
498 }
499 Ok(())
500 },
501 );
502
503 session.set_initial()?;
504 let bindings = Bindings::new()
505 .key(Key::Space, TWIST)
506 .key(Key::Letter('s'), SCRAMBLE)
507 .key(Key::Letter('u'), UNDO)
508 .key(Key::Letter('r'), RESET);
509 let app =
510 SessionApp::with_args(HostConfig::new("rubiks4d", bindings), args).recover_on_fault(RESET);
511 Ok((session, app))
512}
513
514fn main() -> Result<(), HostError> {
515 launch(build)
516}
517
518#[cfg(test)]
519mod tests {
520 use loam::app::args::Args;
521 use loam::runtime::{ActionEvent, Input, Pointer, PointerButton, PointerPhase, Publication};
522
523 use super::*;
524
525 #[test]
526 fn restored_selection_keeps_its_highlight_and_drives_a_twist() {
527 let (mut session, _) = build(Args::default()).unwrap();
528 let mut publication = Publication::default();
529 session.publish(&mut publication).unwrap();
530
531 let (ndc, picked) = publication
532 .views
533 .iter()
534 .flat_map(|view| {
535 view.records
536 .instances
537 .rows()
538 .iter()
539 .map(|record| (view.placement.apply(record.image_point), record.entity))
540 })
541 .filter_map(|(point, entity)| {
542 session
543 .views()
544 .ndc(point)
545 .and_then(|ndc| session.pick(ndc).map(|pick| (ndc, entity, pick)))
546 })
547 .find_map(|(ndc, entity, pick)| (entity == pick.entity).then_some((ndc, pick)))
548 .expect("a published sticker was visible to the root view");
549 let selected_cell = session
550 .app
551 .stickers
552 .get(picked.entity)
553 .expect("the pick was a sticker")
554 .cell;
555 session
556 .boundary(Input {
557 pointers: vec![Pointer {
558 id: 0,
559 button: Some(PointerButton::Primary),
560 ndc,
561 delta: [0.0; 2],
562 phase: PointerPhase::Began,
563 time: 0.0,
564 }],
565 ..Input::default()
566 })
567 .unwrap();
568 assert_eq!(
569 session
570 .app
571 .stickers
572 .get(picked.entity)
573 .map(|sticker| (sticker.cell, sticker.selected)),
574 Some((selected_cell, true))
575 );
576 session.publish(&mut publication).unwrap();
577 let highlight = publication
578 .views
579 .iter()
580 .flat_map(|view| view.records.instances.rows())
581 .find(|record| record.entity == picked.entity)
582 .map(|record| record.material)
583 .expect("the selected sticker was published");
584
585 let snapshot = session.snapshot().unwrap();
586 session.restore(&snapshot).unwrap();
587 let (restored, sticker) = session
588 .app
589 .stickers
590 .iter()
591 .find(|(_, sticker)| sticker.selected)
592 .expect("restore kept the selected sticker");
593 assert_ne!(restored, picked.entity);
594 assert_eq!(sticker.cell, selected_cell);
595 session.publish(&mut publication).unwrap();
596 let restored_highlight = publication
597 .views
598 .iter()
599 .flat_map(|view| view.records.instances.rows())
600 .find(|record| record.entity == restored)
601 .map(|record| record.material)
602 .expect("the restored selected sticker was published");
603 assert_eq!(restored_highlight, highlight);
604 session
605 .boundary(Input {
606 actions: vec![ActionEvent {
607 action: TWIST,
608 pressed: true,
609 }],
610 ..Input::default()
611 })
612 .unwrap();
613 assert_eq!(
614 (*session.app.turning.get()).map(|turning| turning.twist.cell),
615 Some(selected_cell)
616 );
617 }
618
619 #[test]
620 fn busy_history_commands_leave_state_unchanged_until_twist_finishes() {
621 let (mut session, _) = build(Args::default()).unwrap();
622 let grids = |session: &Session<CubeStores>| {
623 let mut grids: Vec<_> = session
624 .app
625 .pieces
626 .iter()
627 .map(|(entity, piece)| (entity.key(), piece.grid))
628 .collect();
629 grids.sort_unstable_by_key(|(key, _)| *key);
630 grids
631 };
632 let history = |session: &Session<CubeStores>| {
633 session
634 .app
635 .history
636 .get()
637 .iter()
638 .map(|twist| (twist.cell, twist.plane, twist.quarter_turns))
639 .collect::<Vec<_>>()
640 };
641 let (cubie, initial_grid) = session
642 .app
643 .pieces
644 .iter()
645 .find(|(_, piece)| piece.grid == [1, 1, 0, 1])
646 .map(|(entity, piece)| (entity, piece.grid))
647 .expect("the known cubie exists");
648 let initial_grids = grids(&session);
649 let initial_history = history(&session);
650 let twist = Twist {
651 cell: Cell { axis: 3, sign: 1 },
652 plane: Plane4::Xy,
653 quarter_turns: 1,
654 };
655
656 session
657 .dispatch(|dispatch| TwistCommand { twist }.apply(dispatch))
658 .unwrap();
659 let busy_grids = grids(&session);
660 let busy_history = history(&session);
661 let busy_rng = *session.app.rng.get();
662 session
663 .boundary(Input {
664 actions: vec![
665 ActionEvent {
666 action: SCRAMBLE,
667 pressed: true,
668 },
669 ActionEvent {
670 action: UNDO,
671 pressed: true,
672 },
673 ],
674 ..Input::default()
675 })
676 .unwrap();
677 assert_eq!(session.results().len(), 2);
678 assert!(session.results().iter().all(|result| {
679 result.outcome == Err(Rejection::Unsupported("a twist is in progress"))
680 }));
681 assert_eq!(grids(&session), busy_grids);
682 assert_eq!(history(&session), busy_history);
683 assert_eq!(*session.app.rng.get(), busy_rng);
684
685 for _ in 0..TWIST_TICKS {
686 session.tick().unwrap();
687 }
688 assert_eq!(
689 session
690 .app
691 .pieces
692 .get(cubie)
693 .expect("the cubie exists")
694 .grid,
695 [-1, 1, 0, 1]
696 );
697
698 session
699 .boundary(Input {
700 actions: vec![ActionEvent {
701 action: UNDO,
702 pressed: true,
703 }],
704 ..Input::default()
705 })
706 .unwrap();
707 assert_eq!(grids(&session), initial_grids);
708 assert_eq!(history(&session), initial_history);
709 assert_eq!(
710 session
711 .app
712 .pieces
713 .get(cubie)
714 .expect("the cubie exists")
715 .grid,
716 initial_grid
717 );
718 }
719}