Skip to main content

motion_core/authoring/
context.rs

1//! Stateless authoring time, design coordinates and optional shared computation.
2
3use std::hash::Hash;
4use std::sync::Arc;
5
6use crate::{Animation, Duration, FrameRate, Node, Point, Random, Rect, Size, Time};
7
8mod cache;
9mod partition;
10#[cfg(test)]
11mod tests;
12
13pub use cache::SceneCache;
14pub use partition::{CacheLimits, CachePartition, RenderCache};
15
16/// Immutable scene evaluation inputs in signed ticks and logical coordinates.
17///
18/// The caller supplies time explicitly and can reuse one [`SceneCache`] across
19/// arbitrary frame orders and threads, without initializing rendering resources
20/// or interpreting design dimensions as output pixels.
21///
22/// ```
23/// use motion_core::prelude::*;
24/// use motion_core::{
25///     DisplayList, RasterLimits, ResourcePool, ValidatedDisplayList, MAX_PATH_VERBS,
26///     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
27/// };
28/// use std::sync::Arc;
29///
30/// let cache = SceneCache::new(32);
31/// let tween = Tween::new(0.0, 100.0, 1.seconds());
32/// let evaluate = |time| {
33///     let context = SceneContext::new(time, FrameRate::from(30), Size::new(640.0, 360.0), &cache)
34///         .with_clip_duration(2.seconds());
35///     let positions = context.cache(("dots", 8_u32), || {
36///         (0..8_u32)
37///             .map(|index| Point::new(context.random(("x", index)) * 640.0, 180.0))
38///             .collect::<Vec<_>>()
39///     });
40///     let local = context.shifted(500.milliseconds());
41///     assert_eq!(local.global_time(), time);
42///     (local.sample(&tween), positions)
43/// };
44/// let (late, first) = evaluate(1500.milliseconds());
45/// let (early, second) = evaluate(500.milliseconds());
46/// let (again, third) = evaluate(1500.milliseconds());
47/// assert_eq!((late, early, again), (100.0, 0.0, 100.0));
48/// assert!(Arc::ptr_eq(&first, &second) && Arc::ptr_eq(&first, &third));
49/// ```
50#[derive(Clone, Copy)]
51pub struct SceneContext<'a> {
52    quality: crate::RenderQuality,
53    time: Time,
54    global_time: Time,
55    clip_duration: Option<Duration>,
56    frame_rate: FrameRate,
57    size: Size,
58    nested_scene: bool,
59
60    assets: Option<&'a crate::Assets>,
61
62    cache: &'a SceneCache,
63
64    diagnostics: Option<&'a crate::RequestDiagnostics>,
65    sounds: Option<&'a std::sync::Mutex<Vec<crate::AudioClip>>>,
66}
67
68impl<'a> SceneContext<'a> {
69    pub(crate) fn collecting_sounds(
70        mut self,
71        sounds: &'a std::sync::Mutex<Vec<crate::AudioClip>>,
72    ) -> Self {
73        self.sounds = Some(sounds);
74        self
75    }
76
77    /// Declares local audio in a `scene_with_sound` metadata pass; visual snapshots never play sound.
78    /// Declare outside time-dependent branches so arbitrary frame order cannot change the track.
79    pub fn sound(&self, clip: crate::AudioClip) -> crate::Result {
80        if let Some(sounds) = self.sounds {
81            let mut sounds = sounds.lock().map_err(|_| {
82                crate::ValidationError::invalid(
83                    "scene_audio",
84                    "lock",
85                    "Sound declaration lock is poisoned",
86                )
87            })?;
88            if sounds.len() >= 256 {
89                return Err(crate::ValidationError::invalid(
90                    "scene_audio",
91                    "count",
92                    "At most 256 local sound declarations",
93                )
94                .into());
95            }
96            if let Some(clip) = clip.relocated(
97                self.global_time - self.time,
98                self.clip_duration.unwrap_or(Time::from_seconds(86400.0)),
99            )? {
100                sounds.push(clip);
101            }
102        }
103        Ok(())
104    }
105
106    /// Creates root evaluation inputs with matching local/global time.
107    ///
108    /// Panics unless both design dimensions are finite and strictly positive.
109    pub fn new(time: Time, frame_rate: FrameRate, size: Size, cache: &'a SceneCache) -> Self {
110        Self::try_new(time, frame_rate, size, cache)
111            .unwrap_or_else(|error| panic!("{}", error.message))
112    }
113
114    /// Creates evaluation inputs with recoverable design-size validation.
115    pub fn try_new(
116        time: Time,
117        frame_rate: FrameRate,
118        size: Size,
119        cache: &'a SceneCache,
120    ) -> Result<Self, crate::ValidationError> {
121        if !size.width.is_finite()
122            || size.width <= 0.0
123            || !size.height.is_finite()
124            || size.height <= 0.0
125        {
126            return Err(crate::ValidationError::invalid(
127                "scene_context",
128                "size",
129                "scene design dimensions must be finite and positive",
130            ));
131        }
132        Ok(Self {
133            quality: crate::RenderQuality::Final,
134            time,
135            global_time: time,
136            clip_duration: None,
137            frame_rate,
138            size,
139            nested_scene: false,
140            assets: None,
141            cache,
142            diagnostics: None,
143            sounds: None,
144        })
145    }
146
147    /// Selects preview approximations, preserved by nested and sampled contexts.
148    pub fn quality(mut self, quality: crate::RenderQuality) -> Self {
149        self.quality = quality;
150        self
151    }
152
153    /// Explicit rendering quality; manually constructed contexts default to Final.
154    pub fn render_quality(&self) -> crate::RenderQuality {
155        self.quality
156    }
157
158    /// Always available resources; an unconfigured context uses an empty owner.
159    pub fn assets(&self) -> &'a crate::Assets {
160        self.assets.unwrap_or(&self.cache.empty_assets)
161    }
162
163    /// Attaches the common resource owner without reading or decoding.
164    pub fn with_assets(mut self, assets: &'a crate::Assets) -> Self {
165        self.assets = Some(assets);
166        self
167    }
168
169    /// Attaches a request-owned sink suitable for explicit background resource reads.
170    pub fn with_diagnostics(mut self, diagnostics: &'a crate::RequestDiagnostics) -> Self {
171        self.diagnostics = Some(diagnostics);
172        self
173    }
174
175    /// Diagnostic scope retained by shifted, sampled and nested contexts.
176    pub fn diagnostics(&self) -> Option<&'a crate::RequestDiagnostics> {
177        self.diagnostics
178    }
179
180    /// Sets finite clip metadata; zero denotes an instantaneous clip.
181    ///
182    /// Panics for negative duration. Sampling time is not clamped to the clip.
183    pub fn with_clip_duration(mut self, duration: Duration) -> Self {
184        assert!(duration >= Time::ZERO, "clip duration must be nonnegative");
185        self.clip_duration = Some(duration);
186        self
187    }
188
189    /// Sets clip metadata with a recoverable negative-duration diagnostic.
190    pub fn try_with_clip_duration(
191        mut self,
192        duration: Duration,
193    ) -> Result<Self, crate::ValidationError> {
194        if duration < Time::ZERO {
195            return Err(crate::ValidationError::invalid(
196                "scene_context",
197                "clip_duration",
198                "clip duration must be nonnegative",
199            ));
200        }
201        self.clip_duration = Some(duration);
202        Ok(self)
203    }
204
205    /// Signed clip-local time.
206    pub fn time(&self) -> Time {
207        self.time
208    }
209
210    /// Clip-local time in floating-point seconds, for animation mathematics.
211    pub fn seconds(&self) -> f64 {
212        self.time.as_seconds()
213    }
214
215    /// Root evaluation time, unaffected by local shifts.
216    pub fn global_time(&self) -> Time {
217        self.global_time
218    }
219
220    /// Explicit clip duration, absent for an unconstrained scene.
221    pub fn clip_duration(&self) -> Option<Duration> {
222        self.clip_duration
223    }
224
225    /// Clamped local progress; unconstrained scenes return zero.
226    ///
227    /// A zero-duration clip jumps from zero to one at local time zero.
228    pub fn progress(&self) -> f64 {
229        self.clip_duration
230            .map_or(0.0, |duration| self.progress_in(Time::ZERO, duration))
231    }
232
233    /// Clamped progress in a local interval, using integer boundary comparisons.
234    ///
235    /// Zero duration jumps at start. Panics for negative duration. Wide integer
236    /// differences allow queries across opposite signed tick extremes.
237    pub fn progress_in(&self, start: Time, duration: Duration) -> f64 {
238        assert!(
239            duration >= Time::ZERO,
240            "interval duration must be nonnegative"
241        );
242        let elapsed = i128::from(self.time.ticks()) - i128::from(start.ticks());
243        if elapsed < 0 {
244            0.0
245        } else if elapsed >= i128::from(duration.ticks()) {
246            1.0
247        } else {
248            elapsed as f64 / duration.ticks() as f64
249        }
250    }
251
252    /// Eased local interval progress, with the same clamping as progress_in.
253    pub fn ease_in(&self, start: Duration, duration: Duration, easing: crate::Easing) -> f64 {
254        easing.apply(self.progress_in(start, duration))
255    }
256
257    /// Exact rational authoring frame rate shared by still, preview and export clocks.
258    pub fn frame_rate(&self) -> FrameRate {
259        self.frame_rate
260    }
261
262    pub(crate) fn for_timeline(&self, size: Size, frame_rate: FrameRate) -> Self {
263        Self {
264            frame_rate,
265            size,
266            nested_scene: true,
267            ..*self
268        }
269    }
270
271    pub(crate) fn is_nested_scene(&self) -> bool {
272        self.nested_scene
273    }
274
275    /// Converts a signed frame count directly to ticks without cumulative rounding.
276    pub fn frames(&self, count: i64) -> Duration {
277        Time::from_frame(count, self.frame_rate)
278    }
279
280    /// Samples an animation using local, not global, time.
281    pub fn sample<Value>(&self, animation: &(impl Animation<Value> + ?Sized)) -> Value {
282        animation.sample(self.time)
283    }
284
285    /// Subtracts an offset from local time, preserving global time and clip metadata.
286    ///
287    /// Offsets can be negative. Panics if the resulting local ticks overflow.
288    pub fn shifted(&self, offset: Duration) -> Self {
289        Self {
290            time: self.time - offset,
291            ..*self
292        }
293    }
294
295    /// Subtracts a local offset without panicking on tick overflow.
296    pub fn try_shifted(&self, offset: Duration) -> Result<Self, crate::ValidationError> {
297        let ticks = self
298            .time
299            .ticks()
300            .checked_sub(offset.ticks())
301            .ok_or_else(|| {
302                crate::ValidationError::invalid(
303                    "scene_context",
304                    "time",
305                    "Shifted local ticks overflow",
306                )
307            })?;
308        Ok(Self {
309            time: Time::from_ticks(ticks),
310            ..*self
311        })
312    }
313
314    pub(crate) fn resampled(&self, offset: Duration) -> Result<Self, crate::ValidationError> {
315        let shift = |time: Time| {
316            time.ticks()
317                .checked_add(offset.ticks())
318                .map(Time::from_ticks)
319                .ok_or_else(|| {
320                    crate::ValidationError::invalid(
321                        "motion_blur",
322                        "time",
323                        "Subframe local/global ticks overflow",
324                    )
325                })
326        };
327        Ok(Self {
328            time: shift(self.time)?,
329            global_time: shift(self.global_time)?,
330            ..*self
331        })
332    }
333
334    /// Resamples a local subtree at 1..=64 centered integer shutter midpoints.
335    /// Both local/global clocks move; clip metadata, resources and cache persist.
336    /// Signed samples outside a clip are supplied unchanged for caller-owned gating.
337    pub fn try_motion_blur(
338        &self,
339        shutter: Duration,
340        samples: u32,
341        build: impl FnMut(&SceneContext<'_>) -> Node,
342    ) -> Result<Node, crate::ValidationError> {
343        crate::authoring::sampling::capture(self, shutter, samples, build)
344    }
345
346    /// Captures shutter samples, deferring invalid sampling parameters to compilation.
347    pub fn motion_blur(
348        &self,
349        shutter: Duration,
350        samples: u32,
351        build: impl FnMut(&SceneContext<'_>) -> Node,
352    ) -> Node {
353        self.try_motion_blur(shutter, samples, build)
354            .unwrap_or_else(super::scene::failure)
355    }
356
357    /// Builds only in [start, start + duration), with shifted local clip inputs.
358    ///
359    /// Outside the interval, including zero duration, returns an empty node and
360    /// does not call build. Global time stays unchanged. Integer differences avoid
361    /// overflowing the endpoint sum. Panics for negative duration.
362    pub fn during(
363        &self,
364        start: Time,
365        duration: Duration,
366        build: impl FnOnce(&SceneContext<'a>) -> Node,
367    ) -> Node {
368        assert!(
369            duration >= Time::ZERO,
370            "interval duration must be nonnegative"
371        );
372        let elapsed = i128::from(self.time.ticks()) - i128::from(start.ticks());
373        if elapsed < 0 || elapsed >= i128::from(duration.ticks()) {
374            Node::empty()
375        } else {
376            build(&Self {
377                nested_scene: true,
378                ..self.shifted(start).with_clip_duration(duration)
379            })
380            .with_scene_time(Time::from_ticks(elapsed as i64))
381        }
382    }
383
384    /// Evaluates a child scene starting at a parent-local offset.
385    ///
386    /// Finite children use their half-open duration. Infinite children are active
387    /// from offset onward and clear inherited clip-duration metadata. The parent
388    /// context itself is never clamped; its caller controls the parent interval.
389    /// Panics for invalid child duration or unrepresentable shifted local ticks.
390    /// Timeline children clip and contain their own design viewport;
391    /// changing the child frame grid never quantizes the elapsed parent ticks.
392    pub fn embed(&self, scene: &dyn crate::authoring::Scene, offset: Duration) -> Node {
393        match scene.duration() {
394            Some(duration) => self.during(offset, duration, |context| scene.render(context)),
395            None if self.time < offset => Node::empty(),
396            None => scene.render(&Self {
397                clip_duration: None,
398                nested_scene: true,
399                ..self.shifted(offset)
400            }),
401        }
402    }
403
404    /// Logical design dimensions, not device pixels.
405    pub fn size(&self) -> Size {
406        self.size
407    }
408
409    /// Opposite-corner logical bounds at the origin.
410    pub fn bounds(&self) -> Rect {
411        self.size.to_rect()
412    }
413
414    /// Center of the logical design bounds.
415    pub fn center(&self) -> Point {
416        self.bounds().center()
417    }
418
419    /// Measures content or declared layout in parent coordinates, independently of time.
420    ///
421    /// Uses default structural limits without rendering or f32 pixel conversion.
422    /// For explicit budgets use [`Node::measure`]. Leaves supply their own bounds
423    /// with the shared explicit font environment. This does not invoke drawing
424    /// or infer filter extents; command validation occurs during compilation.
425    pub fn measure(&self, node: &Node) -> Result<Rect, crate::ValidationError> {
426        let drawing = crate::DrawContext::default().with_assets(self.assets());
427        node.measure_with(&drawing, crate::ValidationLimits::default())
428    }
429
430    /// Measures like [`measure`](Self::measure) but substitutes `fallback` when measurement fails,
431    /// for example `context.measure_or(&label, Rect::default())`. The failure still surfaces
432    /// when the node is compiled.
433    pub fn measure_or(&self, node: &Node, fallback: Rect) -> Rect {
434        self.measure(node).unwrap_or(fallback)
435    }
436
437    /// Places a measured snapshot next to a reference in parent coordinates.
438    ///
439    /// Vertical sides center horizontally; horizontal sides center vertically.
440    /// Finite negative gaps intentionally overlap. Returns errors for unknown
441    /// geometry, invalid gaps or unrepresentable offsets; inputs stay unchanged.
442    /// This is immediate snapshot positioning, not a live constraint to reference.
443    ///
444    /// ```
445    /// use motion_core::prelude::*;
446    /// use motion_core::{
447    ///     DisplayList, RasterLimits, ResourcePool, ValidatedDisplayList, MAX_PATH_VERBS,
448    ///     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
449    /// };
450    /// let cache = SceneCache::new(0);
451    /// let context = SceneContext::new(
452    ///     Time::ZERO,
453    ///     FrameRate::from(30),
454    ///     Size::new(200.0, 100.0),
455    ///     &cache,
456    /// );
457    /// let reference = Node::from(Rect::new(10.0, 10.0, 50.0, 30.0).fill(Color::WHITE));
458    /// let card = Node::from(Rect::new(0.0, 0.0, 8.0, 4.0).fill(Color::WHITE));
459    /// let card = context.next_to(card, &reference, Side::Right, 6.0);
460    /// assert_eq!(context.measure(&card)?, Rect::new(56.0, 18.0, 64.0, 22.0));
461    /// let card = context.align_to(card, &reference, Side::Bottom);
462    /// assert_eq!(context.measure(&card)?, Rect::new(56.0, 26.0, 64.0, 30.0));
463    /// # Ok::<(), motion_core::ValidationError>(())
464    /// ```
465    pub fn try_next_to(
466        &self,
467        node: impl Into<Node>,
468        reference: &Node,
469        side: crate::authoring::Side,
470        gap: f64,
471    ) -> Result<Node, crate::ValidationError> {
472        if !gap.is_finite() {
473            return Err(crate::ValidationError::invalid(
474                "layout",
475                "gap",
476                "Relative layout gap must be finite",
477            ));
478        }
479        let node = node.into();
480        let offset = side.adjacent(self.measure(&node)?, self.measure(reference)?, gap);
481        self.translated_layout(node, offset)
482    }
483
484    /// Aligns one measured edge with a reference, preserving the other axis.
485    ///
486    /// Uses the already transformed parent-space AABBs. Existing position,
487    /// rotation, scale and pivot remain intact; only translation is added.
488    pub fn try_align_to(
489        &self,
490        node: impl Into<Node>,
491        reference: &Node,
492        edge: crate::authoring::Side,
493    ) -> Result<Node, crate::ValidationError> {
494        let node = node.into();
495        let offset = edge.aligned(self.measure(&node)?, self.measure(reference)?);
496        self.translated_layout(node, offset)
497    }
498
499    /// Measures and places the snapshot next to its reference during compilation.
500    pub fn next_to(
501        &self,
502        node: impl Into<Node>,
503        reference: &Node,
504        side: super::Side,
505        gap: f64,
506    ) -> Node {
507        node.into().next_to(reference, side, gap)
508    }
509
510    /// Aligns one edge using the current compile-time font and drawing environment.
511    pub fn align_to(&self, node: impl Into<Node>, reference: &Node, edge: super::Side) -> Node {
512        node.into().align_to(reference, edge)
513    }
514
515    fn translated_layout(
516        &self,
517        node: Node,
518        offset: crate::Vector2,
519    ) -> Result<Node, crate::ValidationError> {
520        use crate::NodeModifiers;
521        if !offset.x.is_finite() || !offset.y.is_finite() {
522            return Err(crate::ValidationError::invalid(
523                "layout",
524                "translation",
525                "Relative layout offset must remain finite",
526            ));
527        }
528        let node = node.translate(offset.x, offset.y);
529        self.measure(&node)?;
530        Ok(node)
531    }
532
533    /// Returns a key-derived value in [0, 1), independent of time and cache state.
534    pub fn random(&self, key: impl Hash) -> f64 {
535        self.random_generator(key).next_float()
536    }
537
538    /// Starts a fresh key-derived stream, independent of all other evaluations.
539    pub fn random_generator(&self, key: impl Hash) -> Random {
540        Random::from_key(key)
541    }
542
543    /// Reuses a pure value by calling location, value type and an equal typed key.
544    ///
545    /// Keys must distinguish all inputs, including resource revisions. Keys are
546    /// retained, so use owned data or static references rather than borrowed locals.
547    /// Hash collisions are resolved by key type and equality.
548    /// Builders may be rerun after eviction, clear, disabled caching or races.
549    #[track_caller]
550    pub fn cache<Key: Eq + Hash + Send + Sync + 'static, Value: Send + Sync + 'static>(
551        &self,
552        key: Key,
553        build: impl FnOnce() -> Value,
554    ) -> Arc<Value> {
555        self.cache.get(key, build)
556    }
557
558    /// Reuses a pure static subtree without exposing its shared Arc handle.
559    #[track_caller]
560    pub fn memo<Key: Eq + Hash + Send + Sync + 'static, Output: Into<Node>>(
561        &self,
562        key: Key,
563        build: impl FnOnce() -> Output,
564    ) -> Node {
565        self.cache(key, || build().into()).as_ref().clone()
566    }
567
568    /// Caches a pure value with an explicit logical retained-byte estimate.
569    /// Cost includes the key, value and their owned heap payloads, with a minimum
570    /// of their combined inline sizes. Entry metadata, allocator overhead and
571    /// shared external pins are excluded.
572    /// Oversized values are returned without retention; cost runs outside the lock.
573    /// Keys retain the same equality-checked, callsite-scoped contract as cache().
574    #[track_caller]
575    pub fn cache_with_cost<Key: Eq + Hash + Send + Sync + 'static, Value: Send + Sync + 'static>(
576        &self,
577        key: Key,
578        build: impl FnOnce() -> Value,
579        cost: impl FnOnce(&Value) -> usize,
580    ) -> Arc<Value> {
581        self.cache.get_with_cost(key, build, cost)
582    }
583}