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