Skip to main content

rustmotion_core/engine/
deps.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2
3use crate::engine::box_tree::{BoxKind, BoxNode};
4use crate::engine::layout_pass::{BoxLayout, LayoutResult};
5use crate::engine::paint_pass::animated_transform;
6use crate::engine::renderer::GlyphMetric;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct NodeRef {
10    pub id: String,
11    pub prop: String,
12}
13
14pub fn scan_node_refs(src: &str) -> Vec<NodeRef> {
15    let bytes = src.as_bytes();
16    let mut out = Vec::new();
17    let mut i = 0usize;
18    while i + 4 <= bytes.len() {
19        let is_word_start = i == 0 || !is_ident_byte(bytes[i - 1]);
20        if is_word_start && &bytes[i..i + 4] == b"node" {
21            let after_ident = i + 4;
22            let is_word_end = bytes.get(after_ident).is_none_or(|&b| !is_ident_byte(b));
23            if is_word_end {
24                if let Some((node_ref, next)) = try_parse_node_call(src, after_ident) {
25                    out.push(node_ref);
26                    i = next;
27                    continue;
28                }
29            }
30        }
31        i += 1;
32    }
33    out
34}
35
36fn is_ident_byte(b: u8) -> bool {
37    b.is_ascii_alphanumeric() || b == b'_'
38}
39
40fn skip_ws(bytes: &[u8], mut i: usize) -> usize {
41    while i < bytes.len() && (bytes[i] as char).is_whitespace() {
42        i += 1;
43    }
44    i
45}
46
47fn try_parse_node_call(src: &str, after_ident: usize) -> Option<(NodeRef, usize)> {
48    let bytes = src.as_bytes();
49    let mut i = skip_ws(bytes, after_ident);
50    if bytes.get(i) != Some(&b'(') {
51        return None;
52    }
53    i = skip_ws(bytes, i + 1);
54    let (id, next) = scan_string_literal(src, i)?;
55    i = skip_ws(bytes, next);
56    if bytes.get(i) != Some(&b',') {
57        return None;
58    }
59    i = skip_ws(bytes, i + 1);
60    let (prop, next) = scan_string_literal(src, i)?;
61    i = skip_ws(bytes, next);
62    if bytes.get(i) != Some(&b')') {
63        return None;
64    }
65    Some((NodeRef { id, prop }, i + 1))
66}
67
68fn scan_string_literal(src: &str, start: usize) -> Option<(String, usize)> {
69    let bytes = src.as_bytes();
70    if bytes.get(start) != Some(&b'"') {
71        return None;
72    }
73    let mut j = start + 1;
74    let mut s = String::new();
75    loop {
76        match bytes.get(j) {
77            None => return None,
78            Some(b'"') => return Some((s, j + 1)),
79            Some(b'\\') if bytes.get(j + 1) == Some(&b'"') => {
80                s.push('"');
81                j += 2;
82            }
83            Some(b'\\') if bytes.get(j + 1) == Some(&b'\\') => {
84                s.push('\\');
85                j += 2;
86            }
87            Some(_) => {
88                let ch = src[j..].chars().next()?;
89                s.push(ch);
90                j += ch.len_utf8();
91            }
92        }
93    }
94}
95
96#[derive(Debug, Clone, PartialEq, thiserror::Error)]
97pub enum DepsError {
98    #[error("node(\"...\") dependency cycle: {}", .chain.join(" -> "))]
99    Cycle { chain: Vec<String> },
100    #[error(
101        "node \"{referencing}\" references unknown id \"{target}\" via node(\"{target}\", ...)"
102    )]
103    UnknownId { referencing: String, target: String },
104    #[error(
105        "node \"{referencing}\" references \"{target}\" from another scene \
106         (reference_cross_scene: scenes are independent render units)"
107    )]
108    CrossScene { referencing: String, target: String },
109    #[error("duplicate node id \"{0}\" declared more than once in the same scene")]
110    DuplicateId(String),
111}
112
113#[derive(Debug, Clone, Default, PartialEq, Eq)]
114pub struct DepGraph {
115    order: Vec<String>,
116}
117
118impl DepGraph {
119    pub fn order(&self) -> &[String] {
120        &self.order
121    }
122
123    pub fn build(
124        nodes: &[(String, Vec<NodeRef>)],
125        other_scene_ids: &HashSet<String>,
126    ) -> Result<DepGraph, DepsError> {
127        let mut known: HashSet<&str> = HashSet::with_capacity(nodes.len());
128        for (id, _) in nodes {
129            if !known.insert(id.as_str()) {
130                return Err(DepsError::DuplicateId(id.clone()));
131            }
132        }
133
134        let mut dependents: HashMap<&str, Vec<&str>> = HashMap::new();
135        let mut indegree: HashMap<&str, usize> =
136            nodes.iter().map(|(id, _)| (id.as_str(), 0)).collect();
137
138        for (id, refs) in nodes {
139            for r in refs {
140                if !known.contains(r.id.as_str()) {
141                    if other_scene_ids.contains(&r.id) {
142                        return Err(DepsError::CrossScene {
143                            referencing: id.clone(),
144                            target: r.id.clone(),
145                        });
146                    }
147                    return Err(DepsError::UnknownId {
148                        referencing: id.clone(),
149                        target: r.id.clone(),
150                    });
151                }
152                dependents
153                    .entry(r.id.as_str())
154                    .or_default()
155                    .push(id.as_str());
156                *indegree.get_mut(id.as_str()).expect("id is known") += 1;
157            }
158        }
159
160        let mut queue: VecDeque<&str> = nodes
161            .iter()
162            .map(|(id, _)| id.as_str())
163            .filter(|id| indegree[id] == 0)
164            .collect();
165        let mut order: Vec<String> = Vec::with_capacity(nodes.len());
166        while let Some(id) = queue.pop_front() {
167            order.push(id.to_string());
168            if let Some(deps) = dependents.get(id) {
169                for &dep in deps {
170                    let e = indegree.get_mut(dep).expect("dependent is known");
171                    *e -= 1;
172                    if *e == 0 {
173                        queue.push_back(dep);
174                    }
175                }
176            }
177        }
178
179        if order.len() != nodes.len() {
180            let resolved: HashSet<&str> = order.iter().map(|s| s.as_str()).collect();
181            let residual: Vec<&str> = nodes
182                .iter()
183                .map(|(id, _)| id.as_str())
184                .filter(|id| !resolved.contains(id))
185                .collect();
186            return Err(DepsError::Cycle {
187                chain: find_cycle_chain(&residual, nodes),
188            });
189        }
190
191        Ok(DepGraph { order })
192    }
193}
194
195fn find_cycle_chain(residual: &[&str], nodes: &[(String, Vec<NodeRef>)]) -> Vec<String> {
196    let deps: HashMap<&str, Vec<&str>> = nodes
197        .iter()
198        .map(|(id, refs)| (id.as_str(), refs.iter().map(|r| r.id.as_str()).collect()))
199        .collect();
200    let residual_set: HashSet<&str> = residual.iter().copied().collect();
201
202    let start = residual[0];
203    let mut path: Vec<&str> = vec![start];
204    let mut current = start;
205    loop {
206        let next = deps
207            .get(current)
208            .into_iter()
209            .flatten()
210            .find(|n| residual_set.contains(*n))
211            .copied();
212        match next {
213            Some(n) => {
214                if let Some(start_idx) = path.iter().position(|&x| x == n) {
215                    let mut chain: Vec<String> =
216                        path[start_idx..].iter().map(|s| s.to_string()).collect();
217                    chain.push(n.to_string());
218                    return chain;
219                }
220                path.push(n);
221                current = n;
222            }
223            None => {
224                return path.iter().map(|s| s.to_string()).collect();
225            }
226        }
227    }
228}
229
230#[derive(Debug, Clone, Default, PartialEq)]
231pub struct TextMetrics {
232    pub text_width: f32,
233    pub cap_height: f32,
234    pub ascender: f32,
235    pub baseline: f32,
236    pub glyphs: Vec<GlyphMetric>,
237}
238
239pub trait TextMetricsProvider {
240    fn text_metrics(
241        &self,
242        payload: &(dyn std::any::Any + Send + Sync),
243        content_box_width: f32,
244    ) -> Option<TextMetrics>;
245}
246
247pub struct NoTextMetrics;
248
249impl TextMetricsProvider for NoTextMetrics {
250    fn text_metrics(
251        &self,
252        _payload: &(dyn std::any::Any + Send + Sync),
253        _content_box_width: f32,
254    ) -> Option<TextMetrics> {
255        None
256    }
257}
258
259#[derive(Debug, Clone, Copy, Default)]
260struct Transform {
261    tx: f32,
262    ty: f32,
263    scale: f32,
264    rotation: f32,
265    opacity: f32,
266}
267
268#[derive(Debug, Clone)]
269pub struct ResolvedNode {
270    layout: BoxLayout,
271    transform: Transform,
272    text: Option<TextMetrics>,
273}
274
275impl ResolvedNode {
276    pub fn new(
277        layout: BoxLayout,
278        transform: (f32, f32, f32, f32, f32),
279        text: Option<TextMetrics>,
280    ) -> Self {
281        let (tx, ty, scale, rotation, opacity) = transform;
282        Self {
283            layout,
284            transform: Transform {
285                tx,
286                ty,
287                scale,
288                rotation,
289                opacity,
290            },
291            text,
292        }
293    }
294
295    pub fn prop(&self, prop: &str) -> Option<f64> {
296        match prop {
297            "x" => Some(self.layout.x as f64),
298            "y" => Some(self.layout.y as f64),
299            "width" => Some(self.layout.width as f64),
300            "height" => Some(self.layout.height as f64),
301            "cx" => Some(self.layout.cx() as f64),
302            "cy" => Some(self.layout.cy() as f64),
303            "right" => Some(self.layout.right() as f64),
304            "bottom" => Some(self.layout.bottom() as f64),
305            "tx" => Some(self.transform.tx as f64),
306            "ty" => Some(self.transform.ty as f64),
307            "scale" => Some(self.transform.scale as f64),
308            "rotation" => Some(self.transform.rotation as f64),
309            "opacity" => Some(self.transform.opacity as f64),
310            "textWidth" => self.text.as_ref().map(|t| t.text_width as f64),
311            "capHeight" => self.text.as_ref().map(|t| t.cap_height as f64),
312            "ascender" => self.text.as_ref().map(|t| t.ascender as f64),
313            "baseline" => self.text.as_ref().map(|t| t.baseline as f64),
314            "glyph_count" => self.text.as_ref().map(|t| t.glyphs.len() as f64),
315            _ => glyph_prop(self.text.as_ref(), prop),
316        }
317    }
318}
319
320fn glyph_prop(text: Option<&TextMetrics>, prop: &str) -> Option<f64> {
321    let text = text?;
322    if let Some(n) = prop.strip_prefix("glyph_x:") {
323        let idx: usize = n.parse().ok()?;
324        return text.glyphs.get(idx).map(|g| g.x as f64);
325    }
326    if let Some(n) = prop.strip_prefix("glyph_cx:") {
327        let idx: usize = n.parse().ok()?;
328        return text.glyphs.get(idx).map(|g| (g.x + g.width / 2.0) as f64);
329    }
330    None
331}
332
333pub fn snapshot_node(
334    node: &BoxNode,
335    layout: &LayoutResult,
336    viewport: (f32, f32),
337    text_provider: &dyn TextMetricsProvider,
338) -> Option<ResolvedNode> {
339    let box_layout = *layout.get(node.id)?;
340    let transform = animated_transform(&node.css, &box_layout, viewport);
341    let text = match &node.kind {
342        BoxKind::Component(payload) | BoxKind::Ghost(payload) => {
343            let (_, _, content_w, _) = box_layout.content_box();
344            text_provider.text_metrics(payload.as_ref(), content_w)
345        }
346        BoxKind::Container => None,
347    };
348    Some(ResolvedNode::new(box_layout, transform, text))
349}
350
351#[derive(Debug, Clone, Default)]
352pub struct ResolvedFrame {
353    nodes: HashMap<String, ResolvedNode>,
354}
355
356impl ResolvedFrame {
357    pub fn new() -> Self {
358        Self::default()
359    }
360
361    pub fn insert(&mut self, id: impl Into<String>, node: ResolvedNode) {
362        self.nodes.insert(id.into(), node);
363    }
364
365    pub fn node_prop(&self, id: &str, prop: &str) -> Option<f64> {
366        self.nodes.get(id)?.prop(prop)
367    }
368}
369
370pub struct FrameScope<'a>(pub &'a ResolvedFrame);
371
372impl crate::expr::Scope for FrameScope<'_> {
373    fn var(&self, _name: &str) -> Option<f64> {
374        None
375    }
376
377    fn node_prop(&self, id: &str, prop: &str) -> Option<f64> {
378        self.0.node_prop(id, prop)
379    }
380}
381
382#[cfg(test)]
383mod scan_tests {
384    use super::*;
385
386    #[test]
387    fn finds_a_single_reference() {
388        let refs = scan_node_refs(r#"= node("badge_3", "tx") + 1"#);
389        assert_eq!(
390            refs,
391            vec![NodeRef {
392                id: "badge_3".into(),
393                prop: "tx".into()
394            }]
395        );
396    }
397
398    #[test]
399    fn finds_multiple_references() {
400        let refs = scan_node_refs(r#"= node("a", "cx") - node("b", "cy")"#);
401        assert_eq!(
402            refs,
403            vec![
404                NodeRef {
405                    id: "a".into(),
406                    prop: "cx".into()
407                },
408                NodeRef {
409                    id: "b".into(),
410                    prop: "cy".into()
411                },
412            ]
413        );
414    }
415
416    #[test]
417    fn tolerates_whitespace_variations() {
418        let refs = scan_node_refs(r#"=node(  "a" ,"tx"  )"#);
419        assert_eq!(
420            refs,
421            vec![NodeRef {
422                id: "a".into(),
423                prop: "tx".into()
424            }]
425        );
426    }
427
428    #[test]
429    fn handles_escaped_quotes_in_the_id() {
430        let refs = scan_node_refs(r#"= node("a\"b", "x")"#);
431        assert_eq!(
432            refs,
433            vec![NodeRef {
434                id: "a\"b".into(),
435                prop: "x".into()
436            }]
437        );
438    }
439
440    #[test]
441    fn does_not_match_node_as_a_substring_of_a_longer_identifier() {
442        assert!(scan_node_refs(r#"= anode("a", "x") + nodeFoo("b", "y")"#).is_empty());
443    }
444
445    #[test]
446    fn no_reference_in_plain_arithmetic() {
447        assert!(scan_node_refs("= $W / 2 + cos($i)").is_empty());
448    }
449}
450
451#[cfg(test)]
452mod graph_tests {
453    use super::*;
454
455    fn nodes(pairs: &[(&str, &[(&str, &str)])]) -> Vec<(String, Vec<NodeRef>)> {
456        pairs
457            .iter()
458            .map(|(id, refs)| {
459                (
460                    id.to_string(),
461                    refs.iter()
462                        .map(|(rid, prop)| NodeRef {
463                            id: rid.to_string(),
464                            prop: prop.to_string(),
465                        })
466                        .collect(),
467                )
468            })
469            .collect()
470    }
471
472    #[test]
473    fn independent_nodes_keep_declaration_order() {
474        let n = nodes(&[("a", &[]), ("b", &[]), ("c", &[])]);
475        let g = DepGraph::build(&n, &HashSet::new()).unwrap();
476        assert_eq!(g.order(), &["a", "b", "c"]);
477    }
478
479    #[test]
480    fn a_dependent_resolves_after_its_dependency_even_when_declared_first() {
481        let n = nodes(&[("line", &[("badge", "tx")]), ("badge", &[])]);
482        let g = DepGraph::build(&n, &HashSet::new()).unwrap();
483        let pos = |id: &str| g.order().iter().position(|x| x == id).unwrap();
484        assert!(pos("badge") < pos("line"));
485    }
486
487    #[test]
488    fn chain_of_three_resolves_in_dependency_order() {
489        let n = nodes(&[("c", &[("b", "x")]), ("b", &[("a", "x")]), ("a", &[])]);
490        let g = DepGraph::build(&n, &HashSet::new()).unwrap();
491        assert_eq!(g.order(), &["a", "b", "c"]);
492    }
493
494    #[test]
495    fn direct_two_node_cycle_is_reported_with_both_names() {
496        let n = nodes(&[("a", &[("b", "x")]), ("b", &[("a", "x")])]);
497        let err = DepGraph::build(&n, &HashSet::new()).unwrap_err();
498        match err {
499            DepsError::Cycle { chain } => {
500                assert!(chain.contains(&"a".to_string()));
501                assert!(chain.contains(&"b".to_string()));
502                assert_eq!(chain.first(), chain.last());
503            }
504            other => panic!("expected Cycle, got {other:?}"),
505        }
506    }
507
508    #[test]
509    fn three_node_cycle_is_reported() {
510        let n = nodes(&[
511            ("a", &[("b", "x")]),
512            ("b", &[("c", "x")]),
513            ("c", &[("a", "x")]),
514        ]);
515        let err = DepGraph::build(&n, &HashSet::new()).unwrap_err();
516        match err {
517            DepsError::Cycle { chain } => {
518                for id in ["a", "b", "c"] {
519                    assert!(
520                        chain.contains(&id.to_string()),
521                        "chain missing {id}: {chain:?}"
522                    );
523                }
524            }
525            other => panic!("expected Cycle, got {other:?}"),
526        }
527    }
528
529    #[test]
530    fn self_reference_is_a_one_node_cycle() {
531        let n = nodes(&[("a", &[("a", "x")])]);
532        let err = DepGraph::build(&n, &HashSet::new()).unwrap_err();
533        assert!(matches!(err, DepsError::Cycle { .. }));
534    }
535
536    #[test]
537    fn duplicate_id_in_the_same_scene_is_rejected() {
538        let n = nodes(&[("dup", &[]), ("other", &[]), ("dup", &[])]);
539        let err = DepGraph::build(&n, &HashSet::new()).unwrap_err();
540        match err {
541            DepsError::DuplicateId(id) => assert_eq!(id, "dup"),
542            other => panic!("expected DuplicateId, got {other:?}"),
543        }
544    }
545
546    #[test]
547    fn reference_to_an_id_declared_nowhere_is_unknown() {
548        let n = nodes(&[("a", &[("ghost", "x")])]);
549        let err = DepGraph::build(&n, &HashSet::new()).unwrap_err();
550        match err {
551            DepsError::UnknownId {
552                referencing,
553                target,
554            } => {
555                assert_eq!(referencing, "a");
556                assert_eq!(target, "ghost");
557            }
558            other => panic!("expected UnknownId, got {other:?}"),
559        }
560    }
561
562    #[test]
563    fn reference_to_an_id_from_another_scene_is_cross_scene() {
564        let n = nodes(&[("a", &[("other_scene_node", "x")])]);
565        let mut other = HashSet::new();
566        other.insert("other_scene_node".to_string());
567        let err = DepGraph::build(&n, &other).unwrap_err();
568        match err {
569            DepsError::CrossScene {
570                referencing,
571                target,
572            } => {
573                assert_eq!(referencing, "a");
574                assert_eq!(target, "other_scene_node");
575            }
576            other => panic!("expected CrossScene, got {other:?}"),
577        }
578    }
579}
580
581#[cfg(test)]
582mod resolution_tests {
583    use super::*;
584    use crate::expr::Expr;
585
586    fn layout(x: f32, y: f32, w: f32, h: f32) -> BoxLayout {
587        BoxLayout {
588            x,
589            y,
590            width: w,
591            height: h,
592            ..Default::default()
593        }
594    }
595
596    #[test]
597    fn geometry_family_reads_from_layout() {
598        let mut frame = ResolvedFrame::new();
599        frame.insert(
600            "box",
601            ResolvedNode::new(
602                layout(10.0, 20.0, 100.0, 50.0),
603                (0.0, 0.0, 1.0, 0.0, 1.0),
604                None,
605            ),
606        );
607        assert_eq!(frame.node_prop("box", "x"), Some(10.0));
608        assert_eq!(frame.node_prop("box", "y"), Some(20.0));
609        assert_eq!(frame.node_prop("box", "width"), Some(100.0));
610        assert_eq!(frame.node_prop("box", "height"), Some(50.0));
611        assert_eq!(frame.node_prop("box", "cx"), Some(60.0));
612        assert_eq!(frame.node_prop("box", "cy"), Some(45.0));
613        assert_eq!(frame.node_prop("box", "right"), Some(110.0));
614        assert_eq!(frame.node_prop("box", "bottom"), Some(70.0));
615    }
616
617    #[test]
618    fn animated_transform_family_reads_through() {
619        let mut frame = ResolvedFrame::new();
620        frame.insert(
621            "chip",
622            ResolvedNode::new(
623                layout(0.0, 0.0, 10.0, 10.0),
624                (12.5, -3.0, 1.5, 45.0, 0.8),
625                None,
626            ),
627        );
628        assert_eq!(frame.node_prop("chip", "tx"), Some(12.5));
629        assert_eq!(frame.node_prop("chip", "ty"), Some(-3.0));
630        assert_eq!(frame.node_prop("chip", "scale"), Some(1.5));
631        assert_eq!(frame.node_prop("chip", "rotation"), Some(45.0));
632        assert_eq!(frame.node_prop("chip", "opacity"), Some(0.8_f32 as f64));
633    }
634
635    #[test]
636    fn text_and_glyph_families_are_none_without_text_metrics() {
637        let mut frame = ResolvedFrame::new();
638        frame.insert(
639            "box",
640            ResolvedNode::new(
641                layout(0.0, 0.0, 10.0, 10.0),
642                (0.0, 0.0, 1.0, 0.0, 1.0),
643                None,
644            ),
645        );
646        assert_eq!(frame.node_prop("box", "textWidth"), None);
647        assert_eq!(frame.node_prop("box", "glyph_count"), None);
648        assert_eq!(frame.node_prop("box", "glyph_x:0"), None);
649    }
650
651    #[test]
652    fn text_and_glyph_families_read_through() {
653        let text = TextMetrics {
654            text_width: 88.0,
655            cap_height: 14.0,
656            ascender: 18.0,
657            baseline: 22.0,
658            glyphs: vec![
659                GlyphMetric {
660                    x: 0.0,
661                    width: 10.0,
662                },
663                GlyphMetric {
664                    x: 10.0,
665                    width: 12.0,
666                },
667            ],
668        };
669        let mut frame = ResolvedFrame::new();
670        frame.insert(
671            "sentence",
672            ResolvedNode::new(
673                layout(0.0, 0.0, 100.0, 30.0),
674                (0.0, 0.0, 1.0, 0.0, 1.0),
675                Some(text),
676            ),
677        );
678        assert_eq!(frame.node_prop("sentence", "textWidth"), Some(88.0));
679        assert_eq!(frame.node_prop("sentence", "capHeight"), Some(14.0));
680        assert_eq!(frame.node_prop("sentence", "ascender"), Some(18.0));
681        assert_eq!(frame.node_prop("sentence", "baseline"), Some(22.0));
682        assert_eq!(frame.node_prop("sentence", "glyph_count"), Some(2.0));
683        assert_eq!(frame.node_prop("sentence", "glyph_x:1"), Some(10.0));
684        assert_eq!(frame.node_prop("sentence", "glyph_cx:1"), Some(16.0));
685        assert_eq!(frame.node_prop("sentence", "glyph_x:5"), None);
686    }
687
688    #[test]
689    fn unresolved_reference_surfaces_as_unknown_ident_through_expr() {
690        let frame = ResolvedFrame::new();
691        let scope = FrameScope(&frame);
692        let expr = Expr::parse(r#"= node("missing", "x")"#).unwrap();
693        let err = expr.eval(&scope).unwrap_err();
694        assert!(matches!(err, crate::expr::ExprError::UnknownIdent(_)));
695    }
696
697    #[test]
698    fn expression_over_a_resolved_node_matches_hand_computed_value() {
699        let mut frame = ResolvedFrame::new();
700        frame.insert(
701            "chip3",
702            ResolvedNode::new(
703                layout(500.0, 300.0, 40.0, 40.0),
704                (77.0, -12.0, 1.0, 0.0, 1.0),
705                None,
706            ),
707        );
708        let scope = FrameScope(&frame);
709        let expr = Expr::parse(r#"= node("chip3", "cx") + node("chip3", "tx")"#).unwrap();
710        let got = expr.eval(&scope).unwrap();
711        assert_eq!(got, 520.0 + 77.0);
712    }
713
714    #[test]
715    fn orbiting_node_reference_tracks_every_frame_with_no_one_frame_lag() {
716        let n = vec![
717            (
718                "line".to_string(),
719                vec![
720                    NodeRef {
721                        id: "badge".to_string(),
722                        prop: "tx".to_string(),
723                    },
724                    NodeRef {
725                        id: "badge".to_string(),
726                        prop: "ty".to_string(),
727                    },
728                ],
729            ),
730            ("badge".to_string(), vec![]),
731        ];
732        let graph = DepGraph::build(&n, &HashSet::new()).unwrap();
733        assert_eq!(graph.order(), &["badge", "line"]);
734
735        let orbit_transform = |angle_deg: f32| -> (f32, f32, f32, f32, f32) {
736            let (s, c) = angle_deg.to_radians().sin_cos();
737            (c * 200.0, s * 200.0, 1.0, 0.0, 1.0)
738        };
739
740        for angle in [0.0f32, 37.0, 90.0, 181.0, 269.5, 350.0] {
741            let mut frame = ResolvedFrame::new();
742            for id in graph.order() {
743                match id.as_str() {
744                    "badge" => {
745                        frame.insert(
746                            "badge",
747                            ResolvedNode::new(
748                                layout(0.0, 0.0, 20.0, 20.0),
749                                orbit_transform(angle),
750                                None,
751                            ),
752                        );
753                    }
754                    "line" => {
755                        let scope = FrameScope(&frame);
756                        let x2 = Expr::parse(r#"= node("badge", "tx")"#)
757                            .unwrap()
758                            .eval(&scope)
759                            .unwrap();
760                        let y2 = Expr::parse(r#"= node("badge", "ty")"#)
761                            .unwrap()
762                            .eval(&scope)
763                            .unwrap();
764                        frame.insert(
765                            "line",
766                            ResolvedNode::new(
767                                layout(x2 as f32, y2 as f32, 0.0, 0.0),
768                                (0.0, 0.0, 1.0, 0.0, 1.0),
769                                None,
770                            ),
771                        );
772                    }
773                    other => panic!("unexpected id {other}"),
774                }
775            }
776
777            let (expected_tx, expected_ty, ..) = orbit_transform(angle);
778            let line = &frame.nodes["line"];
779            assert_eq!(
780                line.layout.x, expected_tx,
781                "angle {angle}: line.x2 lagged behind badge.tx"
782            );
783            assert_eq!(
784                line.layout.y, expected_ty,
785                "angle {angle}: line.y2 lagged behind badge.ty"
786            );
787        }
788    }
789
790    #[test]
791    fn resolving_in_declaration_order_instead_of_topological_order_fails_loudly() {
792        let mut frame = ResolvedFrame::new();
793        let scope = FrameScope(&frame);
794        let err = Expr::parse(r#"= node("badge", "tx")"#)
795            .unwrap()
796            .eval(&scope)
797            .unwrap_err();
798        assert!(matches!(err, crate::expr::ExprError::UnknownIdent(_)));
799
800        frame.insert(
801            "badge",
802            ResolvedNode::new(
803                layout(0.0, 0.0, 20.0, 20.0),
804                (99.0, 0.0, 1.0, 0.0, 1.0),
805                None,
806            ),
807        );
808        let scope = FrameScope(&frame);
809        let got = Expr::parse(r#"= node("badge", "tx")"#)
810            .unwrap()
811            .eval(&scope)
812            .unwrap();
813        assert_eq!(got, 99.0);
814    }
815}