Skip to main content

oxframe_2d/
graph.rs

1use std::cell::{Cell, Ref, RefCell};
2use std::rc::Rc;
3
4use kurbo::{Affine, BezPath, Point, Shape, Vec2};
5use oxframe_core::{BlendMode, Color, Gradient, Signal};
6use oxframe_text::{Lang, TextStyle};
7
8use crate::text::{CodeTheme, DiffFrame, RichSpan, Styled};
9
10use crate::draw::{DrawItem, Layer, LineCap, LineJoin, Primitive, Shadow};
11use crate::flex::{FlexBox, FlexMap};
12use crate::image::ImageSource;
13
14/// Index of a node in its [`SceneGraph`]. Assigned in creation order, so it
15/// is deterministic too.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
17pub struct NodeId(
18    /// Position in creation order, starting at 0 for the root view.
19    pub u32,
20);
21
22/// Main axis of a [`Layout`](crate::Layout).
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum Direction {
25    /// Left to right.
26    #[default]
27    Row,
28    /// Top to bottom.
29    Column,
30}
31
32/// Cross-axis alignment of children in a [`Layout`](crate::Layout).
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34pub enum LayoutAlign {
35    /// Top (in a row) or left (in a column).
36    Start,
37    /// Centered.
38    #[default]
39    Center,
40    /// Bottom (in a row) or right (in a column).
41    End,
42    /// Fill the cross axis (resizes rects, images and nested layouts).
43    Stretch,
44}
45
46/// Main-axis distribution of children; only matters with a fixed size.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum Justify {
49    /// Packed at the start.
50    Start,
51    /// Packed in the middle.
52    #[default]
53    Center,
54    /// Packed at the end.
55    End,
56    /// First and last at the edges, equal space between.
57    SpaceBetween,
58    /// Equal space around each child (half-size at the edges).
59    SpaceAround,
60    /// Equal space between children and at the edges.
61    SpaceEvenly,
62}
63
64/// Node type and its type-specific properties (internal representation).
65#[doc(hidden)]
66pub enum NodeKind {
67    Group,
68    /// Looks at `x`/`y` with `zoom` and `rotation`; its children are drawn
69    /// through it (see [`NodeData::local_transform`]).
70    Camera {
71        zoom: Signal<f64>,
72    },
73    Rect {
74        width: Signal<f64>,
75        height: Signal<f64>,
76        radius: Signal<f64>,
77    },
78    Circle {
79        size: Signal<f64>,
80        /// Degrees clockwise from 12 o'clock; a full turn draws a circle.
81        start_angle: Signal<f64>,
82        end_angle: Signal<f64>,
83        /// Close an arc through the center (pie slice).
84        pie: bool,
85    },
86    Polygon {
87        sides: Signal<f64>,
88        size: Signal<f64>,
89    },
90    Star {
91        points: Signal<f64>,
92        size: Signal<f64>,
93        /// Inner radius as a fraction of the outer one.
94        inner: Signal<f64>,
95    },
96    Path {
97        data: Signal<BezPath>,
98    },
99    Line {
100        points: Signal<Vec<Vec2>>,
101        closed: bool,
102        /// Smooth curve through the points (Catmull-Rom).
103        smooth: bool,
104        start_arrow: bool,
105        end_arrow: bool,
106        arrow_size: Signal<f64>,
107    },
108    Txt {
109        text: Signal<String>,
110        /// When non-empty, replaces `text` with styled runs.
111        spans: Signal<Vec<RichSpan>>,
112        font_size: Signal<f64>,
113        weight: Signal<f64>,
114        style: TextStyle,
115        /// Active glyph morph: (from, to) and progress 0..1.
116        morph: Signal<Option<(String, String)>>,
117        morph_t: Signal<f64>,
118    },
119    Code {
120        code: Signal<String>,
121        /// Source a running diff started from. Ignored unless `editing` is set.
122        edit_from: Signal<String>,
123        /// Source a running diff is going to.
124        edit_to: Signal<String>,
125        /// A diff tween is in progress.
126        editing: Signal<bool>,
127        /// Progress of the diff, 0 = `edit_from`, 1 = `edit_to`.
128        edit_t: Signal<f64>,
129        /// Flash a background behind text the diff inserts.
130        edit_flash: Signal<bool>,
131        lang: Lang,
132        theme: CodeTheme,
133        font_size: Signal<f64>,
134        style: TextStyle,
135        /// Focused lines (1-based, inclusive); (0, 0) focuses all.
136        focus: Signal<Vec2>,
137        /// Alpha of unfocused lines.
138        dim: Signal<f64>,
139    },
140    Img {
141        source: ImageSource,
142        width: Signal<f64>,
143        height: Signal<f64>,
144    },
145    Layout {
146        direction: Direction,
147        align: LayoutAlign,
148        justify: Justify,
149        /// Wrap children onto several lines.
150        wrap: bool,
151        /// CSS grid with this many equal columns instead of flexbox.
152        columns: Option<u16>,
153        gap: Signal<f64>,
154        padding: Signal<f64>,
155        width: Signal<Option<f64>>,
156        height: Signal<Option<f64>>,
157        radius: Signal<f64>,
158    },
159    Grid {
160        width: Signal<f64>,
161        height: Signal<f64>,
162        spacing: Signal<f64>,
163    },
164    /// An imported SVG. Draws nothing itself; its children are the file's
165    /// groups, paths and images. `width` and `height` are the fitted size.
166    Svg {
167        width: Signal<f64>,
168        height: Signal<f64>,
169    },
170    /// Geometry drawn by a closure on every paint. `width` and `height` are
171    /// the layout box; the closure's shapes are not measured.
172    Custom {
173        draw: crate::draw::DrawFn,
174        width: f64,
175        height: f64,
176    },
177    /// Particles integrated from bursts. The box is a point; the dots are
178    /// drawn in the node's coordinates.
179    Particles {
180        seed: u64,
181        gravity: Signal<f64>,
182        toward_camera: bool,
183        life: f64,
184        speed: f64,
185        radius: f64,
186        state: crate::sim::ParticlesState,
187    },
188    /// Pixels from a WGSL `shade` function. No children in this version.
189    /// The waves preset is rasterized here; other shaders are type-checked.
190    Shader {
191        code: crate::shader::ShaderCode,
192        /// Runs the waves formula. A custom `shade` is checked, not executed.
193        waves: bool,
194        width: Signal<f64>,
195        height: Signal<f64>,
196        uniforms: Vec<crate::shader::Uniform>,
197    },
198}
199
200impl NodeKind {
201    pub fn name(&self) -> &'static str {
202        match self {
203            Self::Group => "Group",
204            Self::Camera { .. } => "Camera",
205            Self::Rect { .. } => "Rect",
206            Self::Circle { .. } => "Circle",
207            Self::Polygon { .. } => "Polygon",
208            Self::Star { .. } => "Star",
209            Self::Path { .. } => "Path",
210            Self::Line { .. } => "Line",
211            Self::Txt { .. } => "Txt",
212            Self::Code { .. } => "Code",
213            Self::Img { .. } => "Img",
214            Self::Layout { .. } => "Layout",
215            Self::Grid { .. } => "Grid",
216            Self::Svg { .. } => "Svg",
217            Self::Custom { .. } => "Custom",
218            Self::Particles { .. } => "Particles",
219            Self::Shader { .. } => "Shader",
220        }
221    }
222}
223
224/// Stored state of one node (internal representation).
225#[doc(hidden)]
226pub struct NodeData {
227    pub id: NodeId,
228    pub name: Option<String>,
229    /// `file:line` of the `add` call that created the node, when known.
230    pub source: Option<String>,
231    pub parent: Option<NodeId>,
232    pub children: Vec<NodeId>,
233    pub kind: NodeKind,
234    pub x: Signal<f64>,
235    pub y: Signal<f64>,
236    /// Degrees, clockwise (y points down).
237    pub rotation: Signal<f64>,
238    pub scale: Signal<Vec2>,
239    pub opacity: Signal<f64>,
240    pub fill: Signal<Color>,
241    pub stroke: Signal<Color>,
242    pub line_width: Signal<f64>,
243    /// Drawn part of the outline, as fractions of its length.
244    pub start: Signal<f64>,
245    pub end: Signal<f64>,
246    /// Overrides `fill` when set (local coordinates).
247    pub fill_gradient: Signal<Option<Gradient>>,
248    pub stroke_gradient: Signal<Option<Gradient>>,
249    /// Dash pattern (on, off, on, …) in local units; empty = solid.
250    pub dash: Signal<Vec<f64>>,
251    pub dash_offset: Signal<f64>,
252    pub line_cap: Signal<LineCap>,
253    pub line_join: Signal<LineJoin>,
254    /// Gaussian blur of the node and its children (local units).
255    pub blur: Signal<f64>,
256    pub shadow_color: Signal<Color>,
257    pub shadow_blur: Signal<f64>,
258    pub shadow_offset: Signal<Vec2>,
259    /// Children are clipped to this node's outline.
260    pub clip: Signal<bool>,
261    pub blend: Signal<BlendMode>,
262    /// Paint order among siblings (higher on top; ties keep insertion order).
263    pub z_index: Signal<f64>,
264    /// Which point of the node's box sits at (x, y) and is the pivot of
265    /// rotation and scale: (-1, -1) top-left, (0, 0) center, (1, 1)
266    /// bottom-right.
267    pub anchor: Signal<Vec2>,
268    // Flex item properties (used when the parent is a `Layout`).
269    pub grow: Signal<f64>,
270    pub shrink: Signal<f64>,
271    pub basis: Signal<Option<f64>>,
272    pub margin: Signal<f64>,
273    pub align_self: Signal<Option<LayoutAlign>>,
274    /// Recomputed every frame. `None` means `x` and `y` are used as they are.
275    pub place: Option<crate::place::Place>,
276    /// Position after [`place`](Self::place) is resolved. Draw uses this.
277    pub placed: Cell<(f64, f64)>,
278}
279
280impl NodeData {
281    /// Transform relative to the parent, plus a layout-assigned offset.
282    /// `size` is the node's box, needed for the anchor.
283    pub fn local_transform(&self, offset: Vec2, size: (f64, f64)) -> Affine {
284        if let NodeKind::Camera { zoom } = &self.kind {
285            // The view through the camera: the point it looks at comes to
286            // the camera's origin, scaled by zoom and turned against its
287            // rotation.
288            let (x, y) = self.placed.get();
289            return Affine::translate(offset)
290                * Affine::rotate(-self.rotation.get().to_radians())
291                * Affine::scale(zoom.get().max(1e-6))
292                * Affine::translate((-x, -y));
293        }
294        let s = self.scale.get();
295        let a = self.anchor.get();
296        let (x, y) = self.placed.get();
297        Affine::translate(offset + Vec2::new(x, y))
298            * Affine::rotate(self.rotation.get().to_radians())
299            * Affine::scale_non_uniform(s.x, s.y)
300            * Affine::translate((-a.x * size.0 / 2.0, -a.y * size.1 / 2.0))
301    }
302
303    /// Text to draw for `Txt` and `Code` nodes.
304    pub(crate) fn text_draw(&self) -> Option<TextDraw> {
305        match &self.kind {
306            NodeKind::Txt {
307                text,
308                spans,
309                font_size,
310                weight,
311                style,
312                morph,
313                morph_t,
314            } => {
315                let style = TextStyle {
316                    size: font_size.get().max(0.1),
317                    weight: weight.get().clamp(1.0, 1000.0),
318                    ..style.clone()
319                };
320                if let Some((from, to)) = morph.get() {
321                    let a = oxframe_text::layout(&from, &style);
322                    let b = oxframe_text::layout(&to, &style);
323                    let (glyphs, size) = crate::text::morph(&a, &b, morph_t.get().clamp(0.0, 1.0));
324                    return Some(TextDraw::Morph { glyphs, size });
325                }
326                let spans = spans.get();
327                Some(TextDraw::Styled(if spans.is_empty() {
328                    crate::text::rich(&[RichSpan::new(text.get())], &style)
329                } else {
330                    crate::text::rich(&spans, &style)
331                }))
332            }
333            NodeKind::Code {
334                code,
335                edit_from,
336                edit_to,
337                editing,
338                edit_t,
339                edit_flash,
340                lang,
341                theme,
342                font_size,
343                style,
344                focus,
345                dim,
346            } => {
347                let style = TextStyle {
348                    size: font_size.get().max(0.1),
349                    ..style.clone()
350                };
351                if editing.get() {
352                    let (frame, _) = crate::text::layout_diff(
353                        &edit_from.get(),
354                        &edit_to.get(),
355                        *lang,
356                        crate::text::DiffView {
357                            theme,
358                            style: &style,
359                            focus: focus.get(),
360                            dim: dim.get(),
361                            t: edit_t.get(),
362                            flash: edit_flash.get(),
363                        },
364                    );
365                    return Some(TextDraw::Diff(frame));
366                }
367                Some(TextDraw::Styled(crate::text::code(
368                    &code.get(),
369                    *lang,
370                    theme,
371                    &style,
372                    focus.get(),
373                    dim.get(),
374                )))
375            }
376            _ => None,
377        }
378    }
379
380    /// Untrimmed outline in local coordinates, centered on the origin.
381    pub fn outline(&self, size: (f64, f64)) -> Option<BezPath> {
382        const TOLERANCE: f64 = 0.1;
383        match &self.kind {
384            NodeKind::Group
385            | NodeKind::Svg { .. }
386            | NodeKind::Camera { .. }
387            | NodeKind::Img { .. }
388            | NodeKind::Txt { .. }
389            | NodeKind::Code { .. }
390            | NodeKind::Grid { .. }
391            | NodeKind::Custom { .. }
392            | NodeKind::Particles { .. }
393            | NodeKind::Shader { .. } => None,
394            NodeKind::Rect { radius, .. } | NodeKind::Layout { radius, .. } => {
395                let (w, h) = (size.0.max(0.0), size.1.max(0.0));
396                let r = radius.get().clamp(0.0, w.min(h) / 2.0);
397                let rect = kurbo::Rect::new(-w / 2.0, -h / 2.0, w / 2.0, h / 2.0);
398                Some(if r > 0.0 {
399                    rect.to_rounded_rect(r).to_path(TOLERANCE)
400                } else {
401                    rect.to_path(TOLERANCE)
402                })
403            }
404            NodeKind::Circle {
405                start_angle,
406                end_angle,
407                pie,
408                ..
409            } => {
410                // Start at the top, clockwise — like a clock hand, which is
411                // what `end` animations expect.
412                let r = size.0.max(0.0) / 2.0;
413                let (a0, a1) = (start_angle.get(), end_angle.get());
414                let sweep = (a1 - a0).clamp(-360.0, 360.0);
415                if sweep.abs() >= 360.0 - 1e-9 {
416                    let circle = kurbo::Circle::new((0.0, 0.0), r).to_path(TOLERANCE);
417                    return Some(Affine::rotate((a0 - 90.0).to_radians()) * circle);
418                }
419                let arc = kurbo::Arc::new(
420                    (0.0, 0.0),
421                    (r, r),
422                    (a0 - 90.0).to_radians(),
423                    sweep.to_radians(),
424                    0.0,
425                );
426                let mut p = BezPath::new();
427                if *pie {
428                    p.move_to((0.0, 0.0));
429                    p.line_to(arc.center + kurbo::Vec2::from_angle(arc.start_angle) * r);
430                    arc.to_cubic_beziers(TOLERANCE, |a, b, c| p.curve_to(a, b, c));
431                    p.close_path();
432                } else {
433                    p.move_to(arc.center + kurbo::Vec2::from_angle(arc.start_angle) * r);
434                    arc.to_cubic_beziers(TOLERANCE, |a, b, c| p.curve_to(a, b, c));
435                }
436                Some(p)
437            }
438            NodeKind::Polygon { sides, .. } => {
439                let n = sides.get().round().max(3.0) as usize;
440                Some(regular(n, size.0.max(0.0) / 2.0, None))
441            }
442            NodeKind::Star { points, inner, .. } => {
443                let n = points.get().round().max(2.0) as usize;
444                let r = size.0.max(0.0) / 2.0;
445                Some(regular(n, r, Some(r * inner.get().clamp(0.0, 1.0))))
446            }
447            NodeKind::Path { data } => Some(data.get()),
448            NodeKind::Line {
449                points,
450                closed,
451                smooth,
452                ..
453            } => {
454                let pts = points.get();
455                if *smooth && pts.len() > 2 {
456                    return Some(catmull_rom(&pts, *closed));
457                }
458                let mut p = BezPath::new();
459                for (i, v) in pts.iter().enumerate() {
460                    if i == 0 {
461                        p.move_to(v.to_point());
462                    } else {
463                        p.line_to(v.to_point());
464                    }
465                }
466                if *closed && pts.len() > 2 {
467                    p.close_path();
468                }
469                Some(p)
470            }
471        }
472    }
473}
474
475/// Regular polygon (or star with an inner radius), first vertex at the top.
476fn regular(n: usize, r: f64, inner: Option<f64>) -> BezPath {
477    let count = if inner.is_some() { 2 * n } else { n };
478    let mut p = BezPath::new();
479    for i in 0..count {
480        let radius = match inner {
481            Some(ri) if i % 2 == 1 => ri,
482            _ => r,
483        };
484        let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * i as f64 / count as f64;
485        let pt = Point::new(a.cos() * radius, a.sin() * radius);
486        if i == 0 {
487            p.move_to(pt);
488        } else {
489            p.line_to(pt);
490        }
491    }
492    p.close_path();
493    p
494}
495
496/// Smooth curve through the points (uniform Catmull-Rom as cubic Béziers).
497fn catmull_rom(pts: &[Vec2], closed: bool) -> BezPath {
498    let n = pts.len();
499    let at = |i: isize| -> Vec2 {
500        if closed {
501            pts[i.rem_euclid(n as isize) as usize]
502        } else {
503            pts[i.clamp(0, n as isize - 1) as usize]
504        }
505    };
506    let mut p = BezPath::new();
507    p.move_to(pts[0].to_point());
508    let segs = if closed { n } else { n - 1 };
509    for i in 0..segs as isize {
510        let (p0, p1, p2, p3) = (at(i - 1), at(i), at(i + 1), at(i + 2));
511        let c1 = p1 + (p2 - p0) / 6.0;
512        let c2 = p2 - (p3 - p1) / 6.0;
513        p.curve_to(c1.to_point(), c2.to_point(), p2.to_point());
514    }
515    if closed {
516        p.close_path();
517    }
518    p
519}
520
521pub(crate) enum TextDraw {
522    Styled(Styled),
523    /// Glyphs with their alpha, already centered.
524    Morph {
525        glyphs: Vec<(BezPath, f64)>,
526        size: (f64, f64),
527    },
528    /// A code diff, glyphs in a top-left layout box.
529    Diff(DiffFrame),
530}
531
532/// Arena of nodes for one scene run. Node 0 is the view (root).
533pub struct SceneGraph {
534    nodes: RefCell<Vec<NodeData>>,
535    size: (u32, u32),
536    /// Seconds of the frame last stepped. [`draw_list`](Self::draw_list) paints this time.
537    time: Cell<f64>,
538    /// WGSL problems from the last [`draw_list`](Self::draw_list).
539    shader_diags: RefCell<Vec<crate::shader::ShaderDiag>>,
540}
541
542impl SceneGraph {
543    /// The root node (the scene's view).
544    pub const ROOT: NodeId = NodeId(0);
545
546    /// An empty scene of the given size in pixels, with only the root.
547    pub fn new(width: u32, height: u32) -> Rc<Self> {
548        let graph = Rc::new(Self {
549            nodes: RefCell::new(Vec::new()),
550            size: (width, height),
551            time: Cell::new(0.0),
552            shader_diags: RefCell::new(Vec::new()),
553        });
554        graph.insert(
555            None,
556            NodeKind::Group,
557            crate::BaseProps {
558                name: Some("view".into()),
559                ..Default::default()
560            },
561        );
562        graph
563    }
564
565    /// Scene size in pixels.
566    pub fn size(&self) -> (u32, u32) {
567        self.size
568    }
569
570    /// Time, in seconds, that the next [`draw_list`](Self::draw_list) paints.
571    /// The engine sets this after each frame.
572    pub fn set_time(&self, seconds: f64) {
573        self.time.set(seconds.max(0.0));
574    }
575
576    /// Time the last [`set_time`](Self::set_time) stored.
577    pub fn time(&self) -> f64 {
578        self.time.get()
579    }
580
581    /// Number of nodes, including the root.
582    pub fn len(&self) -> usize {
583        self.nodes.borrow().len()
584    }
585
586    /// Never true: the root always exists.
587    pub fn is_empty(&self) -> bool {
588        self.len() == 0
589    }
590
591    /// Borrows a node's stored state.
592    ///
593    /// # Panics
594    ///
595    /// If `id` is not a node of this graph.
596    pub fn node(&self, id: NodeId) -> Ref<'_, NodeData> {
597        Ref::map(self.nodes.borrow(), |n| &n[id.0 as usize])
598    }
599
600    pub(crate) fn next_id(&self) -> u32 {
601        self.len() as u32
602    }
603
604    pub(crate) fn insert(
605        &self,
606        parent: Option<NodeId>,
607        kind: NodeKind,
608        base: crate::BaseProps,
609    ) -> NodeId {
610        let mut nodes = self.nodes.borrow_mut();
611        let id = NodeId(u32::try_from(nodes.len()).expect("too many nodes"));
612        let label = format!("{}#{}", kind.name(), id.0);
613        let sig = |field: &str| format!("{label}.{field}");
614        nodes.push(NodeData {
615            id,
616            name: base.name,
617            source: current_add_at().map(|(file, line)| source_label(file, line)),
618            parent,
619            children: Vec::new(),
620            x: Signal::new(base.x).with_name(sig("x")),
621            y: Signal::new(base.y).with_name(sig("y")),
622            rotation: Signal::new(base.rotation).with_name(sig("rotation")),
623            scale: Signal::new(base.scale).with_name(sig("scale")),
624            opacity: Signal::new(base.opacity).with_name(sig("opacity")),
625            fill: Signal::new(base.fill).with_name(sig("fill")),
626            stroke: Signal::new(base.stroke).with_name(sig("stroke")),
627            line_width: Signal::new(base.line_width).with_name(sig("line_width")),
628            start: Signal::new(base.start).with_name(sig("start")),
629            end: Signal::new(base.end).with_name(sig("end")),
630            fill_gradient: Signal::new(base.fill_gradient).with_name(sig("fill_gradient")),
631            stroke_gradient: Signal::new(base.stroke_gradient).with_name(sig("stroke_gradient")),
632            dash: Signal::new(base.dash).with_name(sig("dash")),
633            dash_offset: Signal::new(base.dash_offset).with_name(sig("dash_offset")),
634            line_cap: Signal::new(base.line_cap).with_name(sig("line_cap")),
635            line_join: Signal::new(base.line_join).with_name(sig("line_join")),
636            blur: Signal::new(base.blur).with_name(sig("blur")),
637            shadow_color: Signal::new(base.shadow_color).with_name(sig("shadow_color")),
638            shadow_blur: Signal::new(base.shadow_blur).with_name(sig("shadow_blur")),
639            shadow_offset: Signal::new(base.shadow_offset).with_name(sig("shadow_offset")),
640            clip: Signal::new(base.clip).with_name(sig("clip")),
641            blend: Signal::new(base.blend).with_name(sig("blend")),
642            z_index: Signal::new(base.z_index).with_name(sig("z_index")),
643            anchor: Signal::new(base.anchor).with_name(sig("anchor")),
644            grow: Signal::new(base.grow).with_name(sig("grow")),
645            shrink: Signal::new(base.shrink).with_name(sig("shrink")),
646            basis: Signal::new(base.basis).with_name(sig("basis")),
647            margin: Signal::new(base.margin).with_name(sig("margin")),
648            align_self: Signal::new(base.align_self).with_name(sig("align_self")),
649            place: base.place,
650            placed: Cell::new((base.x, base.y)),
651            kind,
652        });
653        if let Some(p) = parent {
654            nodes[p.0 as usize].children.push(id);
655        }
656        id
657    }
658
659    pub(crate) fn detach(&self, id: NodeId) {
660        let mut nodes = self.nodes.borrow_mut();
661        if let Some(p) = nodes[id.0 as usize].parent.take() {
662            nodes[p.0 as usize].children.retain(|c| *c != id);
663        }
664    }
665
666    pub(crate) fn reparent(&self, id: NodeId, parent: NodeId) {
667        self.detach(id);
668        let mut nodes = self.nodes.borrow_mut();
669        nodes[id.0 as usize].parent = Some(parent);
670        nodes[parent.0 as usize].children.push(id);
671    }
672
673    /// Size of a node's box in its own (unscaled) coordinates.
674    pub fn measure(&self, id: NodeId) -> (f64, f64) {
675        self.resolve_places();
676        let nodes = self.nodes.borrow();
677        measure(&nodes, id)
678    }
679
680    pub(crate) fn set_place(&self, id: NodeId, place: crate::place::Place) {
681        self.nodes.borrow_mut()[id.0 as usize].place = Some(place);
682    }
683
684    fn resolve_places(&self) {
685        let nodes = self.nodes.borrow();
686        crate::place::resolve(&nodes, (f64::from(self.size.0), f64::from(self.size.1)));
687    }
688
689    /// Flattened, paint-ordered list of everything to draw.
690    pub fn draw_list(&self) -> Vec<DrawItem> {
691        self.resolve_places();
692        let nodes = self.nodes.borrow();
693        let mut out = Vec::new();
694        crate::shader::clear_diags();
695        for n in nodes.iter() {
696            for diag in crate::shader::problems(n) {
697                crate::shader::push_diag(diag);
698            }
699        }
700        visit(
701            &nodes,
702            Self::ROOT,
703            Affine::IDENTITY,
704            None,
705            None,
706            self.time.get(),
707            &mut out,
708        );
709        *self.shader_diags.borrow_mut() = crate::shader::take_diags();
710        out
711    }
712
713    /// WGSL problems from the last [`draw_list`](Self::draw_list).
714    pub fn shader_diags(&self) -> Vec<crate::shader::ShaderDiag> {
715        self.shader_diags.borrow().clone()
716    }
717
718    /// `Some` when painting this frame twice does not produce the same
719    /// primitives. The string is the node's name, or its `add` call site.
720    pub fn draw_drift(&self) -> Option<(String, Option<String>)> {
721        let a = self.draw_list();
722        let b = self.draw_list();
723        let id = first_drift(&a, &b)?;
724        let n = self.node(id);
725        let label = n
726            .name
727            .clone()
728            .or_else(|| n.source.clone())
729            .unwrap_or_else(|| n.kind.name().to_owned());
730        Some((label, n.source.clone()))
731    }
732
733    /// Topmost node under `p` (scene coordinates), excluding the view.
734    pub fn pick(&self, p: Point) -> Option<NodeId> {
735        self.draw_list()
736            .iter()
737            .rev()
738            .find(|item| item.node != Self::ROOT && item.hit(p))
739            .map(|item| item.node)
740    }
741
742    /// World transform of a node (including layout offsets).
743    pub fn world_transform(&self, id: NodeId) -> Option<Affine> {
744        // The draw list already composes transforms; reuse it for nodes
745        // that draw, and fall back to walking parents for those that don't.
746        if let Some(item) = self.draw_list().into_iter().find(|i| i.node == id) {
747            return Some(item.transform);
748        }
749        let nodes = self.nodes.borrow();
750        let mut tf = Affine::IDENTITY;
751        let mut cur = Some(id);
752        while let Some(c) = cur {
753            let n = nodes.get(c.0 as usize)?;
754            tf = n.local_transform(Vec2::ZERO, measure(&nodes, c)) * tf;
755            cur = n.parent;
756        }
757        Some(tf)
758    }
759
760    /// The box of `id` (its size around its origin) in the coordinate frame
761    /// of `frame`'s children: for a camera, where the node sits in the
762    /// world the camera looks at. Axis-aligned.
763    pub fn box_in(&self, id: NodeId, frame: NodeId) -> Option<kurbo::Rect> {
764        let node_tf = self.world_transform(id)?;
765        let frame_tf = self.world_transform(frame)?;
766        let (w, h) = measure(&self.nodes.borrow(), id);
767        let [x0, y0, x1, y1] = aabb(frame_tf.inverse() * node_tf, w, h);
768        Some(kurbo::Rect::new(x0, y0, x1, y1))
769    }
770
771    /// Frame size in pixels.
772    pub fn frame_size(&self) -> (f64, f64) {
773        (f64::from(self.size.0), f64::from(self.size.1))
774    }
775
776    /// First descendant of `root` (including `root`) whose name is `name`.
777    pub(crate) fn descendant_named(&self, root: NodeId, name: &str) -> Option<NodeId> {
778        fn walk(nodes: &[NodeData], id: NodeId, name: &str) -> Option<NodeId> {
779            let n = nodes.get(id.0 as usize)?;
780            if n.name.as_deref() == Some(name) {
781                return Some(id);
782            }
783            for &c in &n.children {
784                if let Some(hit) = walk(nodes, c, name) {
785                    return Some(hit);
786                }
787            }
788            None
789        }
790        walk(&self.nodes.borrow(), root, name)
791    }
792
793    /// Path nodes under `root`, in paint order.
794    pub(crate) fn descendant_paths(&self, root: NodeId) -> Vec<NodeId> {
795        fn walk(nodes: &[NodeData], id: NodeId, out: &mut Vec<NodeId>) {
796            let Some(n) = nodes.get(id.0 as usize) else {
797                return;
798            };
799            if matches!(n.kind, NodeKind::Path { .. }) {
800                out.push(id);
801            }
802            for &c in &n.children {
803                walk(nodes, c, out);
804            }
805        }
806        let mut out = Vec::new();
807        walk(&self.nodes.borrow(), root, &mut out);
808        out
809    }
810
811    /// `(id, parent, kind, name)` of every attached node, depth-first.
812    pub fn tree(&self) -> Vec<(NodeId, Option<NodeId>, &'static str, Option<String>)> {
813        let nodes = self.nodes.borrow();
814        let mut out = Vec::new();
815        let mut stack = vec![Self::ROOT];
816        while let Some(id) = stack.pop() {
817            let n = &nodes[id.0 as usize];
818            out.push((id, n.parent, n.kind.name(), n.name.clone()));
819            stack.extend(n.children.iter().rev());
820        }
821        out
822    }
823
824    /// Current signal values of a node, for the inspector.
825    pub fn describe(&self, id: NodeId) -> Vec<(String, String)> {
826        let nodes = self.nodes.borrow();
827        let Some(n) = nodes.get(id.0 as usize) else {
828            return Vec::new();
829        };
830        let f = |v: f64| format!("{v:.3}");
831        let s = n.scale.get();
832        let mut out = vec![
833            ("x".into(), f(n.x.get())),
834            ("y".into(), f(n.y.get())),
835            ("rotation".into(), f(n.rotation.get())),
836            ("scale".into(), format!("{:.3}, {:.3}", s.x, s.y)),
837            ("opacity".into(), f(n.opacity.get())),
838            ("fill".into(), n.fill.get().to_string()),
839            ("stroke".into(), n.stroke.get().to_string()),
840            ("line_width".into(), f(n.line_width.get())),
841            ("start".into(), f(n.start.get())),
842            ("end".into(), f(n.end.get())),
843        ];
844        let (w, h) = measure(&nodes, id);
845        out.push(("size".into(), format!("{w:.1} × {h:.1}")));
846        match &n.kind {
847            NodeKind::Rect { radius, .. } => out.push(("radius".into(), f(radius.get()))),
848            NodeKind::Code { code, .. } => out.push(("code".into(), code.get())),
849            NodeKind::Txt {
850                text,
851                font_size,
852                weight,
853                ..
854            } => {
855                out.push(("text".into(), text.get()));
856                out.push(("font_size".into(), f(font_size.get())));
857                out.push(("weight".into(), f(weight.get())));
858            }
859            NodeKind::Line { points, .. } => {
860                out.push(("points".into(), points.get().len().to_string()))
861            }
862            NodeKind::Layout { gap, padding, .. } => {
863                out.push(("gap".into(), f(gap.get())));
864                out.push(("padding".into(), f(padding.get())));
865            }
866            NodeKind::Grid { spacing, .. } => out.push(("spacing".into(), f(spacing.get()))),
867            _ => {}
868        }
869        out
870    }
871
872    /// One record per attached node, in tree order, with scene-space bounds.
873    ///
874    /// `order` is paint order (0 is the back). Nodes that are not drawn
875    /// (fully transparent) get `u32::MAX`.
876    pub fn inspect_nodes(&self) -> Vec<InspectNode> {
877        let list = self.draw_list();
878        let mut drawn: Vec<Option<(Affine, u32)>> = vec![None; self.len()];
879        let mut order = 0u32;
880        for item in &list {
881            let slot = &mut drawn[item.node.0 as usize];
882            if slot.is_none() {
883                *slot = Some((item.transform, order));
884                order += 1;
885            }
886        }
887        let nodes = self.nodes.borrow();
888        let mut out = Vec::new();
889        let mut stack = vec![Self::ROOT];
890        while let Some(id) = stack.pop() {
891            let n = &nodes[id.0 as usize];
892            stack.extend(n.children.iter().rev());
893            let (w, h) = measure(&nodes, id);
894            let (tf, order) = drawn[id.0 as usize]
895                .unwrap_or_else(|| (transform_without_layout(&nodes, id), u32::MAX));
896            let (text, text_overflow) = text_info(n);
897            out.push(InspectNode {
898                id: id.0,
899                parent: n.parent.map(|p| p.0),
900                kind: n.kind.name(),
901                name: n.name.clone().unwrap_or_else(|| {
902                    n.source
903                        .clone()
904                        .unwrap_or_else(|| format!("{}#{}", n.kind.name(), id.0))
905                }),
906                source: n.source.clone(),
907                bounds: aabb(tf, w, h),
908                opacity: n.opacity.get().clamp(0.0, 1.0),
909                order,
910                text,
911                fill: n.fill.get(),
912                text_overflow,
913                parent_kind: n.parent.map(|p| nodes[p.0 as usize].kind.name()),
914                in_camera: {
915                    let mut up = n.parent;
916                    let mut found = false;
917                    while let Some(p) = up {
918                        let pn = &nodes[p.0 as usize];
919                        if matches!(pn.kind, NodeKind::Camera { .. }) {
920                            found = true;
921                            break;
922                        }
923                        up = pn.parent;
924                    }
925                    found
926                },
927            });
928        }
929        out
930    }
931}
932
933/// A node as `oxframe inspect` and the visual linter see it.
934#[derive(Debug, Clone, PartialEq)]
935pub struct InspectNode {
936    /// Node index.
937    pub id: u32,
938    /// Parent index; `None` for the root.
939    pub parent: Option<u32>,
940    /// Node type, e.g. `"Rect"`.
941    pub kind: &'static str,
942    /// `.name(..)`, or the `add` call site, or `Kind#id`.
943    pub name: String,
944    /// `file:line` of the `add` call, when it was recorded.
945    pub source: Option<String>,
946    /// Axis-aligned bounds in scene coordinates: min x, min y, max x, max y.
947    /// The origin is the center of the frame, y points down.
948    pub bounds: [f64; 4],
949    /// 0 (invisible) to 1.
950    pub opacity: f64,
951    /// Paint order among drawn nodes; `u32::MAX` if the node is not drawn.
952    pub order: u32,
953    /// Text of a `Txt` or `Code` node.
954    pub text: Option<String>,
955    /// Fill color.
956    pub fill: Color,
957    /// How many pixels the text is wider than its `max_width` (0 if it fits
958    /// or `max_width` is unset).
959    pub text_overflow: f64,
960    /// Type of the parent, if any.
961    pub parent_kind: Option<&'static str>,
962    /// Seen through a [`Camera`](crate::Camera): leaving the frame is then
963    /// usually the camera's framing, not a layout mistake.
964    pub in_camera: bool,
965}
966
967thread_local! {
968    static ADD_AT: Cell<Option<(&'static str, u32)>> = const { Cell::new(None) };
969}
970
971pub(crate) fn push_add_at(file: &'static str, line: u32) -> Option<(&'static str, u32)> {
972    ADD_AT.with(|c| c.replace(Some((file, line))))
973}
974
975pub(crate) fn pop_add_at(prev: Option<(&'static str, u32)>) {
976    ADD_AT.with(|c| c.set(prev));
977}
978
979fn current_add_at() -> Option<(&'static str, u32)> {
980    ADD_AT.with(|c| c.get())
981}
982
983/// `src/main.rs:42` rather than an absolute build path.
984fn source_label(file: &str, line: u32) -> String {
985    let file = file.replace('\\', "/");
986    let rel = file
987        .rfind("/src/")
988        .map_or(file.as_str(), |i| &file[i + 1..]);
989    format!("{rel}:{line}")
990}
991
992fn aabb(tf: Affine, w: f64, h: f64) -> [f64; 4] {
993    let pts = [
994        (-w / 2.0, -h / 2.0),
995        (w / 2.0, -h / 2.0),
996        (w / 2.0, h / 2.0),
997        (-w / 2.0, h / 2.0),
998    ];
999    let mut b = [
1000        f64::INFINITY,
1001        f64::INFINITY,
1002        f64::NEG_INFINITY,
1003        f64::NEG_INFINITY,
1004    ];
1005    for (x, y) in pts {
1006        let p = tf * Point::new(x, y);
1007        b[0] = b[0].min(p.x);
1008        b[1] = b[1].min(p.y);
1009        b[2] = b[2].max(p.x);
1010        b[3] = b[3].max(p.y);
1011    }
1012    b
1013}
1014
1015fn transform_without_layout(nodes: &[NodeData], id: NodeId) -> Affine {
1016    let mut chain = Vec::new();
1017    let mut cur = Some(id);
1018    while let Some(c) = cur {
1019        chain.push(c);
1020        cur = nodes.get(c.0 as usize).and_then(|n| n.parent);
1021    }
1022    let mut tf = Affine::IDENTITY;
1023    for c in chain.into_iter().rev() {
1024        let Some(n) = nodes.get(c.0 as usize) else {
1025            break;
1026        };
1027        tf *= n.local_transform(Vec2::ZERO, measure(nodes, c));
1028    }
1029    tf
1030}
1031
1032fn text_info(n: &NodeData) -> (Option<String>, f64) {
1033    let (body, max_width) = match &n.kind {
1034        NodeKind::Txt {
1035            text, spans, style, ..
1036        } => {
1037            let spans = spans.get();
1038            let body = if spans.is_empty() {
1039                text.get()
1040            } else {
1041                spans.into_iter().map(|s| s.text).collect()
1042            };
1043            (body, style.max_width)
1044        }
1045        NodeKind::Code { code, style, .. } => (code.get(), style.max_width),
1046        _ => return (None, 0.0),
1047    };
1048    let overflow = match (max_width, n.text_draw()) {
1049        (Some(max), Some(TextDraw::Styled(s))) => (s.layout.width - max).max(0.0),
1050        (Some(max), Some(TextDraw::Morph { size, .. })) => (size.0 - max).max(0.0),
1051        (Some(max), Some(TextDraw::Diff(frame))) => (frame.size.0 - max).max(0.0),
1052        _ => 0.0,
1053    };
1054    (Some(body), overflow)
1055}
1056
1057pub(crate) fn measure(nodes: &[NodeData], id: NodeId) -> (f64, f64) {
1058    let n = &nodes[id.0 as usize];
1059    match &n.kind {
1060        NodeKind::Rect { width, height, .. } => (width.get(), height.get()),
1061        NodeKind::Circle { size, .. }
1062        | NodeKind::Polygon { size, .. }
1063        | NodeKind::Star { size, .. } => (size.get(), size.get()),
1064        NodeKind::Img { width, height, .. }
1065        | NodeKind::Grid { width, height, .. }
1066        | NodeKind::Svg { width, height } => (width.get(), height.get()),
1067        NodeKind::Txt { .. } | NodeKind::Code { .. } => match n.text_draw() {
1068            Some(TextDraw::Styled(s)) => (s.layout.width, s.layout.height),
1069            Some(TextDraw::Morph { size, .. }) => size,
1070            Some(TextDraw::Diff(frame)) => frame.size,
1071            None => (0.0, 0.0),
1072        },
1073        NodeKind::Path { .. } | NodeKind::Line { .. } => {
1074            let bb = n
1075                .outline((0.0, 0.0))
1076                .map_or(kurbo::Rect::ZERO, |p| p.bounding_box());
1077            (bb.width(), bb.height())
1078        }
1079        NodeKind::Layout { .. } => crate::flex::compute(nodes, id, &|c| measure(nodes, c)).0,
1080        // A window onto its children, not a shape: no box of its own.
1081        NodeKind::Camera { .. } | NodeKind::Particles { .. } => (0.0, 0.0),
1082        NodeKind::Custom { width, height, .. } => (*width, *height),
1083        NodeKind::Shader { width, height, .. } => (width.get(), height.get()),
1084        NodeKind::Group => {
1085            let mut bb: Option<kurbo::Rect> = None;
1086            for &c in &n.children {
1087                let child = &nodes[c.0 as usize];
1088                let (w, h) = measure(nodes, c);
1089                let r = child
1090                    .local_transform(Vec2::ZERO, (w, h))
1091                    .transform_rect_bbox(kurbo::Rect::new(-w / 2.0, -h / 2.0, w / 2.0, h / 2.0));
1092                bb = Some(bb.map_or(r, |b| b.union(r)));
1093            }
1094            bb.map_or((0.0, 0.0), |b| (b.width(), b.height()))
1095        }
1096    }
1097}
1098
1099/// Kinds whose drawn size follows the box a layout gives them.
1100fn resizable(kind: &NodeKind) -> bool {
1101    matches!(
1102        kind,
1103        NodeKind::Rect { .. }
1104            | NodeKind::Layout { .. }
1105            | NodeKind::Img { .. }
1106            | NodeKind::Grid { .. }
1107            | NodeKind::Svg { .. }
1108            | NodeKind::Shader { .. }
1109    )
1110}
1111
1112fn first_drift(a: &[DrawItem], b: &[DrawItem]) -> Option<NodeId> {
1113    let n = a.len().max(b.len());
1114    for i in 0..n {
1115        match (a.get(i), b.get(i)) {
1116            (Some(x), Some(y)) if x.node == y.node && prim_same(&x.prim, &y.prim) => {}
1117            (Some(x), _) => return Some(x.node),
1118            (_, Some(y)) => return Some(y.node),
1119            (None, None) => {}
1120        }
1121    }
1122    None
1123}
1124
1125fn prim_same(a: &Primitive, b: &Primitive) -> bool {
1126    use Primitive::{Fill, Image, PopLayer, PushLayer, Stroke};
1127    match (a, b) {
1128        (Fill { path: p, paint: c }, Fill { path: q, paint: d }) => p == q && c == d,
1129        (
1130            Stroke {
1131                path: p,
1132                paint: c,
1133                style: s,
1134            },
1135            Stroke {
1136                path: q,
1137                paint: d,
1138                style: t,
1139            },
1140        ) => p == q && c == d && s == t,
1141        (
1142            Image {
1143                rect: r,
1144                opacity: o,
1145                ..
1146            },
1147            Image {
1148                rect: s,
1149                opacity: p,
1150                ..
1151            },
1152        ) => r == s && o == p,
1153        (PushLayer(x), PushLayer(y)) => x == y,
1154        (PopLayer, PopLayer) => true,
1155        _ => false,
1156    }
1157}
1158
1159fn visit(
1160    nodes: &[NodeData],
1161    id: NodeId,
1162    parent_tf: Affine,
1163    placement: Option<FlexBox>,
1164    flex: Option<&FlexMap>,
1165    time: f64,
1166    out: &mut Vec<DrawItem>,
1167) {
1168    let n = &nodes[id.0 as usize];
1169    let opacity = n.opacity.get().clamp(0.0, 1.0);
1170    if opacity <= 0.0 {
1171        return;
1172    }
1173    let size = match placement {
1174        Some(b) if resizable(&n.kind) => {
1175            let s = n.scale.get();
1176            (
1177                b.size.0 / s.x.abs().max(1e-9),
1178                b.size.1 / s.y.abs().max(1e-9),
1179            )
1180        }
1181        _ => measure(nodes, id),
1182    };
1183    let offset = placement.map_or(Vec2::ZERO, |b| b.center);
1184    let tf = parent_tf * n.local_transform(offset, size);
1185
1186    // A layer is needed when the node's effects apply to it and its children
1187    // as a whole; plain leaves just fold opacity into their paints.
1188    let blend = n.blend.get();
1189    let blur = n.blur.get().max(0.0);
1190    let shadow_color = n.shadow_color.get();
1191    let shadow = (shadow_color.a > 0.0).then(|| Shadow {
1192        color: shadow_color,
1193        blur: n.shadow_blur.get().max(0.0),
1194        offset: n.shadow_offset.get(),
1195    });
1196    let layered = blend != BlendMode::Normal
1197        || blur > 0.0
1198        || shadow.is_some()
1199        || (opacity < 1.0 && !n.children.is_empty());
1200    if layered {
1201        out.push(DrawItem {
1202            node: id,
1203            transform: tf,
1204            prim: Primitive::PushLayer(Layer {
1205                opacity,
1206                blend,
1207                clip: None,
1208                blur,
1209                shadow,
1210            }),
1211        });
1212    }
1213    let paint_opacity = if layered { 1.0 } else { opacity };
1214    for prim in crate::draw::primitives(n, size, paint_opacity, time) {
1215        out.push(DrawItem {
1216            node: id,
1217            transform: tf,
1218            prim,
1219        });
1220    }
1221    if let NodeKind::Shader { .. } = &n.kind
1222        && crate::shader::problems(n).is_empty()
1223        && let Some(prim) = crate::shader::raster(n, size, tf, time, paint_opacity)
1224    {
1225        out.push(DrawItem {
1226            node: id,
1227            transform: tf,
1228            prim,
1229        });
1230    }
1231
1232    if !n.children.is_empty() {
1233        // Nodes without an outline (groups, text, images) clip to their box.
1234        let clip = n.clip.get().then(|| {
1235            n.outline(size).unwrap_or_else(|| {
1236                kurbo::Rect::new(-size.0 / 2.0, -size.1 / 2.0, size.0 / 2.0, size.1 / 2.0)
1237                    .to_path(0.1)
1238            })
1239        });
1240        let clipped = clip.is_some();
1241        if let Some(path) = clip {
1242            out.push(DrawItem {
1243                node: id,
1244                transform: tf,
1245                prim: Primitive::PushLayer(Layer::clip(path)),
1246            });
1247        }
1248        // Boxes of the children: from the enclosing layout tree, or a new
1249        // tree when this is a top-level layout.
1250        let own;
1251        let map: Option<&FlexMap> = match (&n.kind, flex) {
1252            (NodeKind::Layout { .. }, Some(m)) => Some(m),
1253            (NodeKind::Layout { .. }, None) => {
1254                own = crate::flex::compute(nodes, id, &|c| measure(nodes, c)).1;
1255                Some(&own)
1256            }
1257            _ => None,
1258        };
1259        let mut order: Vec<(usize, NodeId)> = n.children.iter().copied().enumerate().collect();
1260        // Stable: equal z keeps insertion order.
1261        order.sort_by(|a, b| {
1262            let za = nodes[a.1.0 as usize].z_index.get();
1263            let zb = nodes[b.1.0 as usize].z_index.get();
1264            za.total_cmp(&zb)
1265        });
1266        for (_, c) in order {
1267            let placement = map.and_then(|m| m.get(&c).copied());
1268            visit(nodes, c, tf, placement, map, time, out);
1269        }
1270        if clipped {
1271            out.push(DrawItem {
1272                node: id,
1273                transform: tf,
1274                prim: Primitive::PopLayer,
1275            });
1276        }
1277    }
1278    if layered {
1279        out.push(DrawItem {
1280            node: id,
1281            transform: tf,
1282            prim: Primitive::PopLayer,
1283        });
1284    }
1285}
1286
1287impl DrawItem {
1288    fn hit(&self, p: Point) -> bool {
1289        let local = self.transform.inverse() * p;
1290        match &self.prim {
1291            Primitive::Fill { path, .. } => {
1292                (is_text_like(path) && path.bounding_box().contains(local))
1293                    || closed(path).contains(local)
1294            }
1295            Primitive::Stroke { path, style, .. } => {
1296                let tol = (style.width / 2.0)
1297                    .max(4.0 / self.transform.determinant().abs().sqrt().max(1e-9));
1298                path.segments().any(|s| {
1299                    kurbo::ParamCurveNearest::nearest(&s, local, 0.1).distance_sq <= tol * tol
1300                })
1301            }
1302            Primitive::Image { rect, .. } => rect.contains(local),
1303            Primitive::PushLayer(_) | Primitive::PopLayer => false,
1304        }
1305    }
1306}
1307
1308/// Fills implicitly close open subpaths; kurbo's winding does not.
1309fn closed(path: &BezPath) -> BezPath {
1310    let mut out = BezPath::new();
1311    let mut open = false;
1312    for el in path.elements() {
1313        match el {
1314            kurbo::PathEl::MoveTo(_) => {
1315                if open {
1316                    out.close_path();
1317                }
1318                open = true;
1319            }
1320            kurbo::PathEl::ClosePath => open = false,
1321            _ => {}
1322        }
1323        out.push(*el);
1324    }
1325    if open {
1326        out.close_path();
1327    }
1328    out
1329}
1330
1331/// Glyph runs are hard to hit exactly; treat many-subpath fills (text) by
1332/// their bounding box.
1333fn is_text_like(path: &BezPath) -> bool {
1334    path.elements()
1335        .iter()
1336        .filter(|e| matches!(e, kurbo::PathEl::MoveTo(_)))
1337        .count()
1338        > 3
1339}
1340
1341thread_local! {
1342    static CURRENT: RefCell<Option<Rc<SceneGraph>>> = const { RefCell::new(None) };
1343}
1344
1345impl SceneGraph {
1346    /// Makes this graph the target of node handles for the duration of `f`.
1347    pub fn enter<R>(self: &Rc<Self>, f: impl FnOnce() -> R) -> R {
1348        let prev = CURRENT.with(|c| c.borrow_mut().replace(self.clone()));
1349        struct Restore(Option<Rc<SceneGraph>>);
1350        impl Drop for Restore {
1351            fn drop(&mut self) {
1352                let prev = self.0.take();
1353                CURRENT.with(|c| *c.borrow_mut() = prev);
1354            }
1355        }
1356        let _restore = Restore(prev);
1357        f()
1358    }
1359}
1360
1361pub(crate) fn with_graph<R>(f: impl FnOnce(&SceneGraph) -> R) -> R {
1362    CURRENT.with(|c| {
1363        let c = c.borrow();
1364        f(c.as_ref()
1365            .expect("oxframe: node handles can only be used while a scene is running"))
1366    })
1367}