1use renamite_animation::{Frame, LoopMode, Tween, ease_progress};
13use renamite_geometry::VectorPath;
14use renamite_model::{KeyframeData, NodeId, Overrides, PropPath, Value};
15use serde::{Deserialize, Serialize};
16use slotmap::SlotMap;
17use std::collections::HashMap;
18
19slotmap::new_key_type! { pub struct ClipId; pub struct MachineId; }
20pub type ClipMap = SlotMap<ClipId, Clip>;
21pub type MachineMap = SlotMap<MachineId, Machine>;
22
23#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
24pub struct Clip {
25 pub name: String,
26 pub range: (Frame, Frame),
27 pub tracks: Vec<Track>,
28 pub events: Vec<EventKey>,
30}
31
32#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
33pub struct Track {
34 pub node: NodeId,
35 pub prop: PropPath,
36 pub keys: Vec<KeyframeData>,
38}
39
40#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
41pub struct EventKey {
42 pub frame: Frame,
43 pub name: String,
44}
45
46pub fn value_tween(a: &Value, b: &Value, t: f64) -> Value {
48 use Value::*;
49 match (a, b) {
50 (F64(x), F64(y)) => F64(f64::tween(x, y, t)),
51 (DVec2(x), DVec2(y)) => DVec2(glam::DVec2::tween(x, y, t)),
52 (Angle(x), Angle(y)) => Angle(renamite_animation::Angle::tween(x, y, t)),
53 (Color(x), Color(y)) => Color(renamite_model::Color::tween(x, y, t)),
54 (Path(x), Path(y)) => Path(VectorPath::tween(x, y, t)),
55 _ => {
56 if t < 1.0 {
57 a.clone()
58 } else {
59 b.clone()
60 }
61 }
62 }
63}
64
65impl Track {
66 pub fn value_at(&self, frame: f64) -> Option<Value> {
67 let ks = &self.keys;
68 if ks.is_empty() {
69 return None;
70 }
71 if frame <= ks[0].frame.0 as f64 {
72 return Some(ks[0].value.clone());
73 }
74 let last = ks.len() - 1;
75 if frame >= ks[last].frame.0 as f64 {
76 return Some(ks[last].value.clone());
77 }
78 let i = ks.partition_point(|k| (k.frame.0 as f64) <= frame) - 1;
79 let (a, b) = (&ks[i], &ks[i + 1]);
80 let u = (frame - a.frame.0 as f64) / (b.frame.0 - a.frame.0) as f64;
81 let y = ease_progress(a.interpolation, a.ease_out, a.ease_in, u);
82 Some(value_tween(&a.value, &b.value, y))
83 }
84}
85
86impl Clip {
87 pub fn len_frames(&self) -> f64 {
88 (self.range.1.0 - self.range.0.0).max(1) as f64
89 }
90
91 pub fn local(&self, time: f64, loop_mode: LoopMode) -> (f64, f64) {
93 let (s, len) = (self.range.0.0 as f64, self.len_frames());
94 let t = match loop_mode {
95 LoopMode::Once => time.clamp(0.0, len),
96 LoopMode::Loop => time.rem_euclid(len),
97 LoopMode::PingPong => {
98 let c = time.rem_euclid(2.0 * len);
99 if c > len { 2.0 * len - c } else { c }
100 }
101 };
102 (s + t, t / len)
103 }
104
105 pub fn sample_into(&self, frame: f64, out: &mut HashMap<(NodeId, PropPath), Value>) {
106 for tr in &self.tracks {
107 if let Some(v) = tr.value_at(frame) {
108 out.insert((tr.node, tr.prop.clone()), v);
109 }
110 }
111 }
112}
113
114#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
115pub struct Machine {
116 pub name: String,
117 pub inputs: Vec<InputDef>,
118 pub layers: Vec<MachineLayer>,
119 pub listeners: Vec<Listener>,
122}
123
124#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
125pub struct InputDef {
126 pub name: String,
127 pub kind: InputKind,
128}
129
130#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
131pub enum InputKind {
132 Bool { default: bool },
133 Number { default: f64 },
134 Trigger,
135}
136
137#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
138pub struct MachineLayer {
139 pub name: String,
140 pub states: Vec<State>,
141 pub entry: usize,
142 pub any_transitions: Vec<Transition>,
144}
145
146#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
147pub struct State {
148 pub name: String,
149 pub kind: StateKind,
150 pub transitions: Vec<Transition>,
151 #[serde(default)]
152 pub graph_pos: Option<(f64, f64)>,
153}
154
155#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
156pub enum StateKind {
157 Clip {
159 clip: ClipId,
160 speed: f64,
161 loop_mode: LoopMode,
162 },
163 Blend1D {
165 input: usize,
166 children: Vec<BlendChild>,
167 },
168 Empty,
170}
171
172#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
173pub struct BlendChild {
174 pub threshold: f64,
175 pub clip: ClipId,
176}
177
178#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
179pub struct Transition {
180 pub to: usize,
181 pub duration: f64,
183 pub exit_time: Option<f64>,
185 pub conditions: Vec<Condition>,
187}
188
189#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
190pub enum Condition {
191 BoolIs { input: usize, value: bool },
192 NumberCmp { input: usize, op: CmpOp, value: f64 },
193 Triggered { input: usize },
194}
195
196#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
197pub enum CmpOp {
198 Eq,
199 Ne,
200 Lt,
201 Le,
202 Gt,
203 Ge,
204}
205
206#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
207pub struct Listener {
208 pub node: NodeId,
209 pub event: PointerEventKind,
210 pub action: ListenerAction,
211}
212
213#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
214pub enum PointerEventKind {
215 Down,
216 Up,
217 Click,
218 Enter,
219 Exit,
220}
221
222#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
223pub enum ListenerAction {
224 SetBool { input: usize, value: bool },
225 ToggleBool { input: usize },
226 SetNumber { input: usize, value: f64 },
227 FireTrigger { input: usize },
228}
229
230#[derive(Clone, Copy, Debug, PartialEq)]
231pub enum InputValue {
232 Bool(bool),
233 Number(f64),
234 Trigger { fired: bool },
235}
236
237#[derive(Clone, Debug)]
238struct LayerRt {
239 current: usize,
240 time: f64,
242 fade: Option<Fade>,
243}
244
245#[derive(Clone, Debug)]
246struct Fade {
247 from: usize,
248 from_time: f64,
249 t: f64,
250 duration: f64,
251}
252
253#[derive(Clone, Debug)]
254pub struct MachineInstance {
255 pub inputs: Vec<InputValue>,
256 layers: Vec<LayerRt>,
257}
258
259#[derive(Clone, Debug, Default)]
260pub struct TickOutput {
261 pub events: Vec<String>,
262}
263
264#[derive(Clone, Debug, thiserror::Error)]
265pub enum MachineError {
266 #[error("unknown input `{0}`")]
267 UnknownInput(String),
268 #[error("input type mismatch for `{0}`")]
269 InputType(String),
270}
271
272impl MachineInstance {
273 pub fn new(m: &Machine) -> Self {
274 Self {
275 inputs: m
276 .inputs
277 .iter()
278 .map(|i| match i.kind {
279 InputKind::Bool { default } => InputValue::Bool(default),
280 InputKind::Number { default } => InputValue::Number(default),
281 InputKind::Trigger => InputValue::Trigger { fired: false },
282 })
283 .collect(),
284 layers: m
285 .layers
286 .iter()
287 .map(|l| LayerRt {
288 current: l.entry.min(l.states.len().saturating_sub(1)),
289 time: 0.0,
290 fade: None,
291 })
292 .collect(),
293 }
294 }
295
296 pub fn input_index(m: &Machine, name: &str) -> Option<usize> {
297 m.inputs.iter().position(|i| i.name == name)
298 }
299 pub fn layer_states(&self) -> impl Iterator<Item = usize> + '_ {
301 self.layers.iter().map(|layer| layer.current)
302 }
303 pub fn set_bool(&mut self, idx: usize, v: bool) {
304 if let Some(InputValue::Bool(b)) = self.inputs.get_mut(idx) {
305 *b = v;
306 }
307 }
308 pub fn set_number(&mut self, idx: usize, v: f64) {
309 if let Some(InputValue::Number(n)) = self.inputs.get_mut(idx) {
310 *n = v;
311 }
312 }
313 pub fn fire(&mut self, idx: usize) {
314 if let Some(InputValue::Trigger { fired }) = self.inputs.get_mut(idx) {
315 *fired = true;
316 }
317 }
318
319 pub fn pointer_event(&mut self, m: &Machine, node: NodeId, kind: PointerEventKind) {
321 for l in m
322 .listeners
323 .iter()
324 .filter(|l| l.node == node && l.event == kind)
325 {
326 match l.action {
327 ListenerAction::SetBool { input, value } => self.set_bool(input, value),
328 ListenerAction::ToggleBool { input } => {
329 if let Some(InputValue::Bool(b)) = self.inputs.get_mut(input) {
330 *b = !*b;
331 }
332 }
333 ListenerAction::SetNumber { input, value } => self.set_number(input, value),
334 ListenerAction::FireTrigger { input } => self.fire(input),
335 }
336 }
337 }
338
339 pub fn tick(
342 &mut self,
343 m: &Machine,
344 clips: &ClipMap,
345 dt_frames: f64,
346 out: &mut Overrides,
347 ) -> TickOutput {
348 let mut output = TickOutput::default();
349 for (li, layer) in m.layers.iter().enumerate() {
350 let rt = &mut self.layers[li];
351 let prev_time = rt.time;
352 rt.time += dt_frames;
353 if let Some(f) = &mut rt.fade {
354 f.from_time += dt_frames;
355 f.t = if f.duration <= 0.0 {
356 1.0
357 } else {
358 (f.t + dt_frames / f.duration).min(1.0)
359 };
360 if f.t >= 1.0 {
361 rt.fade = None;
362 }
363 }
364
365 let state = &layer.states[rt.current];
367 let norm = normalized_time(state, clips, rt.time);
368 let fired = layer
369 .any_transitions
370 .iter()
371 .chain(state.transitions.iter())
372 .find(|tr| transition_ready(tr, &self.inputs, norm));
373 let fired = fired.cloned().filter(|tr| {
374 let to = tr.to.min(layer.states.len() - 1);
375 if to != rt.current {
376 return true;
377 }
378 tr.conditions
379 .iter()
380 .any(|c| matches!(c, Condition::Triggered { .. }))
381 });
382 let mut transitioned_from: Option<(usize, f64, f64)> = None;
383 if let Some(tr) = fired {
384 consume_triggers(&tr, &mut self.inputs);
385 transitioned_from = Some((rt.current, prev_time, rt.time));
386 rt.fade = (tr.duration > 0.0).then_some(Fade {
387 from: rt.current,
388 from_time: prev_time,
389 t: 0.0,
390 duration: tr.duration,
391 });
392 rt.current = tr.to.min(layer.states.len() - 1);
393 rt.time = 0.0;
394 }
395
396 let mut b = HashMap::new();
398 let mut evs = Vec::new();
399 if let Some((from_idx, from_prev, from_cur)) = transitioned_from {
400 let mut old_evs = Vec::new();
401 let mut old_vals = HashMap::new();
402 sample_state(
403 &layer.states[from_idx],
404 clips,
405 &self.inputs,
406 from_prev,
407 from_cur,
408 &mut old_vals,
409 &mut old_evs,
410 );
411 evs.append(&mut old_evs);
412 }
413 let sample_prev = if transitioned_from.is_some() {
414 rt.time
415 } else {
416 prev_if_same(prev_time, rt.time)
417 };
418 sample_state(
419 &layer.states[rt.current],
420 clips,
421 &self.inputs,
422 sample_prev,
423 rt.time,
424 &mut b,
425 &mut evs,
426 );
427 if let Some(f) = &rt.fade {
428 let mut a = HashMap::new();
429 let mut fade_evs = Vec::new();
430 let fade_prev = (f.from_time - dt_frames).max(0.0);
431 sample_state(
432 &layer.states[f.from],
433 clips,
434 &self.inputs,
435 fade_prev,
436 f.from_time,
437 &mut a,
438 &mut fade_evs,
439 );
440 evs.append(&mut fade_evs);
441 for (k, va) in a {
442 let merged = match b.get(&k) {
443 Some(vb) => value_tween(&va, vb, f.t),
444 None => va,
445 };
446 b.insert(k, merged);
447 }
448 }
449 for (k, v) in b {
450 out.set(k.0, k.1, v);
451 }
452 output.events.append(&mut evs);
453 }
454 for i in &mut self.inputs {
456 if let InputValue::Trigger { fired } = i {
457 *fired = false;
458 }
459 }
460 output
461 }
462}
463
464fn prev_if_same(prev: f64, cur: f64) -> f64 {
465 if cur < prev { 0.0 } else { prev }
466}
467
468fn normalized_time(state: &State, clips: &ClipMap, time: f64) -> f64 {
469 match &state.kind {
470 StateKind::Clip {
471 clip,
472 speed,
473 loop_mode,
474 } => clips
475 .get(*clip)
476 .map(|c| c.local(time * speed.max(0.0), *loop_mode).1)
477 .unwrap_or(1.0),
478 StateKind::Blend1D { children, .. } => {
479 if children.is_empty() {
480 return 1.0;
481 }
482 children
483 .first()
484 .and_then(|ch| clips.get(ch.clip))
485 .map(|c| c.local(time, LoopMode::Loop).1)
486 .unwrap_or(1.0)
487 }
488 StateKind::Empty => 1.0,
489 }
490}
491
492fn transition_ready(tr: &Transition, inputs: &[InputValue], norm: f64) -> bool {
493 if let Some(et) = tr.exit_time
494 && norm < et
495 {
496 return false;
497 }
498 if tr.conditions.is_empty() && tr.exit_time.is_none() {
499 return false;
500 }
501 tr.conditions.iter().all(|c| match *c {
502 Condition::BoolIs { input, value } => {
503 matches!(inputs.get(input), Some(InputValue::Bool(b)) if *b == value)
504 }
505 Condition::NumberCmp { input, op, value } => match inputs.get(input) {
506 Some(InputValue::Number(n)) => match op {
507 CmpOp::Eq => (n - value).abs() < 1e-9,
508 CmpOp::Ne => (n - value).abs() >= 1e-9,
509 CmpOp::Lt => *n < value,
510 CmpOp::Le => *n <= value,
511 CmpOp::Gt => *n > value,
512 CmpOp::Ge => *n >= value,
513 },
514 _ => false,
515 },
516 Condition::Triggered { input } => {
517 matches!(inputs.get(input), Some(InputValue::Trigger { fired: true }))
518 }
519 })
520}
521
522fn consume_triggers(tr: &Transition, inputs: &mut [InputValue]) {
523 for c in &tr.conditions {
524 if let Condition::Triggered { input } = c
525 && let Some(InputValue::Trigger { fired }) = inputs.get_mut(*input)
526 {
527 *fired = false;
528 }
529 }
530}
531
532fn sample_state(
533 state: &State,
534 clips: &ClipMap,
535 inputs: &[InputValue],
536 prev_time: f64,
537 time: f64,
538 out: &mut HashMap<(NodeId, PropPath), Value>,
539 events: &mut Vec<String>,
540) {
541 match &state.kind {
542 StateKind::Empty => {}
543 StateKind::Clip {
544 clip,
545 speed,
546 loop_mode,
547 } => {
548 let Some(c) = clips.get(*clip) else { return };
549 let (frame, _) = c.local(time * speed.max(0.0), *loop_mode);
550 let (pframe, _) = c.local(prev_time * speed.max(0.0), *loop_mode);
551 c.sample_into(frame, out);
552 emit_events(c, pframe, frame, *loop_mode, events);
553 }
554 StateKind::Blend1D { input, children } => {
555 if children.is_empty() {
556 return;
557 }
558 let x = match inputs.get(*input) {
559 Some(InputValue::Number(n)) => *n,
560 _ => 0.0,
561 };
562 let (lo, hi, t) = bracket(children, x);
563 let mut a = HashMap::new();
564 if let Some(c) = clips.get(children[lo].clip) {
565 c.sample_into(c.local(time, LoopMode::Loop).0, &mut a);
566 }
567 if hi != lo {
568 let mut b = HashMap::new();
569 if let Some(c) = clips.get(children[hi].clip) {
570 c.sample_into(c.local(time, LoopMode::Loop).0, &mut b);
571 }
572 for (k, va) in a {
573 let v = match b.remove(&k) {
574 Some(vb) => value_tween(&va, &vb, t),
575 None => va,
576 };
577 out.insert(k, v);
578 }
579 out.extend(b);
580 } else {
581 out.extend(a);
582 }
583 }
584 }
585}
586
587fn bracket(children: &[BlendChild], x: f64) -> (usize, usize, f64) {
589 if children.is_empty() {
590 return (0, 0, 0.0);
591 }
592 if x <= children[0].threshold {
593 return (0, 0, 0.0);
594 }
595 let last = children.len() - 1;
596 if x >= children[last].threshold {
597 return (last, last, 0.0);
598 }
599 let hi = children.partition_point(|c| c.threshold <= x);
600 let (lo, hi) = (hi - 1, hi);
601 let span = children[hi].threshold - children[lo].threshold;
602 (
603 lo,
604 hi,
605 if span <= 0.0 {
606 0.0
607 } else {
608 (x - children[lo].threshold) / span
609 },
610 )
611}
612
613fn emit_events(c: &Clip, prev: f64, cur: f64, loop_mode: LoopMode, out: &mut Vec<String>) {
614 let hit = |a: f64, b: f64, out: &mut Vec<String>| {
615 for e in &c.events {
616 let f = e.frame.0 as f64;
617 if f > a && f <= b {
618 out.push(e.name.clone());
619 }
620 }
621 };
622 if cur >= prev {
623 hit(prev, cur, out);
624 } else if loop_mode == LoopMode::Loop {
625 hit(prev, c.range.1.0 as f64, out);
626 hit(c.range.0.0 as f64 - 1.0, cur, out);
627 } else if loop_mode == LoopMode::PingPong {
628 hit(cur, prev, out);
629 }
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635 use renamite_animation::{EasingHandle, Interpolation};
636
637 fn key(f: i64, v: f64) -> KeyframeData {
638 KeyframeData {
639 frame: Frame(f),
640 value: Value::F64(v),
641 interpolation: Interpolation::Linear,
642 ease_out: EasingHandle::LINEAR_OUT,
643 ease_in: EasingHandle::LINEAR_IN,
644 }
645 }
646
647 fn world() -> (ClipMap, Machine, NodeId) {
648 let node = {
649 let mut doc = renamite_model::Document::empty();
650 doc.create_node(renamite_model::Node::new(
651 "n",
652 renamite_model::NodeKind::Group,
653 ))
654 };
655 let mut clips = ClipMap::default();
656 let up = clips.insert(Clip {
657 name: "up".into(),
658 range: (Frame(0), Frame(60)),
659 tracks: vec![Track {
660 node,
661 prop: PropPath::new("opacity"),
662 keys: vec![key(0, 0.0), key(60, 1.0)],
663 }],
664 events: vec![EventKey {
665 frame: Frame(30),
666 name: "half".into(),
667 }],
668 });
669 let down = clips.insert(Clip {
670 name: "down".into(),
671 range: (Frame(0), Frame(60)),
672 tracks: vec![Track {
673 node,
674 prop: PropPath::new("opacity"),
675 keys: vec![key(0, 1.0), key(60, 0.0)],
676 }],
677 events: vec![],
678 });
679 let m = Machine {
680 name: "hover".into(),
681 inputs: vec![InputDef {
682 name: "over".into(),
683 kind: InputKind::Bool { default: false },
684 }],
685 layers: vec![MachineLayer {
686 name: "base".into(),
687 entry: 0,
688 any_transitions: vec![],
689 states: vec![
690 State {
691 name: "Down".into(),
692 kind: StateKind::Clip {
693 clip: down,
694 speed: 1.0,
695 loop_mode: LoopMode::Once,
696 },
697 transitions: vec![Transition {
698 to: 1,
699 duration: 10.0,
700 exit_time: None,
701 conditions: vec![Condition::BoolIs {
702 input: 0,
703 value: true,
704 }],
705 }],
706 graph_pos: None,
707 },
708 State {
709 name: "Up".into(),
710 kind: StateKind::Clip {
711 clip: up,
712 speed: 1.0,
713 loop_mode: LoopMode::Once,
714 },
715 transitions: vec![Transition {
716 to: 0,
717 duration: 10.0,
718 exit_time: None,
719 conditions: vec![Condition::BoolIs {
720 input: 0,
721 value: false,
722 }],
723 }],
724 graph_pos: None,
725 },
726 ],
727 }],
728 listeners: vec![Listener {
729 node,
730 event: PointerEventKind::Enter,
731 action: ListenerAction::SetBool {
732 input: 0,
733 value: true,
734 },
735 }],
736 };
737 (clips, m, node)
738 }
739
740 #[test]
741 fn track_lerps() {
742 let (clips, _, node) = world();
743 let c = clips.values().find(|c| c.name == "up").unwrap();
744 let mut out = HashMap::new();
745 c.sample_into(30.0, &mut out);
746 assert_eq!(out[&(node, PropPath::new("opacity"))], Value::F64(0.5));
747 }
748
749 #[test]
750 fn bool_input_transitions_and_listener_sets_it() {
751 let (clips, m, node) = world();
752 let mut inst = MachineInstance::new(&m);
753 let mut ov = Overrides::default();
754 inst.tick(&m, &clips, 1.0, &mut ov);
755 assert_eq!(inst.layers[0].current, 0);
756 inst.pointer_event(&m, node, PointerEventKind::Enter);
757 inst.tick(&m, &clips, 1.0, &mut ov);
758 assert_eq!(inst.layers[0].current, 1);
759 assert!(inst.layers[0].fade.is_some());
760 }
761
762 #[test]
763 fn trigger_consumed_once() {
764 let (clips, mut m, _) = world();
765 m.inputs.push(InputDef {
766 name: "tap".into(),
767 kind: InputKind::Trigger,
768 });
769 m.layers[0].states[0].transitions[0].conditions = vec![Condition::Triggered { input: 1 }];
770 let mut inst = MachineInstance::new(&m);
771 let mut ov = Overrides::default();
772 inst.fire(1);
773 inst.tick(&m, &clips, 1.0, &mut ov);
774 assert_eq!(inst.layers[0].current, 1);
775 m.layers[0].states[1].transitions[0].conditions = vec![Condition::Triggered { input: 1 }];
776 inst.tick(&m, &clips, 1.0, &mut ov);
777 assert_eq!(inst.layers[0].current, 1);
778 }
779
780 #[test]
781 fn clip_event_crossing_fires_once() {
782 let (clips, m, _) = world();
783 let mut inst = MachineInstance::new(&m);
784 inst.set_bool(0, true);
785 let mut ov = Overrides::default();
786 inst.tick(&m, &clips, 1.0, &mut ov);
787 let mut names = Vec::new();
788 for _ in 0..40 {
789 let out = inst.tick(&m, &clips, 1.0, &mut ov);
790 names.extend(out.events);
791 }
792 assert_eq!(names.iter().filter(|n| *n == "half").count(), 1);
793 }
794
795 #[test]
796 fn crossfade_tweens_overlapping_props() {
797 let (clips, m, node) = world();
798 let mut inst = MachineInstance::new(&m);
799 let mut ov = Overrides::default();
800 inst.set_bool(0, true);
801 inst.tick(&m, &clips, 1.0, &mut ov);
803 assert!(inst.layers[0].fade.is_some());
804 ov.clear();
806 inst.tick(&m, &clips, 4.0, &mut ov);
807 let Some(Value::F64(op)) = ov.get(node, "opacity").cloned() else {
808 panic!("expected f64 opacity");
809 };
810 assert!(op > 0.0 && op < 1.0, "got {op}");
811 }
812}