Skip to main content

blockworx/tutorial/
lowering.rs

1//! Lower a [`Script`] into the per-frame synthetic input a human performing
2//! it would have produced: hover glides, click/drag events, tool switches,
3//! and editor typing, at a fixed timestep so replays are deterministic.
4//!
5//! Lowering is *streaming*: each step's endpoints resolve against the
6//! document as the step begins, because earlier frames may have created or
7//! moved the very objects a later step targets (a drag that moves a block
8//! must not chase the block it is moving).
9
10use egui::Pos2;
11
12use crate::canvas::Event;
13use crate::schema::model as schema;
14use crate::tools::names::ToolName;
15
16use super::script::{ClickCount, CueTarget, Script, Step, typed_prefix};
17
18/// The fixed simulation timestep (seconds per frame).
19pub const SIM_DT: f32 = 1.0 / 60.0;
20
21/// One frame of synthetic input.
22#[derive(Clone, Copy, Debug, Default)]
23pub struct SimFrame {
24    /// Canvas event, world space — what `compute_interaction` would have
25    /// produced from the real pointer.
26    pub event: Option<Event>,
27    /// A toolbar press this frame: arm this tool.
28    pub switch_tool: Option<ToolName>,
29    /// Text to force into the open in-place editor, plus whether this frame
30    /// finishes the entry (Enter on the next frame).
31    pub typing: Option<Typing>,
32}
33
34#[derive(Clone, Copy, Debug)]
35pub struct Typing {
36    /// The prefix typed so far (grows frame over frame).
37    pub text: &'static str,
38    /// This frame completes the entry; the runner presses Enter next frame.
39    pub commit: bool,
40}
41
42/// A doc-anchored target named an object the document doesn't have — the
43/// stage's demo references something its prerequisites didn't create.
44// The fields reach error reports through the derived Debug, which dead-code
45// analysis deliberately ignores.
46#[derive(Debug)]
47#[allow(dead_code)]
48pub struct UnresolvedTarget {
49    pub step: usize,
50    pub target: CueTarget,
51}
52
53/// Streaming lowerer over one script. Call [`Lowering::next`] with the
54/// *current* document each frame until it returns `Ok(None)`.
55pub struct Lowering {
56    steps: Vec<Step>,
57    step: usize,
58    /// Frames already emitted for the current step.
59    frame: u32,
60    /// World endpoints of the current step, resolved when it began.
61    span: Option<(Option<Pos2>, Option<Pos2>)>,
62    /// Where the cursor rests, in world space (`None` after toolbar targets).
63    cursor: Option<Pos2>,
64}
65
66fn ease(t: f32) -> f32 {
67    t * t * (3.0 - 2.0 * t)
68}
69
70impl Lowering {
71    pub fn new(script: &Script) -> Self {
72        Self {
73            steps: script.steps().to_vec(),
74            step: 0,
75            frame: 0,
76            span: None,
77            cursor: None,
78        }
79    }
80
81    /// The next frame of input, or `Ok(None)` when the script has finished.
82    pub fn next(&mut self, doc: &schema::Document) -> Result<Option<SimFrame>, UnresolvedTarget> {
83        loop {
84            let Some(step) = self.steps.get(self.step) else {
85                return Ok(None);
86            };
87            let frames = step_frames(step);
88            if self.frame >= frames {
89                self.finish_step();
90                continue;
91            }
92            let (from, to) = if let Some(span) = self.span {
93                span
94            } else {
95                let span = self.resolve_span(step, doc)?;
96                self.span = Some(span);
97                span
98            };
99            // Progress through the step at this frame's *end*, so the final
100            // frame lands exactly on the target.
101            let p = (self.frame + 1) as f32 / frames as f32;
102            let frame = self.frame;
103            self.frame += 1;
104            let lerp = |p: f32| match (from, to) {
105                (Some(a), Some(b)) => Some(a.lerp(b, ease(p))),
106                (_, b) => b,
107            };
108            let sim = match step {
109                Step::Highlight { .. } | Step::Pause { .. } => SimFrame::default(),
110                Step::MoveTo { .. } | Step::Hover { .. } => SimFrame {
111                    event: lerp(p).map(Event::HoverAt),
112                    ..SimFrame::default()
113                },
114                Step::Click { target, count } => lower_click(*target, *count, p, to),
115                Step::Drag { .. } => {
116                    let (Some(a), Some(b)) = (from, to) else {
117                        return Err(self.unresolved(step));
118                    };
119                    // The glide occupies frames 0..frames-1 and *completes* on
120                    // the last Dragging frame, so per-frame deltas sum exactly
121                    // to the displacement (movers accumulate deltas and ignore
122                    // the stop position). DragStopped is its own final frame.
123                    let motion = |f: u32| {
124                        let q = f as f32 / (frames - 2) as f32;
125                        a.lerp(b, ease(q))
126                    };
127                    let event = if frame == 0 {
128                        Event::DragStarted { pos: a }
129                    } else if self.frame == frames {
130                        Event::DragStopped { pos: b }
131                    } else {
132                        Event::Dragging {
133                            pos: motion(frame),
134                            delta: motion(frame) - motion(frame - 1),
135                        }
136                    };
137                    SimFrame {
138                        event: Some(event),
139                        ..SimFrame::default()
140                    }
141                }
142                Step::Type { text, .. } => SimFrame {
143                    typing: Some(Typing {
144                        text: typed_prefix(text, p),
145                        commit: self.frame == frames,
146                    }),
147                    ..SimFrame::default()
148                },
149            };
150            return Ok(Some(sim));
151        }
152    }
153
154    /// Resolve the step's world endpoints as it begins: `from` is where the
155    /// cursor rests, `to` the step's target.
156    fn resolve_span(
157        &self,
158        step: &Step,
159        doc: &schema::Document,
160    ) -> Result<(Option<Pos2>, Option<Pos2>), UnresolvedTarget> {
161        let resolve = |target: &CueTarget| -> Result<Option<Pos2>, UnresolvedTarget> {
162            match target {
163                // Toolbar targets have no world position by design.
164                CueTarget::ToolButton(_) => Ok(None),
165                _ => target
166                    .world(doc)
167                    .map(Some)
168                    .ok_or_else(|| self.unresolved(step)),
169            }
170        };
171        Ok(match step {
172            Step::Highlight { .. } | Step::Pause { .. } | Step::Type { .. } => {
173                (self.cursor, self.cursor)
174            }
175            Step::MoveTo { target, .. }
176            | Step::Hover { target, .. }
177            | Step::Click { target, .. } => (self.cursor, resolve(target)?),
178            Step::Drag { from, to, .. } => (resolve(from)?, resolve(to)?),
179        })
180    }
181
182    fn finish_step(&mut self) {
183        if let Some((_, to)) = self.span.take()
184            && !matches!(
185                self.steps[self.step],
186                Step::Highlight { .. } | Step::Pause { .. } | Step::Type { .. }
187            )
188        {
189            self.cursor = to.or(self.cursor);
190        }
191        self.step += 1;
192        self.frame = 0;
193    }
194
195    fn unresolved(&self, step: &Step) -> UnresolvedTarget {
196        let target = match step {
197            Step::Highlight { target, .. }
198            | Step::MoveTo { target, .. }
199            | Step::Hover { target, .. }
200            | Step::Click { target, .. }
201            | Step::Type { target, .. } => *target,
202            Step::Drag { from, to, .. } => {
203                // Whichever endpoint failed; prefer naming the one that can't
204                // be a fixed position.
205                if matches!(from, CueTarget::World(_)) {
206                    *to
207                } else {
208                    *from
209                }
210            }
211            // Pause has no target and resolves unconditionally.
212            Step::Pause { .. } => CueTarget::World(Pos2::ZERO),
213        };
214        UnresolvedTarget {
215            step: self.step,
216            target,
217        }
218    }
219}
220
221/// A click flashes for its press duration but acts once, on its final
222/// frame: the canvas event or the toolbar switch.
223fn lower_click(target: CueTarget, count: ClickCount, p: f32, world: Option<Pos2>) -> SimFrame {
224    if p < 1.0 {
225        return SimFrame::default();
226    }
227    match (target, world) {
228        (CueTarget::ToolButton(name), _) => SimFrame {
229            switch_tool: Some(name),
230            ..SimFrame::default()
231        },
232        (_, Some(pos)) => SimFrame {
233            event: Some(match count {
234                ClickCount::Single => Event::Clicked { pos },
235                ClickCount::Double => Event::DoubleClicked { pos },
236            }),
237            ..SimFrame::default()
238        },
239        (_, None) => SimFrame::default(),
240    }
241}
242
243/// Frames a step occupies at [`SIM_DT`] (at least one; a drag needs start,
244/// one full-displacement `Dragging`, and stop — at least three).
245fn step_frames(step: &Step) -> u32 {
246    let secs = match step {
247        Step::Highlight { secs, .. }
248        | Step::MoveTo { secs, .. }
249        | Step::Hover { secs, .. }
250        | Step::Drag { secs, .. }
251        | Step::Type { secs, .. }
252        | Step::Pause { secs } => *secs,
253        Step::Click { count, .. } => match count {
254            ClickCount::Single => super::script::CLICK_PRESS_SECS,
255            ClickCount::Double => 2.0 * super::script::CLICK_PRESS_SECS,
256        },
257    };
258    let floor = if matches!(step, Step::Drag { .. }) {
259        3
260    } else {
261        1
262    };
263    ((secs / SIM_DT).ceil() as u32).max(floor)
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::tools::names::ToolName;
270    use crate::tutorial::script::{Script, at, grid_pos, tool};
271
272    fn drain(script: &Script) -> Vec<SimFrame> {
273        let doc = schema::Document {
274            top: String::new(),
275            blocks: Vec::new(),
276        };
277        let mut lowering = Lowering::new(script);
278        let mut frames = Vec::new();
279        while let Some(f) = lowering.next(&doc).unwrap() {
280            frames.push(f);
281        }
282        frames
283    }
284
285    #[test]
286    fn drag_lowers_to_start_move_stop_with_exact_deltas() {
287        let script = Script::builder().drag(at(0, 0), at(8, 0), 0.5).build();
288        let frames = drain(&script);
289        assert_eq!(frames.len(), 30);
290        assert_eq!(
291            frames[0].event,
292            Some(Event::DragStarted {
293                pos: grid_pos(0, 0)
294            })
295        );
296        assert_eq!(
297            frames.last().unwrap().event,
298            Some(Event::DragStopped {
299                pos: grid_pos(8, 0)
300            })
301        );
302        // Movers accumulate Dragging deltas and ignore the stop position, so
303        // the deltas must sum to exactly the displacement.
304        let sum = frames
305            .iter()
306            .filter_map(|f| match f.event {
307                Some(Event::Dragging { delta, .. }) => Some(delta),
308                _ => None,
309            })
310            .fold(egui::Vec2::ZERO, |acc, d| acc + d);
311        let expected = grid_pos(8, 0) - grid_pos(0, 0);
312        assert!((sum.x - expected.x).abs() < 1e-3, "{sum:?}");
313        assert!(sum.y.abs() < 1e-6);
314    }
315
316    #[test]
317    fn toolbar_click_lowers_to_a_tool_switch() {
318        let script = Script::builder()
319            .highlight(tool(ToolName::Route), 0.1)
320            .click(tool(ToolName::Route))
321            .build();
322        let frames = drain(&script);
323        let switches: Vec<_> = frames.iter().filter_map(|f| f.switch_tool).collect();
324        assert_eq!(switches, vec![ToolName::Route]);
325        assert!(frames.iter().all(|f| f.event.is_none()));
326    }
327
328    #[test]
329    fn click_after_move_lands_on_the_target() {
330        let script = Script::builder()
331            .move_to(at(4, 4), 0.2)
332            .click(at(4, 4))
333            .build();
334        let frames = drain(&script);
335        let clicks: Vec<_> = frames
336            .iter()
337            .filter_map(|f| match f.event {
338                Some(Event::Clicked { pos }) => Some(pos),
339                _ => None,
340            })
341            .collect();
342        assert_eq!(clicks, vec![grid_pos(4, 4)]);
343        // Hover frames precede the click and end on the target.
344        assert!(matches!(frames[0].event, Some(Event::HoverAt(_))));
345    }
346
347    #[test]
348    fn typing_grows_and_commits_on_the_final_frame() {
349        let script = Script::builder().type_text(at(0, 0), "CPU", 0.2).build();
350        let frames = drain(&script);
351        let last = frames.last().unwrap().typing.unwrap();
352        assert_eq!(last.text, "CPU");
353        assert!(last.commit);
354        assert!(
355            frames
356                .iter()
357                .rev()
358                .skip(1)
359                .all(|f| !f.typing.unwrap().commit)
360        );
361    }
362
363    #[test]
364    fn unresolved_block_target_reports_the_step() {
365        let script = Script::builder()
366            .move_to(crate::tutorial::script::block("ghost"), 0.2)
367            .build();
368        let doc = schema::Document {
369            top: String::new(),
370            blocks: Vec::new(),
371        };
372        let err = Lowering::new(&script).next(&doc).unwrap_err();
373        assert_eq!(err.step, 0);
374    }
375}