Skip to main content

rubiks4d/
rubiks4d.rs

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
210// Knuth, TAOCP vol. 2, 3.3.4, the MMIX linear congruential constants.
211fn 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}